diff --git a/README.md b/README.md index 14f1db8..d9b382d 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,7 @@ git submodule update --init --recursive * [Milkdrop / projectM](https://github.com/projectM-visualizer/projectM) - visualizer engine * [Demucs](https://github.com/facebookresearch/demucs) - audio separation +* [Beat This!](https://github.com/CPJKU/beat_this) - beat and downbeat detection * [FFmpeg](https://ffmpeg.org) - video encoding * [pygame](https://www.pygame.org/) - window, input, overlay UI, and SDL2 audio * [OpenGL](https://www.opengl.org/) / [PyOpenGL](https://pyopengl.sourceforge.io/) - layer compositing and rendering diff --git a/cleave/analyse.py b/cleave/analyse.py index cc84d08..b319215 100644 --- a/cleave/analyse.py +++ b/cleave/analyse.py @@ -10,7 +10,7 @@ from cleave.extract import ( extract_bass, - extract_drums_beats, + extract_beats_downbeats, extract_drums_onset, extract_mix_onset, extract_mix_rms, @@ -38,24 +38,31 @@ def run_analyse(project_dir: Path, *, high_quality: bool) -> Path: ) drums_onset = extract_drums_onset(paths["drums"]) - drums_beats = extract_drums_beats(paths["drums"]) + beats, downbeats = extract_beats_downbeats(mix) bass = extract_bass(paths["bass"]) vocals = extract_vocals(paths["vocals"], high_quality=high_quality) other = extract_other(paths["other"]) mix_onset = extract_mix_onset(mix) mix_rms = extract_mix_rms(mix) - if len(drums_beats) == 0: - print("drum stem beat detection produced no useful data") + if len(beats) == 0: + print("mix beat detection produced no useful data") beat_times = [] else: - beat_times = [float(t) for t in drums_beats] + beat_times = [float(t) for t in beats] + + if len(downbeats) == 0: + print("mix downbeat detection produced no useful data") + downbeat_times = [] + else: + downbeat_times = [float(t) for t in downbeats] output: dict = { - "version": 2, + "version": 3, "sample_rate_hz": int(TARGET_HZ), "duration_sec": duration_sec, "beat_times": beat_times, + "downbeat_times": downbeat_times, "drums": { "onset_strength": resample_to_100hz( *drums_onset, duration_sec diff --git a/cleave/extract.py b/cleave/extract.py index d707dc4..4b20f3e 100644 --- a/cleave/extract.py +++ b/cleave/extract.py @@ -1,4 +1,4 @@ -"""Per-stem librosa feature extraction at native analysis rates.""" +"""Per-stem feature extraction at native analysis rates.""" from __future__ import annotations @@ -7,6 +7,8 @@ import librosa import numpy as np +import torch +from beat_this.inference import File2Beats HOP_LENGTH = 512 BASS_RMS_HOP_MS = 0.02 @@ -100,37 +102,18 @@ def extract_drums_onset(path: Path | str) -> tuple[np.ndarray, np.ndarray]: return values, times -def extract_drums_beats(path: Path | str) -> np.ndarray: - """Beat times in seconds from the drum stem. +def extract_beats_downbeats(path: Path | str) -> tuple[np.ndarray, np.ndarray]: + """Beat and downbeat times in seconds from the mixed source track. - Uses a per-frame tempo curve so the DP tracker can follow tempo changes. - Falls back to a global-tempo track if the dynamic run fails or finds no beats. + Runs Beat This! (`File2Beats`) on the mix path. """ - y, sr = _load(path) - onset_env = librosa.onset.onset_strength(y=y, sr=sr, hop_length=HOP_LENGTH) - frames: np.ndarray - try: - dtempo = librosa.feature.tempo( - onset_envelope=onset_env, - sr=sr, - hop_length=HOP_LENGTH, - aggregate=None, - ) - _tempo, frames = librosa.beat.beat_track( - onset_envelope=onset_env, - sr=sr, - hop_length=HOP_LENGTH, - bpm=dtempo, - ) - if len(frames) == 0: - raise ValueError("dynamic beat_track returned no beats") - except Exception: - _tempo, frames = librosa.beat.beat_track( - onset_envelope=onset_env, - sr=sr, - hop_length=HOP_LENGTH, - ) - return librosa.frames_to_time(frames, sr=sr, hop_length=HOP_LENGTH) + device = "cuda" if torch.cuda.is_available() else "cpu" + file2beats = File2Beats(checkpoint_path="final0", device=device, dbn=False) + beats, downbeats = file2beats(str(path)) + return ( + np.asarray(beats, dtype=np.float64), + np.asarray(downbeats, dtype=np.float64), + ) def extract_mix_onset(path: Path | str) -> tuple[np.ndarray, np.ndarray]: diff --git a/cleave/separate.py b/cleave/separate.py index 5f83c95..9fd88c9 100644 --- a/cleave/separate.py +++ b/cleave/separate.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import shutil import subprocess import sys @@ -13,6 +14,7 @@ from cleave.extract import stem_paths, stems_dir from cleave.paths import project_dir, project_slug, resolve_project from cleave.project import load_manifest, manifest_path, mix_path, write_manifest +from cleave.signals import SIGNALS_VERSION def project_stems_complete(project_dir: Path) -> bool: @@ -22,8 +24,18 @@ def project_stems_complete(project_dir: Path) -> bool: def signals_complete(project_dir: Path) -> bool: - """Return True when ``signals.json`` exists in *project_dir*.""" - return (project_dir / "signals.json").is_file() + """Return True when ``signals.json`` exists at the current schema version.""" + path = project_dir / "signals.json" + if not path.is_file(): + return False + try: + with path.open(encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return False + if not isinstance(data, dict): + return False + return data.get("version") == SIGNALS_VERSION def resolve_separate_target(path_or_slug: Path | str) -> tuple[Path, Path]: diff --git a/cleave/signals.py b/cleave/signals.py index 10287c3..5b06aa0 100644 --- a/cleave/signals.py +++ b/cleave/signals.py @@ -8,8 +8,10 @@ import numpy as np -_META_KEYS = frozenset({"version", "sample_rate_hz", "duration_sec", "beat_times"}) -SIGNALS_VERSION = 2 +_META_KEYS = frozenset( + {"version", "sample_rate_hz", "duration_sec", "beat_times", "downbeat_times"} +) +SIGNALS_VERSION = 3 _EXPECTED_STEMS = frozenset({"drums", "bass", "vocals", "other", "full_mix"}) _FULL_MIX_KEYS = frozenset({"onset_strength", "rms"}) @@ -33,6 +35,7 @@ class Signals: path: Path stems: dict[str, dict[str, np.ndarray]] = field(repr=False) beat_times: tuple[float, ...] = () + downbeat_times: tuple[float, ...] = () _normalized_cache: dict[tuple[str, str, float], np.ndarray] = field( default_factory=dict, init=False, repr=False ) @@ -123,6 +126,8 @@ def load_signals(path: Path) -> Signals: raw_beats = data.get("beat_times", []) beat_times = tuple(float(t) for t in raw_beats) + raw_downbeats = data.get("downbeat_times", []) + downbeat_times = tuple(float(t) for t in raw_downbeats) return Signals( sample_rate_hz=float(data["sample_rate_hz"]), @@ -130,4 +135,5 @@ def load_signals(path: Path) -> Signals: path=signals_path, stems=stems, beat_times=beat_times, + downbeat_times=downbeat_times, ) diff --git a/cleave/timeline.py b/cleave/timeline.py index 8504c15..4d22458 100644 --- a/cleave/timeline.py +++ b/cleave/timeline.py @@ -201,78 +201,35 @@ def _nearest_with_earlier_tie(t: float, candidates: Sequence[float]) -> float: return min(candidates, key=lambda c: (abs(c - t), c)) -def bar_phase_from_beats( +def shift_bars_by_beats( + downbeat_times: Sequence[float], beat_times: Sequence[float], - onset_at_beats: Sequence[float], - *, - beats_per_bar: int = 4, -) -> int | None: - """Pick 4/4 bar phase by maximizing summed onset at candidate downbeats.""" - if len(beat_times) != len(onset_at_beats): - raise ValueError( - "beat_times and onset_at_beats must have the same length " - f"(got {len(beat_times)} and {len(onset_at_beats)})" - ) - if beats_per_bar < 1: - raise ValueError(f"beats_per_bar must be >= 1 (got {beats_per_bar})") - n = (len(beat_times) // beats_per_bar) * beats_per_bar - if n < beats_per_bar: - return None - onset = np.asarray(onset_at_beats[:n], dtype=np.float64).reshape( - -1, beats_per_bar - ) - return int(np.argmax(onset.sum(axis=0))) - - -def bar_times_at_phase( - beat_times: Sequence[float], - phase: int, - *, - beats_per_bar: int = 4, + offset: int, ) -> tuple[float, ...]: - """Return every ``beats_per_bar``-th beat starting at ``phase``.""" - if beats_per_bar < 1: - raise ValueError(f"beats_per_bar must be >= 1 (got {beats_per_bar})") - if not beat_times: - return () - phase = phase % beats_per_bar - return tuple(beat_times[phase::beats_per_bar]) + """Map each downbeat to the beat ``offset`` positions away (clamped). - -def bar_times_from_beats( - beat_times: Sequence[float], - onset_at_beats: Sequence[float], - *, - beats_per_bar: int = 4, -) -> tuple[float, ...]: - """Bar grid at the onset-strongest phase (see ``bar_phase_from_beats``).""" - k = bar_phase_from_beats( - beat_times, onset_at_beats, beats_per_bar=beats_per_bar - ) - if k is None: + Each downbeat is matched to the nearest beat (earlier on a tie), then the + beat index is shifted by ``offset`` and clamped to the beat grid. + """ + if not downbeat_times or not beat_times: return () - return bar_times_at_phase(beat_times, k, beats_per_bar=beats_per_bar) - - -def bar_phase_matching( - beat_times: Sequence[float], - bar_times: Sequence[float], - *, - beats_per_bar: int = 4, -) -> int | None: - """Return phase ``k`` where ``bar_times_at_phase`` equals ``bar_times``.""" - if beats_per_bar < 1: - raise ValueError(f"beats_per_bar must be >= 1 (got {beats_per_bar})") - if not beat_times or not bar_times: - return None - target = tuple(float(t) for t in bar_times) - for k in range(beats_per_bar): - if ( - bar_times_at_phase(beat_times, k, beats_per_bar=beats_per_bar) - == target - ): - return k - return None + beats = np.asarray(beat_times, dtype=np.float64) + last = len(beats) - 1 + result: list[float] = [] + for t in downbeat_times: + idx = int(np.searchsorted(beats, float(t))) + candidates: list[int] = [] + if idx > 0: + candidates.append(idx - 1) + if idx <= last: + candidates.append(idx) + nearest = min( + candidates, + key=lambda i: (abs(float(beats[i]) - float(t)), float(beats[i])), + ) + shifted = max(0, min(last, nearest + offset)) + result.append(float(beats[shifted])) + return tuple(result) def snap_lane_to_beats( diff --git a/cleave/viz/timeline_snap_controls.py b/cleave/viz/timeline_snap_controls.py index a4af8fc..f8d63ed 100644 --- a/cleave/viz/timeline_snap_controls.py +++ b/cleave/viz/timeline_snap_controls.py @@ -5,17 +5,15 @@ from collections.abc import Callable, Sequence from cleave.timeline import ( - bar_phase_matching, - bar_times_at_phase, empty_lane, + shift_bars_by_beats, snap_lane_to_beats, ) from cleave.viz.modal import ModalHost, ModalOption from cleave.viz.session import TuningSession _CANCEL_LABEL = "Cancel" -_BEATS_PER_BAR = 4 -_BAR_PHASE_OFFSETS = (0, 1, 2, 3) +_BAR_BEAT_OFFSETS = (0, 1, 2, 3) _BAR_SNAP_MESSAGE = "Snap timeline cues to nearest bar (choose bar phase)" @@ -35,12 +33,6 @@ def __init__( self._modal = modal_host self._beat_times = tuple(beat_times) self._bar_times = tuple(bar_times) - matched = bar_phase_matching( - self._beat_times, - self._bar_times, - beats_per_bar=_BEATS_PER_BAR, - ) - self._bar_phase = 0 if matched is None else matched self._on_notification = on_notification def prompt(self) -> None: @@ -70,22 +62,21 @@ def prompt_bars(self) -> None: f"{offset:+d}", lambda o=offset: self._snap_bars_at_offset(o), ) - for offset in _BAR_PHASE_OFFSETS + for offset in _BAR_BEAT_OFFSETS ] options.append(ModalOption(_CANCEL_LABEL, dismiss)) self._modal.prompt_choice( _BAR_SNAP_MESSAGE, options, on_dismiss=dismiss, - initial_focus_index=_BAR_PHASE_OFFSETS.index(0), + initial_focus_index=_BAR_BEAT_OFFSETS.index(0), ) def _snap_bars_at_offset(self, offset: int) -> None: - phase = (self._bar_phase + offset) % _BEATS_PER_BAR - grid = bar_times_at_phase( + grid = shift_bars_by_beats( + self._bar_times, self._beat_times, - phase, - beats_per_bar=_BEATS_PER_BAR, + offset, ) label = f"{offset:+d}" notify_msg = f"Snapped timeline cues to bars ({label})" diff --git a/cleave/viz/wiring.py b/cleave/viz/wiring.py index e85da60..3269bcf 100644 --- a/cleave/viz/wiring.py +++ b/cleave/viz/wiring.py @@ -46,7 +46,6 @@ sync_manual_browse_with_user_defined_rotation, ) from cleave.stem_pcm import StemPcmBank -from cleave.timeline import bar_times_from_beats from cleave.viz.playback import current_sec, seek from cleave.config import VIZ_CONFIG_FILENAME @@ -423,11 +422,7 @@ def on_chroma_boost_apply_mode_change(old_mode: str, new_mode: str) -> None: bar_times: list[float] = [] if signals is not None: beat_times = list(signals.beat_times) - if beat_times: - onset_at_beats = [ - signals.sample("drums", "onset_strength", t) for t in beat_times - ] - bar_times = list(bar_times_from_beats(beat_times, onset_at_beats)) + bar_times = list(signals.downbeat_times) kwargs: dict = { "session": session, diff --git a/docs/legacy-plans/BeatThis-plan.md b/docs/legacy-plans/BeatThis-plan.md new file mode 100644 index 0000000..4018cb5 --- /dev/null +++ b/docs/legacy-plans/BeatThis-plan.md @@ -0,0 +1,59 @@ +# Beat This! beat and downbeat plan + +Stronger automatic beat and bar grids via [Beat This!](https://github.com/CPJKU/beat_this). + +Tracked under [todos.md](todos.md) (Stronger beat and downbeat detection). + +## Goal + +Replace librosa drum-stem beat tracking with a trained beat and downbeat model so timeline snap lands on the pulse and bar 1 more reliably. Existing projects re-run analyse (rewrite `signals.json`); Demucs stems stay. No backward compatibility. + +## Choice: Beat This! + +Prefer [Beat This!](https://github.com/CPJKU/beat_this) (ISMIR 2024, CPJKU) over [madmom](https://github.com/CPJKU/madmom). + +- Current SOTA beat **and** native downbeat output (`File2Beats` → beats, downbeats). +- PyTorch stack fits the cleave env (already used for Demucs). +- Installs on modern Python / numpy; madmom does not fit cleanly (legacy PyPI, numpy constraints). +- Optional madmom DBN postprocessing is not needed for v1. + +## Implemented + +- Analyse: [cleave/extract.py](../cleave/extract.py) `extract_beats_downbeats` runs Beat This! on the **mix**. +- Persist: `signals.json` version 3 with `beat_times` and `downbeat_times` ([cleave/analyse.py](../cleave/analyse.py), [cleave/signals.py](../cleave/signals.py)). Non-v3 is stale for `separate.signals_complete`. +- Wiring: [cleave/viz/wiring.py](../cleave/viz/wiring.py) sets `bar_times` from `signals.downbeat_times`. +- Bar snap nudge: [cleave/timeline.py](../cleave/timeline.py) `shift_bars_by_beats` shifts each downbeat by N beats on the beat grid; [cleave/viz/timeline_snap_controls.py](../cleave/viz/timeline_snap_controls.py) offers `+0`/`+1`/`+2`/`+3`. + +Re-analyse: `cleave separate ` or `cleave play` (stems reused). + +## Separation of concerns + +Beat This! owns the **timeline grid only**. Leave onset envelopes alone for cleave effects. + +| Consumer | Need | Source | +| --- | --- | --- | +| Timeline snap | Sparse event times (beat / bar 1) | `beat_times` + `downbeat_times` from Beat This! | +| Effects (`pulse` / `flash` / `grit`) | Dense continuous energy | librosa `onset_strength` (drums / full_mix) at 100 Hz | + +Effects sample normalized envelopes every frame. Beat This! outputs event times, not a general transient driver. + +``` +Demucs stems + mix + | + +-- Beat This! on mix --> beat_times, downbeat_times (timeline / future MIDI) + | + +-- librosa (etc.) -----> onset_strength, rms, ... (effect drivers) + | + v + signals.json + | + +-------+--------+ + timeline snap effects runtime + (discrete grids) (continuous curves) +``` + +## Out of scope / known limits + +- Quiet intros, drum-sparse sections, half-time and double-time flips, unusual meters: better models help; they do not remove the need for later sparse song anchors ([todos.md](todos.md)). +- New effect drivers keyed to beats (e.g. explicit beat flash): possible later as a separate roster entry, not part of this swap. +- Manual dense cueing: not the goal; automation first. diff --git a/docs/todos.md b/docs/todos.md index 65a465f..b99629e 100644 --- a/docs/todos.md +++ b/docs/todos.md @@ -17,21 +17,6 @@ Outstanding bugs and issues. ## Beat grid and cue snap -Low user effort is the goal: improve the automatic grid before adding manual -workflow. Existing projects must re-run analyse (rewrite `signals.json`) after -beat-detection changes; Demucs stems can stay. - -### Stronger beat and downbeat detection -* Current analyse uses drum-stem librosa with a per-frame tempo curve - ([cleave/extract.py](../cleave/extract.py) `extract_drums_beats`). Better than - a single global tempo, but still not reliably "on the pop." -* Next: investigate [madmom](https://github.com/CPJKU/madmom) (or similar trained - beat and downbeat models) for stronger bar alignment and tempo-map quality. -* Same persisted `beat_times` (and optional downbeats) feed timeline snap and - future MIDI out; expect a heavier analyse step and a new dependency. -* Known remaining weak spots even with better models: quiet intros / drum-sparse - sections, half-time and double-time flips, unusual meters. - ### Sparse song anchors (after the grid is solid) * Manual "drop song cue" at major transitions for guaranteed pops solves a different problem from beat snap (structure vs pulse). diff --git a/requirements.txt b/requirements.txt index dd2b1e5..ef631d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ +beat-this==1.1.0 demucs==4.0.1 +einops==0.8.2 librosa==0.11.0 matplotlib==3.10.9 moderngl==5.12.0 @@ -6,5 +8,8 @@ numpy==2.2.6 pygame==2.6.1 PyOpenGL==3.1.10 PyYAML==6.0.3 +rotary-embedding-torch==0.9.1 setuptools==80.8.0 soundfile==0.13.1 +soxr==1.1.0 +tqdm==4.67.3 diff --git a/tests/cleave/test_analyse.py b/tests/cleave/test_analyse.py index 81e0ca1..2faa3b2 100644 --- a/tests/cleave/test_analyse.py +++ b/tests/cleave/test_analyse.py @@ -52,13 +52,16 @@ def _write_project(project: Path) -> None: @patch("cleave.analyse.extract_other", return_value=_stub_signal()) @patch("cleave.analyse.extract_vocals", return_value=_stub_vocals()) @patch("cleave.analyse.extract_bass", return_value=_stub_bass()) -@patch("cleave.analyse.extract_drums_beats", return_value=np.array([0.5, 1.0, 1.5])) +@patch( + "cleave.analyse.extract_beats_downbeats", + return_value=(np.array([0.5, 1.0, 1.5]), np.array([0.5, 1.5])), +) @patch("cleave.analyse.extract_drums_onset", return_value=_stub_signal()) @patch("cleave.analyse._stem_duration_sec", return_value=1.0) -def test_run_analyse_writes_version_2_full_mix( +def test_run_analyse_writes_version_3_full_mix( _duration: object, _drums: object, - _drums_beats: object, + _beats_downbeats: object, _bass: object, _vocals: object, _other: object, @@ -72,12 +75,13 @@ def test_run_analyse_writes_version_2_full_mix( signals_path = run_analyse(project, high_quality=False) data = json.loads(signals_path.read_text(encoding="utf-8")) - assert data["version"] == 2 + assert data["version"] == 3 assert "mix_onset_strength" not in data["drums"] assert set(data["full_mix"]) == {"onset_strength", "rms"} assert len(data["full_mix"]["onset_strength"]) > 0 assert len(data["full_mix"]["rms"]) > 0 assert data["beat_times"] == [0.5, 1.0, 1.5] + assert data["downbeat_times"] == [0.5, 1.5] @patch("cleave.analyse.extract_mix_rms", return_value=_stub_signal()) @@ -85,13 +89,16 @@ def test_run_analyse_writes_version_2_full_mix( @patch("cleave.analyse.extract_other", return_value=_stub_signal()) @patch("cleave.analyse.extract_vocals", return_value=_stub_vocals()) @patch("cleave.analyse.extract_bass", return_value=_stub_bass()) -@patch("cleave.analyse.extract_drums_beats", return_value=np.array([])) +@patch( + "cleave.analyse.extract_beats_downbeats", + return_value=(np.array([]), np.array([])), +) @patch("cleave.analyse.extract_drums_onset", return_value=_stub_signal()) @patch("cleave.analyse._stem_duration_sec", return_value=1.0) def test_run_analyse_empty_beats_warns_and_persists_empty( _duration: object, _drums: object, - _drums_beats: object, + _beats_downbeats: object, _bass: object, _vocals: object, _other: object, @@ -107,7 +114,10 @@ def test_run_analyse_empty_beats_warns_and_persists_empty( data = json.loads(signals_path.read_text(encoding="utf-8")) assert data["beat_times"] == [] - assert "drum stem beat detection produced no useful data" in capsys.readouterr().out + assert data["downbeat_times"] == [] + out = capsys.readouterr().out + assert "mix beat detection produced no useful data" in out + assert "mix downbeat detection produced no useful data" in out @patch( diff --git a/tests/cleave/test_cli.py b/tests/cleave/test_cli.py index 23ae469..979b049 100644 --- a/tests/cleave/test_cli.py +++ b/tests/cleave/test_cli.py @@ -66,7 +66,7 @@ def test_cmd_separate_noop_when_complete( original_path=tmp_path / "song.flac", demucs_model="htdemucs", ) - (project / "signals.json").write_text("{}") + (project / "signals.json").write_text('{"version": 3}') with patch("cleave.cli.run_separate") as run_separate: cmd_separate(build_parser().parse_args(["separate", "song"])) @@ -175,7 +175,7 @@ def _complete_project(tmp_path: Path, slug: str = "my-track") -> Path: original_path=tmp_path / f"{slug}.flac", demucs_model="htdemucs", ) - (project / "signals.json").write_text("{}") + (project / "signals.json").write_text('{"version": 3}') return project diff --git a/tests/cleave/test_extract_beats.py b/tests/cleave/test_extract_beats.py index 77813ff..a4fb607 100644 --- a/tests/cleave/test_extract_beats.py +++ b/tests/cleave/test_extract_beats.py @@ -1,77 +1,36 @@ -"""Tempo-aware drum beat tracking.""" +"""Tests for Beat This! beat and downbeat extraction.""" from __future__ import annotations from pathlib import Path +from unittest.mock import MagicMock, patch import numpy as np -import soundfile as sf -from cleave.extract import extract_drums_beats +from cleave.extract import extract_beats_downbeats -def _tempo_step_clicks( - *, - sr: int, - bpm_slow: float, - bpm_fast: float, - sec_slow: float, - sec_fast: float, -) -> np.ndarray: - """Impulse clicks that step from bpm_slow to bpm_fast at sec_slow.""" - n = int(sr * (sec_slow + sec_fast)) - y = np.zeros(n, dtype=np.float32) - click_n = max(1, int(0.01 * sr)) - click = np.sin(2 * np.pi * 1000.0 * np.arange(click_n) / sr).astype(np.float32) - click *= np.linspace(1.0, 0.0, click_n, dtype=np.float32) +@patch("cleave.extract.File2Beats") +@patch("cleave.extract.torch.cuda.is_available", return_value=False) +def test_extract_beats_downbeats_uses_file2beats( + _cuda: object, + mock_file2beats_cls: MagicMock, + tmp_path: Path, +) -> None: + path = tmp_path / "mix.wav" + path.write_bytes(b"wav") + instance = mock_file2beats_cls.return_value + instance.return_value = ([0.5, 1.0, 1.5], [0.5, 1.5]) - def _place(start_sec: float, end_sec: float, bpm: float) -> None: - t = start_sec - period = 60.0 / bpm - while t < end_sec: - i = int(t * sr) - end = min(n, i + click_n) - y[i:end] += click[: end - i] - t += period + beats, downbeats = extract_beats_downbeats(path) - _place(0.0, sec_slow, bpm_slow) - _place(sec_slow, sec_slow + sec_fast, bpm_fast) - peak = float(np.max(np.abs(y))) - if peak > 0.0: - y /= peak - return y - - -def test_extract_drums_beats_tracks_tempo_step(tmp_path: Path) -> None: - """Detected inter-beat intervals should shrink after a tempo increase.""" - sr = 22050 - bpm_slow, bpm_fast = 90.0, 140.0 - sec_slow, sec_fast = 4.0, 4.0 - path = tmp_path / "tempo_step.wav" - sf.write( - path, - _tempo_step_clicks( - sr=sr, - bpm_slow=bpm_slow, - bpm_fast=bpm_fast, - sec_slow=sec_slow, - sec_fast=sec_fast, - ), - sr, + mock_file2beats_cls.assert_called_once_with( + checkpoint_path="final0", + device="cpu", + dbn=False, ) - - beats = extract_drums_beats(path) - assert len(beats) >= 8 - - # Ignore a small window around the tempo change. - margin = 0.25 - early = beats[beats < sec_slow - margin] - late = beats[beats > sec_slow + margin] - assert len(early) >= 3 - assert len(late) >= 3 - - early_ibi = float(np.median(np.diff(early))) - late_ibi = float(np.median(np.diff(late))) - assert late_ibi < early_ibi * 0.85 - assert abs(early_ibi - 60.0 / bpm_slow) < 0.08 - assert abs(late_ibi - 60.0 / bpm_fast) < 0.08 + instance.assert_called_once_with(str(path)) + assert beats.dtype == np.float64 + assert downbeats.dtype == np.float64 + np.testing.assert_array_equal(beats, np.array([0.5, 1.0, 1.5], dtype=np.float64)) + np.testing.assert_array_equal(downbeats, np.array([0.5, 1.5], dtype=np.float64)) diff --git a/tests/cleave/test_separate.py b/tests/cleave/test_separate.py index c3f6ca2..0431798 100644 --- a/tests/cleave/test_separate.py +++ b/tests/cleave/test_separate.py @@ -46,13 +46,34 @@ def test_signals_complete_false_when_missing(tmp_path: Path) -> None: assert signals_complete(project) is False -def test_signals_complete_true_when_present(tmp_path: Path) -> None: +def test_signals_complete_true_when_current_version(tmp_path: Path) -> None: project = tmp_path / "my-track" project.mkdir() - (project / "signals.json").write_text("{}") + (project / "signals.json").write_text('{"version": 3}') assert signals_complete(project) is True +def test_signals_complete_false_when_stale_version(tmp_path: Path) -> None: + project = tmp_path / "my-track" + project.mkdir() + (project / "signals.json").write_text('{"version": 2}') + assert signals_complete(project) is False + + +def test_signals_complete_false_when_corrupt(tmp_path: Path) -> None: + project = tmp_path / "my-track" + project.mkdir() + (project / "signals.json").write_text("not-json") + assert signals_complete(project) is False + + +def test_signals_complete_false_when_missing_version(tmp_path: Path) -> None: + project = tmp_path / "my-track" + project.mkdir() + (project / "signals.json").write_text("{}") + assert signals_complete(project) is False + + def test_resolve_separate_target_audio_file( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: @@ -98,7 +119,7 @@ def test_run_separate_writes_project_viz_config( project = tmp_path / "projects" / "my-track" project.mkdir(parents=True) _write_stub_stems(project) - (project / "signals.json").write_text("{}") + (project / "signals.json").write_text('{"version": 3}') with patch("cleave.separate._run_demucs") as run_demucs, patch( "cleave.separate.run_analyse" @@ -123,7 +144,7 @@ def test_run_separate_noop_when_stems_and_signals_exist( project = tmp_path / "projects" / "my-track" project.mkdir(parents=True) _write_stub_stems(project) - (project / "signals.json").write_text("{}") + (project / "signals.json").write_text('{"version": 3}') with patch("cleave.separate._run_demucs") as run_demucs, patch( "cleave.separate.run_analyse" @@ -135,6 +156,34 @@ def test_run_separate_noop_when_stems_and_signals_exist( run_analyse.assert_not_called() +def test_run_separate_analyse_only_when_stems_exist_stale_signals( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setenv("CLEAVE_DATA", str(tmp_path)) + project = tmp_path / "projects" / "my-track" + project.mkdir(parents=True) + _write_stub_stems(project) + mix = project / "my-track.flac" + mix.write_bytes(b"mix") + write_manifest( + project, + slug="my-track", + mix_filename="my-track.flac", + original_path=tmp_path / "my-track.flac", + demucs_model="htdemucs", + ) + (project / "signals.json").write_text('{"version": 2}') + + with patch("cleave.separate._run_demucs") as run_demucs, patch( + "cleave.separate.run_analyse", return_value=project / "signals.json" + ) as run_analyse: + result = run_separate("my-track") + + assert result == project.resolve() + run_demucs.assert_not_called() + run_analyse.assert_called_once_with(project.resolve(), high_quality=False) + + def test_run_separate_analyse_only_when_stems_exist_no_signals( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: diff --git a/tests/cleave/test_signals.py b/tests/cleave/test_signals.py index 868a96d..231797f 100644 --- a/tests/cleave/test_signals.py +++ b/tests/cleave/test_signals.py @@ -31,7 +31,7 @@ def test_load_signals_minimal_fixture( minimal_signals_json_path: Path, ) -> None: raw = json.loads(minimal_signals_json_path.read_text(encoding="utf-8")) - assert raw["version"] == 2 + assert raw["version"] == 3 signals = load_signals(minimal_signals_json_path) @@ -39,6 +39,7 @@ def test_load_signals_minimal_fixture( assert signals.sample_rate_hz == 100.0 assert signals.duration_sec == pytest.approx(0.14) assert signals.beat_times == (0.0, 0.5, 1.0) + assert signals.downbeat_times == (0.0, 1.0) pitch = signals.array("vocals", "pitch_hz") assert math.isnan(pitch[2]) @@ -55,7 +56,7 @@ def test_load_signals_missing_beat_times_defaults_empty(tmp_path: Path) -> None: path.write_text( json.dumps( { - "version": 2, + "version": 3, "sample_rate_hz": 100, "duration_sec": 0.1, "drums": {"onset_strength": [0.0]}, @@ -69,6 +70,7 @@ def test_load_signals_missing_beat_times_defaults_empty(tmp_path: Path) -> None: ) signals = load_signals(path) assert signals.beat_times == () + assert signals.downbeat_times == () def test_load_signals_rejects_version_1(tmp_path: Path) -> None: @@ -91,7 +93,7 @@ def test_load_signals_rejects_version_1(tmp_path: Path) -> None: load_signals(path) -def test_load_signals_rejects_missing_full_mix(tmp_path: Path) -> None: +def test_load_signals_rejects_version_2(tmp_path: Path) -> None: path = tmp_path / "signals.json" path.write_text( json.dumps( @@ -99,6 +101,28 @@ def test_load_signals_rejects_missing_full_mix(tmp_path: Path) -> None: "version": 2, "sample_rate_hz": 100, "duration_sec": 0.1, + "beat_times": [0.0, 0.5], + "drums": {"onset_strength": [0.0]}, + "bass": {"rms": [0.0], "sub_bass": [0.0], "mid_bass": [0.0]}, + "vocals": {"rms": [0.0], "pitch_hz": [220.0]}, + "other": {"spectral_centroid": [1000.0]}, + "full_mix": {"onset_strength": [0.0], "rms": [0.0]}, + } + ), + encoding="utf-8", + ) + with pytest.raises(ValueError, match="unsupported signals.json version"): + load_signals(path) + + +def test_load_signals_rejects_missing_full_mix(tmp_path: Path) -> None: + path = tmp_path / "signals.json" + path.write_text( + json.dumps( + { + "version": 3, + "sample_rate_hz": 100, + "duration_sec": 0.1, "drums": {"onset_strength": [0.0]}, "bass": {"rms": [0.0], "sub_bass": [0.0], "mid_bass": [0.0]}, "vocals": {"rms": [0.0], "pitch_hz": [220.0]}, diff --git a/tests/cleave/test_timeline_snap.py b/tests/cleave/test_timeline_snap.py index 75c1bd1..4c9ef7f 100644 --- a/tests/cleave/test_timeline_snap.py +++ b/tests/cleave/test_timeline_snap.py @@ -1,16 +1,11 @@ -"""Tests for per-lane timeline beat snapping and bar-phase derivation.""" +"""Tests for per-lane timeline beat snapping and bar-grid nudge.""" from __future__ import annotations -import pytest - from cleave.timeline import ( SlotCue, TimelineLane, - bar_phase_from_beats, - bar_phase_matching, - bar_times_at_phase, - bar_times_from_beats, + shift_bars_by_beats, snap_lane_to_beats, ) @@ -86,58 +81,23 @@ def test_snap_preserves_baseline() -> None: assert result.cues == [SlotCue(t=0.0, visible=False)] -def test_bar_times_picks_strongest_phase() -> None: - # Two bars; phase 2 has the strongest onsets. - beat_times = (0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0) - onset_at_beats = (1.0, 1.0, 5.0, 1.0, 1.0, 1.0, 5.0, 1.0) - assert bar_phase_from_beats(beat_times, onset_at_beats) == 2 - assert bar_times_from_beats(beat_times, onset_at_beats) == (2.0, 6.0) - - -def test_bar_times_short_input_returns_empty() -> None: - assert bar_phase_from_beats((0.0, 1.0, 2.0), (1.0, 1.0, 1.0)) is None - assert bar_times_from_beats((0.0, 1.0, 2.0), (1.0, 1.0, 1.0)) == () - - -def test_bar_times_n4_slicing() -> None: - beat_times = tuple(float(i) for i in range(12)) - # Phase 0 strongest. - onset = [3.0, 0.0, 0.0, 0.0] * 3 - assert bar_times_from_beats(beat_times, onset) == (0.0, 4.0, 8.0) - - -def test_bar_times_at_phase_slices() -> None: +def test_shift_bars_by_beats_offsets() -> None: beat_times = tuple(float(i) for i in range(8)) - assert bar_times_at_phase(beat_times, 0) == (0.0, 4.0) - assert bar_times_at_phase(beat_times, 1) == (1.0, 5.0) - assert bar_times_at_phase(beat_times, 2) == (2.0, 6.0) - assert bar_times_at_phase(beat_times, 3) == (3.0, 7.0) - - -def test_bar_times_at_phase_wraps_and_offsets_heuristic() -> None: - beat_times = (0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0) - onset_at_beats = (1.0, 1.0, 5.0, 1.0, 1.0, 1.0, 5.0, 1.0) - base = bar_phase_from_beats(beat_times, onset_at_beats) - assert base == 2 - assert bar_times_at_phase(beat_times, (base + 0) % 4) == (2.0, 6.0) - assert bar_times_at_phase(beat_times, (base + 1) % 4) == (3.0, 7.0) - assert bar_times_at_phase(beat_times, (base + 2) % 4) == (0.0, 4.0) - assert bar_times_at_phase(beat_times, (base + 3) % 4) == (1.0, 5.0) + downbeats = (0.0, 4.0) + assert shift_bars_by_beats(downbeats, beat_times, 0) == (0.0, 4.0) + assert shift_bars_by_beats(downbeats, beat_times, 1) == (1.0, 5.0) + assert shift_bars_by_beats(downbeats, beat_times, 2) == (2.0, 6.0) + assert shift_bars_by_beats(downbeats, beat_times, 3) == (3.0, 7.0) -def test_bar_times_at_phase_empty_beats() -> None: - assert bar_times_at_phase((), 0) == () +def test_shift_bars_by_beats_clamps() -> None: + beat_times = (0.0, 1.0, 2.0, 3.0) + assert shift_bars_by_beats((0.0, 3.0), beat_times, 2) == (2.0, 3.0) + assert shift_bars_by_beats((0.0,), beat_times, -1) == (0.0,) -def test_bar_times_length_mismatch_raises() -> None: - with pytest.raises(ValueError, match="same length"): - bar_times_from_beats((0.0, 1.0, 2.0, 3.0), (1.0, 1.0, 1.0)) - - -def test_bar_phase_matching_finds_phase() -> None: - beat_times = tuple(float(i) for i in range(8)) - assert bar_phase_matching(beat_times, (0.0, 4.0)) == 0 - assert bar_phase_matching(beat_times, (2.0, 6.0)) == 2 - assert bar_phase_matching(beat_times, (1.0, 9.0)) is None - assert bar_phase_matching((), (0.0,)) is None - assert bar_phase_matching(beat_times, ()) is None +def test_shift_bars_by_beats_nearest_and_empty() -> None: + beat_times = (0.0, 1.0, 2.0, 3.0, 4.0) + assert shift_bars_by_beats((0.4, 3.6), beat_times, 1) == (1.0, 4.0) + assert shift_bars_by_beats((), beat_times, 1) == () + assert shift_bars_by_beats((0.0,), (), 1) == () diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 1e91f51..1b616ac 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -1680,7 +1680,7 @@ def test_timeline_snap_bars_confirm_mutates_cues() -> None: assert view.notification_message == "Snapped timeline cues to bars (+0)" -def test_timeline_snap_bars_confirm_plus_one_uses_phase_grid() -> None: +def test_timeline_snap_bars_confirm_plus_one_shifts_by_beats() -> None: controls = _make_controls( ("layer_1", "layer_2"), beat_times=(0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0), @@ -1694,7 +1694,7 @@ def test_timeline_snap_bars_confirm_plus_one_uses_phase_grid() -> None: controls.handle_keydown(_keydown(pygame.K_RETURN)) _choose_modal_option(controls, "+1") assert not controls.modal_host.active - # Phase 0 bars are 0/4; +1 -> phase 1 bars 1/5 + # Downbeats 0/4 shifted +1 beat -> 1/5 assert controls.session.timeline.lanes["layer_1"].cues == [ SlotCue(t=1.0, visible=True), ] @@ -1705,7 +1705,7 @@ def test_timeline_snap_bars_confirm_plus_one_uses_phase_grid() -> None: assert view.notification_message == "Snapped timeline cues to bars (+1)" -def test_timeline_snap_bars_confirm_plus_three_uses_phase_grid() -> None: +def test_timeline_snap_bars_confirm_plus_three_shifts_by_beats() -> None: controls = _make_controls( ("layer_1", "layer_2"), beat_times=tuple(float(i) for i in range(12)), @@ -1719,7 +1719,7 @@ def test_timeline_snap_bars_confirm_plus_three_uses_phase_grid() -> None: controls.handle_keydown(_keydown(pygame.K_RETURN)) _choose_modal_option(controls, "+3") assert not controls.modal_host.active - # Phase 0 bars 0/4/8; +3 -> phase 3 bars 3/7/11 + # Downbeats 0/4/8 shifted +3 beats -> 3/7/11 assert controls.session.timeline.lanes["layer_1"].cues == [ SlotCue(t=3.0, visible=True), ] @@ -1730,8 +1730,8 @@ def test_timeline_snap_bars_confirm_plus_three_uses_phase_grid() -> None: assert view.notification_message == "Snapped timeline cues to bars (+3)" -def test_timeline_snap_bars_phase_stays_on_beat_grid() -> None: - """After any bar phase snap, beat snap is a no-op (even with uneven beats).""" +def test_timeline_snap_bars_shift_stays_on_beat_grid() -> None: + """After any bar-offset snap, beat snap is a no-op (even with uneven beats).""" beat_times = (0.0, 0.9, 2.1, 3.0, 4.2, 5.0, 6.1, 7.0) bar_times = (0.0, 4.2) controls = _make_controls( diff --git a/tests/cleave/viz/test_render.py b/tests/cleave/viz/test_render.py index 28cca57..aba1921 100644 --- a/tests/cleave/viz/test_render.py +++ b/tests/cleave/viz/test_render.py @@ -142,7 +142,7 @@ def _setup_render_project( overrides["editor"] = editor write_minimal_config(project, preset_root, **overrides) _write_stub_stems(project) - (project / "signals.json").write_text("{}") + (project / "signals.json").write_text('{"version": 3}') mix = project / "my-track.flac" mix.write_bytes(b"mix") write_manifest( @@ -200,7 +200,7 @@ def test_validate_render_project_missing_viz_config(tmp_path: Path) -> None: project = tmp_path / "my-track" project.mkdir() _write_stub_stems(project) - (project / "signals.json").write_text("{}") + (project / "signals.json").write_text('{"version": 3}') mix = project / "my-track.flac" mix.write_bytes(b"mix") write_manifest( @@ -219,7 +219,7 @@ def test_validate_render_project_missing_mix(tmp_path: Path) -> None: project = tmp_path / "my-track" write_minimal_config(project, preset_root) _write_stub_stems(project) - (project / "signals.json").write_text("{}") + (project / "signals.json").write_text('{"version": 3}') write_manifest( project, slug="my-track", diff --git a/tests/fixtures/minimal_signals.json b/tests/fixtures/minimal_signals.json index 4a54891..5cd719f 100644 --- a/tests/fixtures/minimal_signals.json +++ b/tests/fixtures/minimal_signals.json @@ -1,8 +1,9 @@ { - "version": 2, + "version": 3, "sample_rate_hz": 100, "duration_sec": 0.14, "beat_times": [0.0, 0.5, 1.0], + "downbeat_times": [0.0, 1.0], "drums": { "onset_strength": [0.0, 0.1, 0.0, 0.8, 0.2, 0.0, 0.5, 0.1, 0.0, 0.3, 0.0, 0.9, 0.1, 0.0, 0.2] },