v0.34.0: Calculate BPM from the audio, on the right-click menu

Spectral-flux onsets + autocorrelation in numpy, decoded by the ffmpeg CLI;
no new dependency. Runs on a worker with the status-bar progress bar, for
one track or many, and the batch is one Ctrl+Z.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-02 14:55:35 -07:00
co-authored by Claude Opus 5.5
parent 4c0aaae463
commit b3d6c01774
7 changed files with 504 additions and 4 deletions
+12
View File
@@ -256,6 +256,18 @@ persistence) → GUI (Qt widgets that read the manager and connect to its signal
it never belongs in git). Audio under ~3 s has no fingerprint at all
("Empty fingerprint"), which is a reported failure, not a crash.
- **`lintunes/bpm_detect.py`** — right-click → Calculate BPM (Round 73).
Spectral-flux onset envelope → autocorrelation → every tempo 60–200 scored
over four beat multiples, times a log-Gaussian prior at 120 BPM (the
half/double-tempo tiebreak), then `fold_bpm` into 70–180. Plain numpy plus
the `ffmpeg` CLI (decodes the first 120 s to mono 11 kHz) — **no librosa**,
which would drag numba/scipy/scikit-learn in for one function. `BpmWorker`
is ExportWorker's shape on the shared status-bar widgets (`_busy_worker`),
reports each result as it lands so a cancel keeps them, and the batch is
applied by `LibraryManager.set_tracks_bpm` as **one** undoable command,
overwriting existing BPMs (trav's call). The transport's tap button stays
for the songs it gets wrong.
- **`lintunes/filename_tags.py`** — the offline half of Identify Track, and the
answer to its biggest limitation: **AcoustID only knows music somebody
submitted**, so an underground/SoundCloud rip fingerprints perfectly and
+1 -1
View File
@@ -1,3 +1,3 @@
"""LinTunes — iTunes-style music library manager and player for Linux."""
__version__ = "0.33.0"
__version__ = "0.34.0"
+191
View File
@@ -0,0 +1,191 @@
"""Calculate BPM: tempo detection from the audio itself.
The textbook method, in numpy: decode to mono, build an onset-strength
envelope (spectral flux — how much louder each frequency got since the last
frame), autocorrelate it, and score every tempo from 60 to 200 BPM by how
strongly the envelope repeats at one, two, three and four beats. A
log-Gaussian prior centred on 120 BPM breaks the tie between a tempo and its
half (librosa's trick). No librosa/aubio: numpy is already a dependency, and
librosa drags numba/scipy/scikit-learn behind it for this one function.
Shaped like ``fingerprint.py``: pure numpy first (tested offline against
synthetic beats), then the ``ffmpeg`` subprocess — the CLI binary, detected
at runtime like ``fpcalc``, never a pip dependency — then ``BpmWorker`` on a
daemon thread. Only the first ``MAX_SECONDS`` are analysed: a song's tempo
is settled long before then, and a batch of a few hundred stays quick.
Like any detector it can land on half or double the tempo a person would
tap; ``fold_bpm`` keeps the answer in a sensible range, and the transport's
tap button is still there for the ones it gets wrong.
"""
import subprocess
import threading
import numpy as np
from PyQt6.QtCore import QObject, pyqtSignal
SAMPLE_RATE = 11025
MAX_SECONDS = 120
WINDOW = 1024
HOP = 128
MIN_BPM, MAX_BPM = 60.0, 200.0
PRIOR_BPM = 120.0
PRIOR_OCTAVES = 1.0
HARMONICS = 4
def onset_envelope(samples: np.ndarray, sr: int = SAMPLE_RATE):
"""Spectral-flux onset strength, one value per hop. Returns
``(envelope, frames_per_second)``; the envelope is empty for audio
shorter than one window."""
samples = np.asarray(samples, dtype=np.float32)
count = 1 + (len(samples) - WINDOW) // HOP if len(samples) >= WINDOW else 0
if count < 2:
return np.zeros(0, dtype=np.float32), sr / HOP
window = np.hanning(WINDOW).astype(np.float32)
frames = np.lib.stride_tricks.sliding_window_view(samples, WINDOW)[::HOP]
parts = []
previous = None
chunk = 2048 # frames at a time: ~10k frames x 513 bins at once is 40 MB
for start in range(0, count, chunk):
spectrum = np.abs(np.fft.rfft(frames[start:start + chunk] * window,
axis=1))
level = np.log1p(100.0 * spectrum).astype(np.float32)
joined = level if previous is None else np.vstack([previous, level])
rise = np.maximum(np.diff(joined, axis=0), 0.0).sum(axis=1)
parts.append(rise if previous is not None else np.r_[0.0, rise])
previous = level[-1:]
return np.concatenate(parts).astype(np.float32), sr / HOP
def _autocorrelation(envelope: np.ndarray) -> np.ndarray:
"""Biased autocorrelation (via FFT) of the mean-removed envelope,
normalised so lag 0 is 1. Biased on purpose: it decays with lag, which
leans a tempo-vs-half-tempo tie toward the faster, beat-level one."""
env = envelope.astype(np.float64)
# Subtract a ~1 s moving average so slow loudness swells aren't "beats".
width = max(int(SAMPLE_RATE / HOP), 1)
env = env - np.convolve(env, np.ones(width) / width, mode="same")
env = np.maximum(env, 0.0)
env -= env.mean()
size = 1 << int(np.ceil(np.log2(2 * len(env))))
spectrum = np.fft.rfft(env, size)
acf = np.fft.irfft(spectrum * np.conj(spectrum), size)[:len(env)]
return acf / acf[0] if acf[0] > 0 else np.zeros_like(acf)
def estimate_bpm(samples: np.ndarray, sr: int = SAMPLE_RATE) -> float | None:
"""The tempo of `samples` in BPM, or None when nothing repeats (silence,
a drone, audio too short to hold four beats)."""
envelope, fps = onset_envelope(samples, sr)
# Four beats at the slowest tempo must fit, or there's nothing to score.
if (len(envelope) <= 60.0 * fps / MIN_BPM * HARMONICS
or not np.any(envelope > 0)):
return None
acf = _autocorrelation(envelope)
lags = np.arange(len(acf))
candidates = np.arange(MIN_BPM, MAX_BPM + 0.05, 0.1)
beat_lags = 60.0 * fps / candidates
score = np.zeros_like(candidates)
for k in range(1, HARMONICS + 1):
score += np.interp(beat_lags * k, lags, acf)
score /= HARMONICS
if score.max() <= 0.05:
return None
prior = np.exp(-0.5 * (np.log2(candidates / PRIOR_BPM)
/ PRIOR_OCTAVES) ** 2)
return float(candidates[np.argmax(score * prior)])
def fold_bpm(bpm: float, low: float = 70.0, high: float = 180.0) -> int:
"""Fold a half/double-tempo answer into [low, high] and round — Track.bpm
is a whole number, as in iTunes."""
while bpm < low:
bpm *= 2
while bpm > high:
bpm /= 2
return int(round(bpm))
def decode_mono(path: str, sr: int = SAMPLE_RATE,
max_seconds: int = MAX_SECONDS) -> np.ndarray:
"""The first `max_seconds` of `path` as mono float32 at `sr`, decoded by
the ffmpeg CLI. Raises OSError when ffmpeg fails or yields nothing."""
cmd = ["ffmpeg", "-nostdin", "-v", "error", "-i", str(path),
"-t", str(max_seconds), "-vn", "-ac", "1", "-ar", str(sr),
"-f", "f32le", "-"]
try:
result = subprocess.run(cmd, capture_output=True, timeout=120)
except (OSError, subprocess.TimeoutExpired) as e:
raise OSError(f"ffmpeg could not run: {e}") from e
if result.returncode != 0:
detail = result.stderr.decode(errors="replace").strip().splitlines()
raise OSError(detail[-1] if detail else "ffmpeg failed")
samples = np.frombuffer(result.stdout, dtype=np.float32)
if len(samples) == 0:
raise OSError("no audio")
return samples
def detect_file(path: str) -> int | None:
"""The folded BPM of the file at `path`, or None if it has no beat.
Raises OSError when the file can't be decoded."""
bpm = estimate_bpm(decode_mono(path))
return fold_bpm(bpm) if bpm is not None else None
class BpmWorker(QObject):
"""Runs Calculate BPM over a batch on a daemon thread (ExportWorker's
shape). Handed `(track_id, location, label)` snapshots so it never reads
the library off the GUI thread; reports each result as it lands, so a
cancel keeps everything already measured."""
progress = pyqtSignal(int, int, str) # done, total, label of the next
detected = pyqtSignal(int, int) # track_id, bpm
finished = pyqtSignal(dict) # {"done", "failed", "no_beat",
# "cancelled"}
def __init__(self, items: list[tuple[int, str, str]], parent=None,
detect=None):
super().__init__(parent)
self._items = list(items)
self._detect = detect # None: detect_file, looked up when run
self._busy = False
self._cancel = threading.Event()
def busy(self) -> bool:
return self._busy
def cancel(self):
self._cancel.set()
def start(self):
if self._busy:
return
self._busy = True
threading.Thread(target=self._run, daemon=True).start()
def _run(self):
summary = {"done": 0, "failed": 0, "no_beat": 0, "cancelled": False}
total = len(self._items)
try:
for i, (track_id, location, label) in enumerate(self._items):
if self._cancel.is_set():
summary["cancelled"] = True
break
self.progress.emit(i, total, label)
try:
bpm = (self._detect or detect_file)(location)
except Exception:
summary["failed"] += 1
continue
if bpm is None:
summary["no_beat"] += 1
continue
summary["done"] += 1
self.detected.emit(track_id, bpm)
else:
self.progress.emit(total, total, "")
finally:
self._busy = False
self.finished.emit(summary)
+75 -2
View File
@@ -10,7 +10,7 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt, QEvent, QObject, QTimer, pyqtSignal
from PyQt6.QtGui import QAction, QKeySequence
from lintunes import device_sync, music_folder, theme, url_import
from lintunes import bpm_detect, device_sync, music_folder, theme, url_import
from lintunes.andtunes import layout as andtunes_layout
from lintunes.andtunes import install as andtunes_install
from lintunes.andtunes import sync as andtunes_sync
@@ -223,6 +223,9 @@ class MainWindow(QMainWindow):
self._identify_running = False
# Import from URL: also shares the status-bar progress widgets.
self._url_worker = None
# Calculate BPM: also shares the status-bar progress widgets.
self._bpm_worker = None
self._bpm_results: dict[int, int] = {}
self._url_target = None # url_import.ImportTarget, or None
self._url_inserted = 0 # songs added to the target so far
self._url_imported = 0
@@ -256,6 +259,7 @@ class MainWindow(QMainWindow):
view.table.tracks_changed.connect(self._update_totals)
view.table.download_art_requested.connect(self._download_album_art)
view.table.identify_requested.connect(self._identify_tracks)
view.table.bpm_requested.connect(self._calculate_bpm)
view.table.remove_from_library_requested.connect(
lambda ids: self._delete_tracks(ids, delete_files=False))
view.table.delete_from_library_requested.connect(
@@ -550,12 +554,17 @@ class MainWindow(QMainWindow):
def _busy_worker(self):
"""The transfer currently owning the status-bar progress widgets."""
for worker in (self._andtunes_planner, self._andtunes_worker,
self._export_worker, self._url_worker):
self._export_worker, self._url_worker,
self._bpm_worker):
if worker is not None and worker.busy():
return worker
return None
def _confirm_cancel_sync(self):
if self._bpm_worker is not None and self._bpm_worker.busy():
# Nothing to lose: what's been measured is kept.
self._bpm_worker.cancel()
return
if self._url_worker is not None and self._url_worker.busy():
self._confirm_cancel_url_import()
return
@@ -1586,6 +1595,70 @@ class MainWindow(QMainWindow):
return
self._enqueue_identify(tracks)
# ---- Calculate BPM ----
def _calculate_bpm(self, track_ids: list[int]):
"""Measure the tempo of the selection on a worker thread, with the
status-bar progress bar; results land as one undoable edit."""
if not exporter.web_support.ffmpeg_available():
QMessageBox.warning(
self, "Calculate BPM",
"Calculating BPM needs ffmpeg, which is not installed.\n\n"
"Install your distro's “ffmpeg” package and try again.")
return
if self._busy_worker() is not None:
self.statusBar().showMessage(
"Wait for the current transfer to finish first", 6000)
return
items = []
for tid in track_ids:
track = self._manager.library.tracks.get(tid)
if track is not None and track.location:
label = (f"{track.artist} — {track.name}" if track.artist
else track.name) or Path(track.location).name
items.append((tid, track.location, label))
if not items:
return
self._bpm_results = {}
worker = bpm_detect.BpmWorker(items, self)
worker.progress.connect(self._on_bpm_progress)
worker.detected.connect(self._bpm_results.__setitem__)
worker.finished.connect(self._on_bpm_finished)
self._bpm_worker = worker
self._sync_label.setText("Calculating BPM…")
self._sync_progress.setRange(0, len(items))
self._sync_progress.setValue(0)
self._sync_cancel.show()
self._sync_label.show()
self._sync_progress.show()
worker.start()
def _on_bpm_progress(self, done: int, total: int, label: str):
self._sync_progress.setRange(0, total)
self._sync_progress.setValue(done)
if label:
short = label if len(label) <= 40 else label[:39] + "…"
where = f"{done + 1} of {total} · " if total > 1 else ""
self._sync_label.setText(f"BPM {where}{short}")
def _on_bpm_finished(self, summary: dict):
self._hide_sync_widgets()
results, self._bpm_results = self._bpm_results, {}
self._manager.set_tracks_bpm(results)
if len(results) == 1 and not summary["cancelled"]:
msg = f"BPM: {next(iter(results.values()))}"
else:
msg = f"BPM set for {len(results)} track(s)"
if summary["cancelled"]:
msg = "Calculate BPM stopped — " + msg[0].lower() + msg[1:]
if summary["no_beat"]:
msg += f" · no steady beat in {summary['no_beat']}"
if summary["failed"]:
msg += f" · {summary['failed']} couldn't be read"
if not summary["cancelled"]:
sounds.play_done(self._prefs)
self.statusBar().showMessage(msg, 10000)
def _identify_setup_problem(self) -> str:
"""Why an AcoustID lookup can't run on this machine, or ''."""
if not fpcalc_available():
+5 -1
View File
@@ -553,6 +553,7 @@ class TrackTableView(QTableView):
rating_edited = pyqtSignal(int, int) # track_id, new rating 0-100
download_art_requested = pyqtSignal(list) # selected track ids
identify_requested = pyqtSignal(list) # selected track ids
bpm_requested = pyqtSignal(list) # selected track ids
remove_from_library_requested = pyqtSignal(list) # track ids, file kept
delete_from_library_requested = pyqtSignal(list) # track ids, file trashed
@@ -860,7 +861,7 @@ class TrackTableView(QTableView):
locations = self.selected_locations()
reveal_action = copy_path_action = download_art_action = None
identify_action = None
identify_action = bpm_action = None
if locations:
menu.addSeparator()
reveal_action = menu.addAction("Reveal in File Browser")
@@ -868,6 +869,7 @@ class TrackTableView(QTableView):
download_art_action = menu.addAction("Download Album Art…")
# Fingerprinting reads the audio, so this needs a file on disk.
identify_action = menu.addAction("Identify Track…")
bpm_action = menu.addAction("Calculate BPM")
# Single track only: one click shouldn't open a browser tab per song.
# Needs no file, so it's offered for a dangling location too.
@@ -921,6 +923,8 @@ class TrackTableView(QTableView):
self.download_art_requested.emit(self.selected_track_ids())
elif identify_action is not None and chosen is identify_action:
self.identify_requested.emit(self.selected_track_ids())
elif bpm_action is not None and chosen is bpm_action:
self.bpm_requested.emit(self.selected_track_ids())
elif youtube_action is not None and chosen is youtube_action:
track = self.model_.track_at(self.selected_source_rows()[0])
QDesktopServices.openUrl(youtube_search_url(track))
+23
View File
@@ -627,6 +627,29 @@ class LibraryManager(QObject):
redo=lambda: [self._revert_track_fields(tid, new)
for tid, new, _old in changes]))
def set_tracks_bpm(self, values: dict[int, int]):
"""Calculate BPM's results: a different bpm per track, tag written to
each file, recorded as ONE undoable command."""
changes = [] # (track_id, new_fields, old_fields)
with timed("set_tracks_bpm (%d tracks)", len(values)):
for track_id, bpm in values.items():
track = self.library.tracks.get(track_id)
if not track or track.bpm == bpm:
continue
changed = {"bpm": bpm}
if not self._write_track_tags(track, changed):
continue
changes.append((track_id, changed, {"bpm": track.bpm}))
self._apply_track_fields(track_id, changed)
if not changes:
return
self.undo_stack.push(Command(
"Calculate BPM",
undo=lambda: [self._revert_track_fields(tid, old)
for tid, _new, old in changes],
redo=lambda: [self._revert_track_fields(tid, new)
for tid, new, _old in changes]))
def _write_track_tags(self, track, field_map: dict) -> bool:
# Library-only fields (rating, size, start/stop times) have no tag
# representation; don't rewrite the audio file for them.
+197
View File
@@ -0,0 +1,197 @@
"""Round 73: Calculate BPM.
Right-click → Calculate BPM measures the tempo from the audio itself
(``bpm_detect``: spectral-flux onsets, autocorrelation, a 120 BPM prior),
with the status-bar progress bar, for one track or many. Results overwrite
whatever BPM a track had, as one Ctrl+Z.
The beats here are synthetic — a kick on every beat, optionally hats on the
off-beats and noise — so the right answer is known exactly.
"""
import shutil
import time
import wave
import numpy as np
import pytest
from PyQt6.QtCore import QCoreApplication
from lintunes import bpm_detect
from lintunes.bpm_detect import (
SAMPLE_RATE, BpmWorker, decode_mono, detect_file, estimate_bpm, fold_bpm,
)
from lintunes.library_manager import LibraryManager
from lintunes.models import Library, Track
def _beat(bpm, seconds=30, hats=False, noise=0.0, sr=SAMPLE_RATE):
rng = np.random.default_rng(7)
n = int(seconds * sr)
out = np.zeros(n, np.float32)
t = np.arange(int(0.08 * sr)) / sr
kick = (np.sin(2 * np.pi * 60 * t) * np.exp(-t * 30)).astype(np.float32)
hat_len = int(0.03 * sr)
hat = (rng.standard_normal(hat_len)
* np.exp(-np.arange(hat_len) / sr * 150) * 0.3).astype(np.float32)
period = 60.0 / bpm
beat = 0
while (beat + 1) * period < seconds:
i = int(beat * period * sr)
out[i:i + len(kick)] += kick
if hats:
j = int((beat + 0.5) * period * sr)
out[j:j + hat_len] += hat
beat += 1
return out + noise * rng.standard_normal(n).astype(np.float32)
def _pump(until, timeout=5.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
QCoreApplication.processEvents()
if until():
return True
time.sleep(0.01)
return False
class TestEstimate:
@pytest.mark.parametrize("bpm", [72, 90, 100, 120, 128, 140, 155, 174])
def test_kick_on_every_beat(self, bpm):
assert abs(fold_bpm(estimate_bpm(_beat(bpm))) - bpm) <= 1
@pytest.mark.parametrize("bpm", [85, 120, 174])
def test_offbeat_hats_and_noise_dont_double_it(self, bpm):
found = estimate_bpm(_beat(bpm, hats=True, noise=0.05))
assert abs(fold_bpm(found) - bpm) <= 1
def test_silence_and_noise_have_no_beat(self):
assert estimate_bpm(np.zeros(SAMPLE_RATE * 10, np.float32)) is None
noise = np.random.default_rng(1).standard_normal(SAMPLE_RATE * 10)
assert estimate_bpm(noise.astype(np.float32)) is None
def test_too_short_for_four_beats(self):
assert estimate_bpm(_beat(120, seconds=1)) is None
assert estimate_bpm(np.zeros(10, np.float32)) is None
def test_fold(self):
assert fold_bpm(60) == 120
assert fold_bpm(240) == 120
assert fold_bpm(127.6) == 128
assert fold_bpm(174) == 174
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="needs ffmpeg")
class TestDecode:
def test_wav_round_trip(self, tmp_path):
path = tmp_path / "beat.wav"
samples = _beat(128, seconds=20, sr=44100)
pcm = (np.clip(samples, -1, 1) * 32000).astype("<i2")
with wave.open(str(path), "wb") as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(44100)
f.writeframes(pcm.tobytes())
decoded = decode_mono(str(path))
assert abs(len(decoded) - 20 * SAMPLE_RATE) < SAMPLE_RATE // 10
assert detect_file(str(path)) == 128
def test_unreadable_file_raises_oserror(self, tmp_path):
bad = tmp_path / "bad.mp3"
bad.write_bytes(b"not audio at all")
with pytest.raises(OSError):
decode_mono(str(bad))
class TestWorker:
def test_reports_each_result_and_a_summary(self, qapp):
answers = {"a": 120, "b": None}
def detect(location):
if location == "c":
raise OSError("nope")
return answers[location]
worker = BpmWorker([(1, "a", "A"), (2, "b", "B"), (3, "c", "C")],
detect=detect)
found, summaries = [], []
worker.detected.connect(lambda tid, bpm: found.append((tid, bpm)))
worker.finished.connect(summaries.append)
worker.start()
assert _pump(lambda: summaries)
assert found == [(1, 120)]
assert summaries[0] == {"done": 1, "failed": 1, "no_beat": 1,
"cancelled": False}
assert not worker.busy()
def test_cancel_keeps_what_was_measured(self, qapp):
worker = BpmWorker([(1, "a", "A"), (2, "b", "B")])
def detect(location):
worker.cancel()
return 100
worker._detect = detect
found, summaries = [], []
worker.detected.connect(lambda tid, bpm: found.append(tid))
worker.finished.connect(summaries.append)
worker.start()
assert _pump(lambda: summaries)
assert found == [1]
assert summaries[0]["cancelled"]
def _manager(tmp_path, bpms):
library = Library()
for tid, bpm in bpms.items():
library.tracks[tid] = Track(track_id=tid, name=f"Song {tid}", bpm=bpm)
return LibraryManager(library, tmp_path / "data")
class TestSetTracksBpm:
def test_per_track_values_overwrite_and_undo_as_one(self, qapp, tmp_path):
manager = _manager(tmp_path, {1: 0, 2: 95, 3: 140})
manager.set_tracks_bpm({1: 120, 2: 128, 3: 140})
tracks = manager.library.tracks
assert [tracks[i].bpm for i in (1, 2, 3)] == [120, 128, 140]
manager.undo_stack.undo()
assert [tracks[i].bpm for i in (1, 2, 3)] == [0, 95, 140]
manager.undo_stack.redo()
assert [tracks[i].bpm for i in (1, 2, 3)] == [120, 128, 140]
def test_nothing_changed_records_nothing(self, qapp, tmp_path):
manager = _manager(tmp_path, {1: 120})
manager.set_tracks_bpm({1: 120, 99: 100})
assert not manager.undo_stack.can_undo()
class TestWindow:
def test_menu_signal_runs_the_batch_through_the_status_bar(
self, qapp, tmp_path, monkeypatch):
from lintunes.gui import sounds
from lintunes.gui.main_window import MainWindow
from lintunes.preferences import Preferences
monkeypatch.setattr(bpm_detect, "detect_file",
lambda location: {"/a.mp3": 90}.get(location))
monkeypatch.setattr(
"lintunes.export.web_support.ffmpeg_available", lambda: True)
monkeypatch.setattr(sounds, "play_done", lambda prefs: None)
manager = _manager(tmp_path, {1: 0, 2: 0, 3: 0})
manager.library.tracks[1].location = "/a.mp3"
manager.library.tracks[2].location = "/b.mp3"
# Nothing on disk to tag; the library edit is what's under test.
monkeypatch.setattr(manager, "_write_track_tags",
lambda track, fields: True)
window = MainWindow(manager, Preferences(tmp_path / "data"))
try:
window._library_view.table.bpm_requested.emit([1, 2, 3])
assert window._bpm_worker is not None
assert window._sync_progress.maximum() == 2 # 3 has no file
assert _pump(lambda: not window._bpm_worker.busy()
and window._sync_progress.isHidden())
tracks = manager.library.tracks
assert (tracks[1].bpm, tracks[2].bpm, tracks[3].bpm) == (90, 0, 0)
assert "no steady beat in 1" in window.statusBar().currentMessage()
finally:
window.close()