diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index b8d8dff..b8dfc3b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -35,5 +35,8 @@ jobs: pip install -r requirements.txt pip install pytest + - name: Install package + run: pip install -e . + - name: Run tests run: pytest . \ No newline at end of file diff --git a/.gitignore b/.gitignore index 74a17ec..69e1f77 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ venv *.pkl output/* download_cache.json -*.csv \ No newline at end of file +*.csv +dist \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c7a06bf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 MutilatedPeripherals + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index e4f7775..408c32d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Blast beat counter +# Blast beat detector [![Tests](https://github.com/MutilatedPeripherals/blastbeat-counter/actions/workflows/run-tests.yml/badge.svg)](https://github.com/MutilatedPeripherals/blastbeat-counter/actions/workflows/run-tests.yml) @@ -38,15 +38,18 @@ And here is another one, from the ecuadorian band Curetaje: ## Demo -Currently the detector is not deployed as a service because demucs requires a GPU for drum-track separation in reasonable time, and those servers aren't free... +Currently the detector is not deployed as a service because demucs requires a GPU for drum-track separation in +reasonable time, and those servers aren't free... But you can: -- Try this notebook to process some songs using Google's free-tier GPUs: + +- Try this notebook to process some songs using Google's free-tier + GPUs: Open In Colab - + - Or just directly download examples of processed songs -from [here](https://drive.google.com/drive/folders/1YFoxrsrBo8hl0cOYkdxCsfW_bf3CX7Al) + from [here](https://drive.google.com/drive/folders/1YFoxrsrBo8hl0cOYkdxCsfW_bf3CX7Al) And then upload the results to the [visualizer](https://mutilatedperipherals.github.io/blastbeat-counter/): @@ -70,25 +73,26 @@ apt-get install ffmpeg ### Specify the input files & run the code -1. Create a `csv` file to pass as input to the `pipeline.py` file. +1. Create a `csv` file to pass as input to the `pipeline.py` file. - The only mandatory column is `src`, with the file paths of the songs to analyze. Youtube URLs are also supported on a best-effort basis (uses `yt-dlp` to download the audio). + The only mandatory column is `src`, with the file paths of the songs to analyze. Youtube URLs are also supported on a + best-effort basis (uses `yt-dlp` to download the audio). - Example: + Example: ```csv src /home/linomp/Downloads/CURETAJE - Arutam.mp3 https://youtu.be/dQw4w9WgXcQ?si=J-UoAhM54KQGR6eW ``` - +
Additional fields (advanced) Other fields are supported for debugging & development, with the most important one being `step_size_in_seconds`, which determines the size of the segments on which the song is split & analized (default value: 0.15s). - - In `pipeline.py` you can see all the configurable fields. If left blank or unspecified, defaults will be used. - Here is an example specifying an extra field: + In `pipeline.py` you can see all the configurable fields. If left blank or unspecified, defaults will be used. + + Here is an example specifying an extra field: ```csv src,step_size_in_seconds diff --git a/index.html b/index.html index b4f80ed..c4b9b9a 100644 --- a/index.html +++ b/index.html @@ -36,21 +36,30 @@

BlastViz

Blastbeat Detection Visualizer


-

Warning: This is a work in progress. The visualizer expects a ZIP file containing an audio +

Warning: This is not a stand-alone tool. The visualizer expects a ZIP file containing an + audio file (MP3 or WAV), a JSON file with the identified blast-beat sections, and optionally a drum-only audio track.

-

Expected JSON structure:

+

Expected JSON structure:

     {
       "blast_beats": [
         {"start_time": 12.5, "end_time": 15.0},
-        {"start_time": 30.0, "end_time": 32.5}
+        {"start_time": 30.0, "end_time": 32.5},
+        ...
       ]
     }
     
-

You can download some examples from Try this notebook to process some songs using Google's free-tier + GPUs: + Open In Colab + +

+

Or directly download some processed song examples from this Google Drive folder

+ target="_blank">this Google Drive folder +

diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..086fda4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["hatchling >= 1.26"] +build-backend = "hatchling.build" + +[project] +name = "blastbeat_detector" +version = "0.0.6" +authors = [ + { name = "@linomp" }, + { name = "@lmeullibre" }, + { name = "@m-poh" }, +] +description = "Experimental package for identifying blast-beats in songs using spectral analysis" +readme = "README.md" +requires-python = ">=3.9" +license = "MIT" +license-files = ["LICEN[CS]E*"] + +[project.urls] +Repository = "https://github.com/MutilatedPeripherals/blastbeat-detector" diff --git a/requirements.txt b/requirements.txt index 84b5cae..5ebd824 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ matplotlib==3.10.3 numpy==1.26.4 pytest==8.4.1 yt-dlp==2025.11.12 -pydub==0.25.1 \ No newline at end of file +pydub==0.25.1 +torchcodec==0.8.1 \ No newline at end of file diff --git a/output/.gitkeep b/src/blastbeat_detector/__init__.py similarity index 100% rename from output/.gitkeep rename to src/blastbeat_detector/__init__.py diff --git a/downloading.py b/src/blastbeat_detector/downloading.py similarity index 88% rename from downloading.py rename to src/blastbeat_detector/downloading.py index 8514437..f80107e 100644 --- a/downloading.py +++ b/src/blastbeat_detector/downloading.py @@ -5,15 +5,14 @@ from yt_dlp import YoutubeDL -CACHE_FILE = Path(__file__).parent / "download_cache.json" - def download_from_youtube_as_mp3(url: str) -> tuple[bool, Path | None]: if not re.match(r"(https?://)?(www\.)?(youtube\.com|youtu\.be)/", url): raise ValueError("The provided URL is not a valid YouTube video URL.") - if CACHE_FILE.exists(): - with open(CACHE_FILE, "r") as f: + cache_file = Path.cwd().resolve() / "download_cache.json" + if cache_file.exists(): + with open(cache_file, "r") as f: cache = json.load(f) if url in cache: cached_path = Path(cache[url]) @@ -23,7 +22,7 @@ def download_from_youtube_as_mp3(url: str) -> tuple[bool, Path | None]: else: cache = {} - output_folder = Path(__file__).parent / "tmp" + output_folder = Path.cwd().resolve() / "tmp" output_folder.mkdir(exist_ok=True) temp_name = str(uuid.uuid4()) @@ -55,7 +54,7 @@ def download_from_youtube_as_mp3(url: str) -> tuple[bool, Path | None]: downloaded_path.rename(final_path) cache[url] = str(final_path) - with open(CACHE_FILE, "w") as f: + with open(cache_file, "w") as f: json.dump(cache, f, indent=2) return True, final_path diff --git a/extraction.py b/src/blastbeat_detector/extraction.py similarity index 90% rename from extraction.py rename to src/blastbeat_detector/extraction.py index 098c30b..edae9d4 100644 --- a/extraction.py +++ b/src/blastbeat_detector/extraction.py @@ -15,7 +15,10 @@ "ignore", message=".*this function's implementation will be changed to use torchaudio.save_with_torchcodec.*", ) - +warnings.filterwarnings( + "ignore", + message=".*The 'encoding' parameter is not fully supported by TorchCodec AudioEncoder.*", +) def read_audio_file(input_file_path: Path) -> tuple[np.ndarray, np.ndarray, float]: y, sample_rate = librosa.load(input_file_path, mono=True) @@ -44,7 +47,7 @@ def extract_drums( ) if skip_cache or not extracted_drums_file_path.exists(): - print("Extracting drums") + print(f"Separating drum track from \'{input_file_path}\'") temp_file_path = ( input_file_path.parent / "htdemucs" @@ -71,7 +74,7 @@ def extract_drums( if __name__ == "__main__": - base_dir = "/home/linomp/Downloads" + base_dir = Path.cwd().resolve() file_path = Path(f"{base_dir}/Dying Fetus - Subjected To A Beating.wav") extract_drums(file_path) diff --git a/pipeline.py b/src/blastbeat_detector/pipeline.py similarity index 94% rename from pipeline.py rename to src/blastbeat_detector/pipeline.py index 75d91ce..e06ee6e 100644 --- a/pipeline.py +++ b/src/blastbeat_detector/pipeline.py @@ -2,8 +2,8 @@ import csv from pathlib import Path -from downloading import download_from_youtube_as_mp3 -from processing import process_song +from blastbeat_detector.downloading import download_from_youtube_as_mp3 +from blastbeat_detector.processing import process_song def parse_float(row, key): diff --git a/plotting.py b/src/blastbeat_detector/plotting.py similarity index 93% rename from plotting.py rename to src/blastbeat_detector/plotting.py index 2450d16..fa24bda 100644 --- a/plotting.py +++ b/src/blastbeat_detector/plotting.py @@ -1,18 +1,13 @@ -from pathlib import Path - import matplotlib.pyplot as plt import numpy as np -base_dir = Path(__file__).parent.resolve() -default_output_dir = f"{base_dir}/output/img" - def plot_waveform_with_highlights( time: np.ndarray, data: np.ndarray, ranges_to_highlight: list[tuple[int, int]], - title: str = "test", - output_dir=default_output_dir, + title: str, + output_dir:str, ): highlighted_time_elements = [] highlighted_data_elements = [] @@ -75,10 +70,10 @@ def plot_fft_with_markers( fft_magnitude: np.ndarray, bass_drum_freq: float, snare_drum_freq: float, + output_dir: str, title: str = "identified_freqs", bass_drum_range: tuple[int, int] | None = None, snare_range: tuple[int, int] | None = None, - output_dir=default_output_dir, ): fig, ax = plt.subplots(1, 1, figsize=(12, 6)) diff --git a/postprocessing.py b/src/blastbeat_detector/postprocessing.py similarity index 91% rename from postprocessing.py rename to src/blastbeat_detector/postprocessing.py index db99029..92dc8cd 100644 --- a/postprocessing.py +++ b/src/blastbeat_detector/postprocessing.py @@ -5,9 +5,6 @@ import numpy as np from pydub import AudioSegment -base_dir = Path(__file__).parent.resolve() -default_output_dir = f"{base_dir}/output" - def compress_to_mp3(wav_path: Path, bitrate: str = "192k") -> Path: mp3_path = wav_path.with_suffix(".mp3") @@ -23,7 +20,7 @@ def save_result( bass_drum_frequency: float, filepath: Path, drumtrack_path: Path, - output_dir: str = default_output_dir, + output_dir: str, ): output = { "blast_beats": [], diff --git a/processing.py b/src/blastbeat_detector/processing.py similarity index 92% rename from processing.py rename to src/blastbeat_detector/processing.py index 05f5f48..82a344d 100644 --- a/processing.py +++ b/src/blastbeat_detector/processing.py @@ -4,10 +4,10 @@ import numpy as np from numpy.fft import fft -from downloading import download_from_youtube_as_mp3 -from extraction import extract_drums -from plotting import plot_fft_with_markers -from postprocessing import save_result +from blastbeat_detector.downloading import download_from_youtube_as_mp3 +from blastbeat_detector.extraction import extract_drums +from blastbeat_detector.plotting import plot_fft_with_markers +from blastbeat_detector.postprocessing import save_result class LabeledSection(NamedTuple): @@ -182,17 +182,17 @@ def process_song( step_size_in_seconds=0.15, bass_drum_range=(10, 100), snare_range=(170, 600), - min_consecutive_hits=8, + min_consecutive_hits=8 ): - print("Separating drum track...") + output_dir = Path.cwd().resolve() / "output" + output_dir.mkdir(exist_ok=True) + (time, audio_data, sample_rate), drumtrack_path = extract_drums(file_path) bass_drum_freq, snare_freq = identify_bass_and_snare_frequencies( audio_data, sample_rate, bass_drum_range, - snare_range, - # TODO: remove this when not debugging - # debug_song_name=file_path.stem, + snare_range ) print( f"Estimated frequencies -- Bass drum: {bass_drum_freq} Hz; Snare drum: {snare_freq} Hz" @@ -212,15 +212,12 @@ def process_song( blastbeat_intervals = identify_blastbeats(labeled_sections, min_consecutive_hits) save_result( - time, blastbeat_intervals, snare_freq, bass_drum_freq, file_path, drumtrack_path + time, blastbeat_intervals, snare_freq, bass_drum_freq, file_path, drumtrack_path, output_dir.as_posix() ) if __name__ == "__main__": import argparse - import webbrowser - - OPEN_BROWSER_AFTER_PROCESSING = True parser = argparse.ArgumentParser() parser.add_argument("--file", type=str) @@ -237,13 +234,9 @@ def process_song( success, file_path = download_from_youtube_as_mp3(args.url) if not success or file_path is None: raise RuntimeError("Failed to download the YouTube video.") - print(f"Downloaded file to: {file_path}") else: raise ValueError( "You must provide either a local file path (--file) or a url to download from YouTube (--url)" ) process_song(file_path) - - if OPEN_BROWSER_AFTER_PROCESSING: - webbrowser.open(f"file://{Path(__file__).parent.resolve()}/index.html") diff --git a/test_processing.py b/tests/test_processing.py similarity index 93% rename from test_processing.py rename to tests/test_processing.py index ccfe9f9..e9a19a9 100644 --- a/test_processing.py +++ b/tests/test_processing.py @@ -1,4 +1,4 @@ -from processing import LabeledSection, identify_blastbeats +from blastbeat_detector.processing import LabeledSection, identify_blastbeats def test_identify_blasts_1():