From 7157c85c74ee16ab451e694e1ab8cf94a8f9b586 Mon Sep 17 00:00:00 2001 From: jiwaszki Date: Tue, 2 Jan 2024 03:55:39 +0100 Subject: [PATCH 1/6] Add simple benchmark POC to compare popular libraries --- .github/workflows/linters.yml | 2 +- benchmark/benchmark.py | 151 ++++++++++++++++++++++++++++++++++ src/fastwave/impl.py | 2 +- 3 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 benchmark/benchmark.py diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 82c585c..9839cf9 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -25,4 +25,4 @@ jobs: pdm install --no-lock --no-self --no-default -G linters - name: Run flake8 run: | - pdm run -v flake8 src/ tests/ + pdm run -v flake8 src/ tests/ benchmark/ diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py new file mode 100644 index 0000000..5a5a21a --- /dev/null +++ b/benchmark/benchmark.py @@ -0,0 +1,151 @@ +import os +import numpy as np +import datetime +import random +import string +import functools +import timeit + +import fastwave +import torchaudio +from scipy.io import wavfile +import pydub +import wave + + +class AudioGenerator: + # Duration is in seconds! + def __init__( + self, sample_rate=44100, duration=5, channels=2, prefix="random_audio_" + ): + self.sample_rate = sample_rate + self.duration = duration + self.channels = channels + self.file_name = self.generate_random_name(prefix) + self.file_path = os.path.join(os.getcwd(), self.file_name) + # Run generation at init! + self.generate_scipy_audio() + + def generate_scipy_audio(self): + if self.channels not in [1, 2]: + raise RuntimeError("Unsupported number of channels!") + + noises = [ + np.random.normal(0, 1, int(self.sample_rate * self.duration)) + for _ in range(self.channels) + ] + audio_data = np.column_stack(noises) if self.channels == 2 else noises[0] + scaled_audio_data = (audio_data * 32767).astype(np.int16) + wavfile.write(self.file_path, self.sample_rate, scaled_audio_data) + + def delete_generated_file(self): + if os.path.exists(self.file_path): + os.remove(self.file_path) + # print(f"Deleted file: {self.file_path}") + + def generate_random_name(self, prefix): + current_datetime = datetime.datetime.now() + random_suffix = "".join( + random.choices(string.ascii_uppercase + string.digits, k=3) + ) + return ( + f"{prefix}{current_datetime.strftime('%Y%m%d%H%M%S')}_{random_suffix}.wav" + ) + + +def benchmark_fastwave_default(audio_generator): + audio = fastwave.read(audio_generator.file_path, mode=fastwave.ReadMode.DEFAULT) + # audio_data = fastwave.convert_data(audio.data, dtype=np.float32) + audio_data = audio.data.astype("float32") / 32767.0 + return audio_data + + +def benchmark_fastwave_threads(audio_generator): + audio = fastwave.read( + audio_generator.file_path, mode=fastwave.ReadMode.THREADS, num_threads=6 + ) + # audio_data = fastwave.convert_data(audio.data, dtype=np.float32) + audio_data = audio.data.astype("float32") / 32767.0 + return audio_data + + +def benchmark_fastwave_mmap_private(audio_generator): + audio = fastwave.read( + audio_generator.file_path, mode=fastwave.ReadMode.MMAP_PRIVATE + ) + # audio_data = fastwave.convert_data(audio.data, dtype=np.float32) + audio_data = audio.data.astype("float32") / 32767.0 + return audio_data + + +def benchmark_fastwave_mmap_shared(audio_generator): + audio = fastwave.read(audio_generator.file_path, mode=fastwave.ReadMode.MMAP_SHARED) + # audio_data = fastwave.convert_data(audio.data, dtype=np.float32) + audio_data = audio.data.astype("float32") / 32767.0 + return audio_data + + +def benchmark_native_python(audio_generator): + w = wave.open(audio_generator.file_path, "rb") + audio = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).reshape(-1, 2) + audio_data = audio.astype("float32") / 32767.0 + return audio_data + + +def benchmark_pydub(audio_generator): + song = pydub.AudioSegment.from_file(audio_generator.file_path) + sig = np.asarray(song.get_array_of_samples(), dtype="float32") + sig = sig.reshape(song.channels, -1) / 32767.0 + return sig + + +def benchmark_torchaudio(audio_generator): + sig, _ = torchaudio.load( + audio_generator.file_path, normalize=True, channels_first=False + ) + return sig + + +def benchmark_scipy_default(audio_generator): + _, sig = wavfile.read(audio_generator.file_path) + sig = sig.astype("float32") / 32767.0 + return sig + + +def benchmark_scipy_mmap(audio_generator): + _, sig = wavfile.read(audio_generator.file_path, mmap=True) + sig = sig.astype("float32") / 32767.0 + return sig + + +if __name__ == "__main__": + audio_generator = AudioGenerator(sample_rate=44100, duration=30 * 10) + # print(f"Generated file: {audio_generator.file_path}") + + ITERATIONS = 10 + REPS = 5 + + methods = { + "fastwave_DEFAULT": benchmark_fastwave_default, + "fastwave_THREADS": benchmark_fastwave_threads, + "fastwave_MMAP_PRIVATE": benchmark_fastwave_mmap_private, + "fastwave_MMAP_SHARED": benchmark_fastwave_mmap_shared, + "native_python": benchmark_native_python, + "pydub": benchmark_pydub, + "torchaudio": benchmark_torchaudio, + "scipy_default": benchmark_scipy_default, + "scipy_mmap": benchmark_scipy_mmap, + } + + ITERATIONS = 10 + REPS = 5 + + for method_name, method_func in methods.items(): + execution_time = timeit.repeat( + functools.partial(method_func, audio_generator), + number=ITERATIONS, + repeat=REPS, + ) + print(f"{method_name}: {min(execution_time)} seconds") + + audio_generator.delete_generated_file() diff --git a/src/fastwave/impl.py b/src/fastwave/impl.py index 2f2235f..90c2c67 100644 --- a/src/fastwave/impl.py +++ b/src/fastwave/impl.py @@ -68,7 +68,7 @@ def read( # This will not release the GIL but give native wrapper # Downside is that it needs some kind of heuristic first # to determine when this method is better. - # TODO: is it worth it to sacrifies GIL release?ł + # TODO: is it worth it to sacrifies GIL release? # import wave # import numpy as np # w = wave.open("./test.wav", "rb") From f18ed02bc70bab1e84f9f2a867d4a6defb633b2c Mon Sep 17 00:00:00 2001 From: jiwaszki Date: Tue, 2 Jan 2024 04:16:14 +0100 Subject: [PATCH 2/6] Add librosa in benchmarks --- benchmark/benchmark.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 5a5a21a..1498a57 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -8,6 +8,7 @@ import fastwave import torchaudio +import librosa from scipy.io import wavfile import pydub import wave @@ -35,8 +36,8 @@ def generate_scipy_audio(self): for _ in range(self.channels) ] audio_data = np.column_stack(noises) if self.channels == 2 else noises[0] - scaled_audio_data = (audio_data * 32767).astype(np.int16) - wavfile.write(self.file_path, self.sample_rate, scaled_audio_data) + audio_data = (audio_data * 32767).astype(np.int16) + wavfile.write(self.file_path, self.sample_rate, audio_data) def delete_generated_file(self): if os.path.exists(self.file_path): @@ -103,6 +104,8 @@ def benchmark_torchaudio(audio_generator): sig, _ = torchaudio.load( audio_generator.file_path, normalize=True, channels_first=False ) + # Already as part of torchaudio.load under `normalize` + # sig = sig.astype("float32") / 32767.0 return sig @@ -118,9 +121,18 @@ def benchmark_scipy_mmap(audio_generator): return sig +def benchmark_librosa(audio_generator): + sig, _ = librosa.load(audio_generator.file_path, sr=None, dtype=np.float32) + # Already as part of librosa.load under `dtype` + # sig = sig.astype("float32") / 32767.0 + return sig.T if sig.ndim == 2 else sig + + if __name__ == "__main__": - audio_generator = AudioGenerator(sample_rate=44100, duration=30 * 10) - # print(f"Generated file: {audio_generator.file_path}") + audio_generator = AudioGenerator(sample_rate=44100, duration=60 * 10, channels=1) + print(f"Generated file: {audio_generator.file_path}") + print(f"Duration: {audio_generator.duration} seconds") + print(f"Channels: {audio_generator.channels}") ITERATIONS = 10 REPS = 5 @@ -135,6 +147,7 @@ def benchmark_scipy_mmap(audio_generator): "torchaudio": benchmark_torchaudio, "scipy_default": benchmark_scipy_default, "scipy_mmap": benchmark_scipy_mmap, + "librosa": benchmark_librosa, } ITERATIONS = 10 From f038b235f545dfe9eebb4defb50beaba544a3c88 Mon Sep 17 00:00:00 2001 From: jiwaszki Date: Tue, 2 Jan 2024 04:44:20 +0100 Subject: [PATCH 3/6] Add plotting --- benchmark/benchmark.py | 32 +++++++++++++++++++++++++++----- 1 file changed, 27 insertions(+), 5 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 1498a57..6360350 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -5,7 +5,10 @@ import string import functools import timeit - +# Plotting +import matplotlib.pyplot as plt +import seaborn as sns +# Reference libs import fastwave import torchaudio import librosa @@ -129,7 +132,7 @@ def benchmark_librosa(audio_generator): if __name__ == "__main__": - audio_generator = AudioGenerator(sample_rate=44100, duration=60 * 10, channels=1) + audio_generator = AudioGenerator(sample_rate=44100, duration=60 * 30, channels=1) print(f"Generated file: {audio_generator.file_path}") print(f"Duration: {audio_generator.duration} seconds") print(f"Channels: {audio_generator.channels}") @@ -143,22 +146,41 @@ def benchmark_librosa(audio_generator): "fastwave_MMAP_PRIVATE": benchmark_fastwave_mmap_private, "fastwave_MMAP_SHARED": benchmark_fastwave_mmap_shared, "native_python": benchmark_native_python, - "pydub": benchmark_pydub, + "librosa": benchmark_librosa, "torchaudio": benchmark_torchaudio, + "pydub": benchmark_pydub, "scipy_default": benchmark_scipy_default, "scipy_mmap": benchmark_scipy_mmap, - "librosa": benchmark_librosa, } ITERATIONS = 10 REPS = 5 + execution_times = [] + for method_name, method_func in methods.items(): execution_time = timeit.repeat( functools.partial(method_func, audio_generator), number=ITERATIONS, repeat=REPS, ) - print(f"{method_name}: {min(execution_time)} seconds") + min_execution_time = min(execution_time) + print(f"{method_name}: {min_execution_time} seconds") + execution_times.append(min_execution_time) audio_generator.delete_generated_file() + + # Plot the benchmark results + plt.figure(figsize=(10, 6)) + palette = sns.color_palette("husl", len(list(methods.keys()))) + bars = plt.barh(list(methods.keys()), execution_times, color=palette) + plt.title(f"Benchmark Results (wav, length: {audio_generator.duration} seconds, channel number: {audio_generator.channels} )") + plt.xlabel("Execution Time (seconds, lower is better)") + plt.ylabel("Library and method") + + # Add legend + plt.legend(bars, methods, loc='upper right') + plt.tight_layout() + + # Show the plot + plt.show() From c81d4b9db479d334f733e9e3579a07082108e32c Mon Sep 17 00:00:00 2001 From: jiwaszki Date: Tue, 2 Jan 2024 04:44:37 +0100 Subject: [PATCH 4/6] Add dependencies to requirements group --- pyproject.toml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 7c04f77..27f3ca1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,15 @@ tests = [ "numpy >= 1.24.4", "scipy", ] +benchmark = [ + "numpy >= 1.24.4", + "scipy", + "torchaudio", + "librosa", + "pydub", + "matplotlib", + "seaborn == 0.13.0", +] [tool.pdm.scripts] # TODO: From dbe5846e638d0c97ec544e7efeab67db22182876 Mon Sep 17 00:00:00 2001 From: jiwaszki Date: Tue, 2 Jan 2024 05:22:29 +0100 Subject: [PATCH 5/6] Add gil notebook --- benchmark/benchmark.py | 1 + benchmark/benchmark_gil.ipynb | 303 ++++++++++++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 benchmark/benchmark_gil.ipynb diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 6360350..9a7b4a7 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -132,6 +132,7 @@ def benchmark_librosa(audio_generator): if __name__ == "__main__": + # TODO: add benchmarks for `info` function audio_generator = AudioGenerator(sample_rate=44100, duration=60 * 30, channels=1) print(f"Generated file: {audio_generator.file_path}") print(f"Duration: {audio_generator.duration} seconds") diff --git a/benchmark/benchmark_gil.ipynb b/benchmark/benchmark_gil.ipynb new file mode 100644 index 0000000..d1e31c0 --- /dev/null +++ b/benchmark/benchmark_gil.ipynb @@ -0,0 +1,303 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Showcase GIL releasing benefits of `fastwave`" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import os\n", + "import datetime\n", + "import random\n", + "import string\n", + "import numpy as np\n", + "from scipy.io import wavfile\n", + "\n", + "class AudioGenerator:\n", + " # Duration is in seconds!\n", + " def __init__(\n", + " self, sample_rate=44100, duration=5, channels=2, prefix=\"random_audio_\"\n", + " ):\n", + " self.sample_rate = sample_rate\n", + " self.duration = duration\n", + " self.channels = channels\n", + " self.file_name = self.generate_random_name(prefix)\n", + " self.file_path = os.path.join(os.getcwd(), self.file_name)\n", + " # Run generation at init!\n", + " self.generate_scipy_audio()\n", + "\n", + " def generate_scipy_audio(self):\n", + " if self.channels not in [1, 2]:\n", + " raise RuntimeError(\"Unsupported number of channels!\")\n", + "\n", + " noises = [\n", + " np.random.normal(0, 1, int(self.sample_rate * self.duration))\n", + " for _ in range(self.channels)\n", + " ]\n", + " audio_data = np.column_stack(noises) if self.channels == 2 else noises[0]\n", + " audio_data = (audio_data * 32767).astype(np.int16)\n", + " wavfile.write(self.file_path, self.sample_rate, audio_data)\n", + "\n", + " def delete_generated_file(self):\n", + " if os.path.exists(self.file_path):\n", + " os.remove(self.file_path)\n", + " # print(f\"Deleted file: {self.file_path}\")\n", + "\n", + " def generate_random_name(self, prefix):\n", + " current_datetime = datetime.datetime.now()\n", + " random_suffix = \"\".join(\n", + " random.choices(string.ascii_uppercase + string.digits, k=3)\n", + " )\n", + " return (\n", + " f\"{prefix}{current_datetime.strftime('%Y%m%d%H%M%S')}_{random_suffix}.wav\"\n", + " )" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "import time\n", + "import threading\n", + "from queue import Queue\n", + "\n", + "def computation_thread(number):\n", + " # Simulating some computation time, should be lower/equal to \"regular read\"\n", + " time.sleep(0.1) \n", + " return number" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "import fastwave\n", + "\n", + "def read_and_compute_fastwave(file_path):\n", + "\n", + " # Create a queue to communicate between threads\n", + " result_queue = Queue()\n", + "\n", + " # Read data in a separate thread\n", + " def read_thread():\n", + " nonlocal audio_data\n", + " audio_data = fastwave.read(file_path, mode=fastwave.ReadMode.DEFAULT)\n", + " result_queue.put(audio_data) # Put the result in the queue\n", + "\n", + " thread = threading.Thread(target=read_thread)\n", + " thread.start()\n", + "\n", + " # Perform additional computations in the main thread\n", + " additional_result = computation_thread(42)\n", + "\n", + " # Wait for the thread to finish\n", + " thread.join()\n", + " # Access the data read by the thread from the queue\n", + " audio_data = result_queue.get()\n", + "\n", + " return audio_data, additional_result" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "from scipy.io import wavfile\n", + "\n", + "def read_and_compute_scipy(file_path):\n", + "\n", + " # Create a queue to communicate between threads\n", + " result_queue = Queue()\n", + "\n", + " # Read data in a separate thread\n", + " def read_thread():\n", + " nonlocal audio_data\n", + " _, sig = wavfile.read(file_path)\n", + " result_queue.put(sig) # Put the result in the queue\n", + "\n", + " thread = threading.Thread(target=read_thread)\n", + " thread.start()\n", + "\n", + " # Perform additional computations in the main thread\n", + " additional_result = computation_thread(21)\n", + "\n", + " # Wait for the thread to finish\n", + " thread.join()\n", + " # Access the data read by the thread from the queue\n", + " audio_data = result_queue.get()\n", + "\n", + " return audio_data, additional_result" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "import torchaudio\n", + "\n", + "def read_and_compute_torchaudio(file_path):\n", + "\n", + " # Create a queue to communicate between threads\n", + " result_queue = Queue()\n", + "\n", + " # Read data in a separate thread\n", + " def read_thread():\n", + " nonlocal audio_data\n", + " sig, _ = torchaudio.load(\n", + " file_path, normalize=False, channels_first=False\n", + " )\n", + " result_queue.put(sig) # Put the result in the queue\n", + "\n", + " thread = threading.Thread(target=read_thread)\n", + " thread.start()\n", + "\n", + " # Perform additional computations in the main thread\n", + " additional_result = computation_thread(37)\n", + "\n", + " # Wait for the thread to finish\n", + " thread.join()\n", + " # Access the data read by the thread from the queue\n", + " audio_data = result_queue.get()\n", + "\n", + " return audio_data, additional_result" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Generated file: /Users/jiwaszkiewicz/CodeProjects/fastwave/benchmark/random_audio_20240102052119_MP6.wav\n", + "Duration: 1800 seconds\n", + "Channels: 2\n", + "Running: read_and_compute_fastwave ...\n", + "Audio: [[ -5103 23502]\n", + " [ 4449 -11882]\n", + " [ 26703 20063]\n", + " ...\n", + " [ 7883 -29269]\n", + " [ 12201 -30454]\n", + " [ 30524 31554]]\n", + "Other: 42\n", + "Total Execution Time: 0.1002500057220459 seconds\n", + "Running: read_and_compute_scipy ...\n", + "Audio: [[ -5103 23502]\n", + " [ 4449 -11882]\n", + " [ 26703 20063]\n", + " ...\n", + " [ 7883 -29269]\n", + " [ 12201 -30454]\n", + " [ 30524 31554]]\n", + "Other: 21\n", + "Total Execution Time: 0.10190987586975098 seconds\n", + "Running: read_and_compute_torchaudio ...\n", + "Audio: tensor([[ -5103, 23502],\n", + " [ 4449, -11882],\n", + " [ 26703, 20063],\n", + " ...,\n", + " [ 7883, -29269],\n", + " [ 12201, -30454],\n", + " [ 30524, 31554]], dtype=torch.int16)\n", + "Other: 37\n", + "Total Execution Time: 0.41034483909606934 seconds\n" + ] + } + ], + "source": [ + "audio_generator = AudioGenerator(sample_rate=44100, duration=60 * 30, channels=2)\n", + "print(f\"Generated file: {audio_generator.file_path}\")\n", + "print(f\"Duration: {audio_generator.duration} seconds\")\n", + "print(f\"Channels: {audio_generator.channels}\")\n", + "\n", + "\n", + "# Run simple benchmark\n", + "def simple_benchmark(function, audio_generator):\n", + " start_time = time.time()\n", + "\n", + " # Run the benchmark\n", + " audio_data, other = function(audio_generator.file_path)\n", + "\n", + " end_time = time.time()\n", + "\n", + " print(f\"Running: {function.__name__} ...\")\n", + " print(f\"Audio: {audio_data.data if isinstance(audio_data, fastwave.AudioData) else audio_data}\")\n", + " print(f\"Other: {other}\")\n", + " print(f\"Total Execution Time: {end_time - start_time} seconds\")\n", + "\n", + "\n", + "simple_benchmark(read_and_compute_fastwave, audio_generator)\n", + "simple_benchmark(read_and_compute_scipy, audio_generator)\n", + "simple_benchmark(read_and_compute_torchaudio, audio_generator)\n", + "\n", + "audio_generator.delete_generated_file()" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "61.1 ms ± 1.37 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)\n", + "48 ms ± 960 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)\n", + "318 ms ± 2.08 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)\n" + ] + } + ], + "source": [ + "audio_generator = AudioGenerator(sample_rate=44100, duration=60 * 30, channels=2)\n", + "\n", + "%timeit audio_data = fastwave.read(audio_generator.file_path, mode=fastwave.ReadMode.DEFAULT)\n", + "\n", + "%timeit audio_data = wavfile.read(audio_generator.file_path)\n", + "\n", + "%timeit sig, _ = torchaudio.load(audio_generator.file_path, normalize=False, channels_first=False)\n", + "\n", + "audio_generator.delete_generated_file()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "pdm-fastwave", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.13" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} From ceeb70b95e7f68b05b75178c4f28681b04b069c5 Mon Sep 17 00:00:00 2001 From: jiwaszki Date: Tue, 2 Jan 2024 05:28:23 +0100 Subject: [PATCH 6/6] Fix codestyle --- benchmark/benchmark.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/benchmark/benchmark.py b/benchmark/benchmark.py index 9a7b4a7..9bfe179 100644 --- a/benchmark/benchmark.py +++ b/benchmark/benchmark.py @@ -5,9 +5,11 @@ import string import functools import timeit + # Plotting import matplotlib.pyplot as plt import seaborn as sns + # Reference libs import fastwave import torchaudio @@ -175,12 +177,15 @@ def benchmark_librosa(audio_generator): plt.figure(figsize=(10, 6)) palette = sns.color_palette("husl", len(list(methods.keys()))) bars = plt.barh(list(methods.keys()), execution_times, color=palette) - plt.title(f"Benchmark Results (wav, length: {audio_generator.duration} seconds, channel number: {audio_generator.channels} )") + plt.title( + f"Benchmark Results (wav, length: {audio_generator.duration} seconds," + f"channel number: {audio_generator.channels} )" + ) plt.xlabel("Execution Time (seconds, lower is better)") plt.ylabel("Library and method") # Add legend - plt.legend(bars, methods, loc='upper right') + plt.legend(bars, methods, loc="upper right") plt.tight_layout() # Show the plot