"""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("