Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 .
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ venv
*.pkl
output/*
download_cache.json
*.csv
*.csv
dist
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 16 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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: <a target="_blank" href="https://colab.research.google.com/drive/1s3fcIpFAnJWguS2-sE6LKjWVkQlOpZK1?usp=sharing">

- Try this notebook to process some songs using Google's free-tier
GPUs: <a target="_blank" href="https://colab.research.google.com/drive/1s3fcIpFAnJWguS2-sE6LKjWVkQlOpZK1?usp=sharing">
<img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open In Colab"/>
</a>
</a>

- 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/):

Expand All @@ -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
```

<details>
<summary>Additional fields (advanced)</summary>
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
Expand Down
19 changes: 14 additions & 5 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,21 +36,30 @@ <h2>BlastViz</h2>
<h4>Blastbeat Detection Visualizer</h4>
<div id="explainer">
<hr>
<p><i><strong>Warning:</strong> This is a work in progress. The visualizer expects a ZIP file containing an audio
<p><i><strong>Warning:</strong> 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.</i>
</p>
<p>Expected JSON structure:</p>
<p><i>Expected JSON structure:</i></p>
<pre>
{
"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},
...
]
}
</pre>
<p>You can download some examples from <a
<p>Try this notebook to process some songs using Google's free-tier
GPUs: <a target="_blank"
href="https://colab.research.google.com/drive/1s3fcIpFAnJWguS2-sE6LKjWVkQlOpZK1?usp=sharing">
<img src="https://colab.research.google.com/assets/colab-badge.svg" alt="Open In Colab"/>
</a>
</p>
<p>Or directly download some processed song examples from <a
href="https://drive.google.com/drive/folders/1YFoxrsrBo8hl0cOYkdxCsfW_bf3CX7Al?usp=drive_link"
target="_blank">this Google Drive folder</a></p>
target="_blank">this Google Drive folder</a>
</p>
</div>
<div class="load-control">
<input type="file" id="zipInput" accept=".zip">
Expand Down
20 changes: 20 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
pydub==0.25.1
torchcodec==0.8.1
File renamed without changes.
11 changes: 5 additions & 6 deletions downloading.py → src/blastbeat_detector/downloading.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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())
Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions extraction.py → src/blastbeat_detector/extraction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand All @@ -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)
4 changes: 2 additions & 2 deletions pipeline.py → src/blastbeat_detector/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
11 changes: 3 additions & 8 deletions plotting.py → src/blastbeat_detector/plotting.py
Original file line number Diff line number Diff line change
@@ -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 = []
Expand Down Expand Up @@ -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))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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": [],
Expand Down
27 changes: 10 additions & 17 deletions processing.py → src/blastbeat_detector/processing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"
Expand All @@ -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)
Expand All @@ -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")
2 changes: 1 addition & 1 deletion test_processing.py → tests/test_processing.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from processing import LabeledSection, identify_blastbeats
from blastbeat_detector.processing import LabeledSection, identify_blastbeats


def test_identify_blasts_1():
Expand Down