diff --git a/cleave/analyse.py b/cleave/analyse.py index b61b63f..cc84d08 100644 --- a/cleave/analyse.py +++ b/cleave/analyse.py @@ -10,6 +10,7 @@ from cleave.extract import ( extract_bass, + extract_drums_beats, extract_drums_onset, extract_mix_onset, extract_mix_rms, @@ -37,16 +38,24 @@ def run_analyse(project_dir: Path, *, high_quality: bool) -> Path: ) drums_onset = extract_drums_onset(paths["drums"]) + drums_beats = extract_drums_beats(paths["drums"]) 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") + beat_times = [] + else: + beat_times = [float(t) for t in drums_beats] + output: dict = { "version": 2, "sample_rate_hz": int(TARGET_HZ), "duration_sec": duration_sec, + "beat_times": beat_times, "drums": { "onset_strength": resample_to_100hz( *drums_onset, duration_sec diff --git a/cleave/extract.py b/cleave/extract.py index 2a21f28..14cad00 100644 --- a/cleave/extract.py +++ b/cleave/extract.py @@ -100,6 +100,13 @@ 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.""" + y, sr = _load(path) + _tempo, frames = librosa.beat.beat_track(y=y, sr=sr, hop_length=HOP_LENGTH) + return librosa.frames_to_time(frames, sr=sr, hop_length=HOP_LENGTH) + + def extract_mix_onset(path: Path | str) -> tuple[np.ndarray, np.ndarray]: """Onset strength envelope from the mixed source track.""" return extract_drums_onset(path) diff --git a/cleave/signals.py b/cleave/signals.py index 914828a..10287c3 100644 --- a/cleave/signals.py +++ b/cleave/signals.py @@ -8,7 +8,7 @@ import numpy as np -_META_KEYS = frozenset({"version", "sample_rate_hz", "duration_sec"}) +_META_KEYS = frozenset({"version", "sample_rate_hz", "duration_sec", "beat_times"}) SIGNALS_VERSION = 2 _EXPECTED_STEMS = frozenset({"drums", "bass", "vocals", "other", "full_mix"}) _FULL_MIX_KEYS = frozenset({"onset_strength", "rms"}) @@ -32,6 +32,7 @@ class Signals: duration_sec: float path: Path stems: dict[str, dict[str, np.ndarray]] = field(repr=False) + beat_times: tuple[float, ...] = () _normalized_cache: dict[tuple[str, str, float], np.ndarray] = field( default_factory=dict, init=False, repr=False ) @@ -120,9 +121,13 @@ def load_signals(path: Path) -> Signals: _validate_signals_data(data, stems) + raw_beats = data.get("beat_times", []) + beat_times = tuple(float(t) for t in raw_beats) + return Signals( sample_rate_hz=float(data["sample_rate_hz"]), duration_sec=float(data["duration_sec"]), path=signals_path, stems=stems, + beat_times=beat_times, ) diff --git a/cleave/timeline.py b/cleave/timeline.py index c39297f..fe1fbcf 100644 --- a/cleave/timeline.py +++ b/cleave/timeline.py @@ -2,8 +2,11 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass +import numpy as np + from cleave.extract import STEM_SOURCES, StemSource RECORD_DEBOUNCE_SEC = 0.08 @@ -121,3 +124,55 @@ def should_accept_toggle(last_toggle_t: float | None, t_sec: float) -> bool: if last_toggle_t is None: return True return t_sec - last_toggle_t >= RECORD_DEBOUNCE_SEC + + +def _nearest_with_earlier_tie(t: float, candidates: Sequence[float]) -> float: + return min(candidates, key=lambda c: (abs(c - t), c)) + + +def snap_cues_to_beats( + cues: Sequence[TimelineCue], + beat_times: Sequence[float], +) -> list[TimelineCue]: + """Rewrite cue times to the nearest beat; merge collisions at the same ``t``.""" + if not cues or not beat_times: + return list(cues) + + beats = np.asarray(beat_times, dtype=np.float64) + if beats.size == 1: + sole = float(beats[0]) + snapped = [ + TimelineCue(t=sole, layers=dict(cue.layers), show_tick=cue.show_tick) + for cue in cues + ] + return _merge_cues_at_same_t(snapped) + + first = float(beats[0]) + last = float(beats[-1]) + interval = float(np.median(np.diff(beats))) + + def snap_t(t: float) -> float: + if first <= t <= last: + idx = int(np.searchsorted(beats, t)) + candidates: list[float] = [] + if idx > 0: + candidates.append(float(beats[idx - 1])) + if idx < len(beats): + candidates.append(float(beats[idx])) + return _nearest_with_earlier_tie(t, candidates) + raw = (t - first) / interval + lo = int(np.floor(raw)) + return _nearest_with_earlier_tie( + t, + (first + lo * interval, first + (lo + 1) * interval), + ) + + snapped = [ + TimelineCue( + t=snap_t(cue.t), + layers=dict(cue.layers), + show_tick=cue.show_tick, + ) + for cue in cues + ] + return _merge_cues_at_same_t(snapped) diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index c8d541d..6817f5a 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -3,7 +3,7 @@ from __future__ import annotations import shutil -from collections.abc import Callable +from collections.abc import Callable, Sequence from pathlib import Path from typing import TYPE_CHECKING @@ -26,6 +26,7 @@ from cleave.viz.render_post_fx_controls import RenderPostFxControls from cleave.viz.settings_controls import SettingsControls from cleave.viz.timeline_preset_controls import TimelinePresetController +from cleave.viz.timeline_snap_controls import TimelineSnapController from cleave.viz.user_presets import resolve_user_preset_dest, user_preset_item_display_name from cleave.viz.focus_nav import ( FocusCursor, @@ -81,6 +82,7 @@ def __init__( repo_root_example: Path | None = None, modal_host: ModalHost | None = None, layer_manager: LayerManager | None = None, + beat_times: Sequence[float] = (), ) -> None: self.session = session self.cfg = cfg @@ -124,6 +126,12 @@ def __init__( self._modal_host, on_notification=self.show_notification, ) + self._timeline_snap = TimelineSnapController( + session, + self._modal_host, + beat_times, + on_notification=self.show_notification, + ) self._view_state = TuningViewStateBuilder( session, playback, @@ -377,6 +385,9 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: if kind == RowKind.TIMELINE_PRESETS: self._timeline_presets.prompt(self.duration_sec) return True + if kind == RowKind.TIMELINE_SNAP_TO_BEATS: + self._timeline_snap.prompt() + return True if kind == RowKind.TRACK_PRESET_DIR: slot = self.focus_descriptor.slot if slot is not None: diff --git a/cleave/viz/row_fields.py b/cleave/viz/row_fields.py index 3d8d896..75b050c 100644 --- a/cleave/viz/row_fields.py +++ b/cleave/viz/row_fields.py @@ -1263,6 +1263,10 @@ def _apply_transport( panel_label="apply a preset", present_style=RowPresentStyle.FULL_LINE, ), + RowKind.TIMELINE_SNAP_TO_BEATS: RowFieldDef( + panel_label="snap to beats", + present_style=RowPresentStyle.FULL_LINE, + ), RowKind.PANEL_NOTIFICATION: RowFieldDef( panel_label="", present_style=RowPresentStyle.FULL_LINE, diff --git a/cleave/viz/row_sections.py b/cleave/viz/row_sections.py index a554620..b5ecfbb 100644 --- a/cleave/viz/row_sections.py +++ b/cleave/viz/row_sections.py @@ -683,6 +683,7 @@ def _build_row_tree_indent_depth() -> dict[RowKind, int]: depths[RowKind.TRACK_USER_PRESET_ITEM] = 7 depths[RowKind.TRACK_USER_PRESET_ADD] = 3 depths[RowKind.TIMELINE_PRESETS] = 1 + depths[RowKind.TIMELINE_SNAP_TO_BEATS] = 1 return depths @@ -797,6 +798,7 @@ def append_render_section_rows( and state.render_timeline.expanded ): row_list.append(RowDescriptor(RowKind.TIMELINE_PRESETS)) + row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_BEATS)) def append_track_section_rows( diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index df83e60..03b08ee 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -75,6 +75,7 @@ class RowKind(Enum): RENDER_POST_FX_CHROMA_BOOST_AMOUNT = auto() RENDER_TIMELINE_HEADER = auto() TIMELINE_PRESETS = auto() + TIMELINE_SNAP_TO_BEATS = auto() SETTINGS_HEADER = auto() SETTINGS_PREVIEW_QUALITY = auto() SETTINGS_UI_HEADER = auto() @@ -691,6 +692,16 @@ class RowBehavior: ), help_mode_entries=TIMELINE_PRESET_HELP_ENTRIES, ), + RowKind.TIMELINE_SNAP_TO_BEATS: RowBehavior( + RowAffordance.ACTION, + navigable=True, + help_title="Snap to beats", + help_entries=(("Enter", "snap cues to beats"),), + help_description=( + "Snap all committed timeline cues to the nearest beat", + "(irreversible).", + ), + ), RowKind.SETTINGS_HEADER: RowBehavior( RowAffordance.EXPAND, is_header=True, diff --git a/cleave/viz/timeline_snap_controls.py b/cleave/viz/timeline_snap_controls.py new file mode 100644 index 0000000..7ef813f --- /dev/null +++ b/cleave/viz/timeline_snap_controls.py @@ -0,0 +1,51 @@ +"""Timeline beat-snap confirm modal and cue rewrite.""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence + +from cleave.timeline import snap_cues_to_beats +from cleave.viz.modal import ModalHost +from cleave.viz.session import TuningSession + + +class TimelineSnapController: + """Prompt for and apply beat snapping to committed timeline cues.""" + + def __init__( + self, + session: TuningSession, + modal_host: ModalHost, + beat_times: Sequence[float], + *, + on_notification: Callable[[str], None] | None = None, + ) -> None: + self.session = session + self._modal = modal_host + self._beat_times = tuple(beat_times) + self._on_notification = on_notification + + def prompt(self) -> None: + tl = self.session.timeline + if tl.recording: + return + if not tl.cues: + self._notify("No timeline cues to snap") + return + if not self._beat_times: + self._notify("No beats available; re-run separate") + return + self._modal.prompt_yes_no( + "Do you want to snap all timeline cues to nearest beat?", + on_confirm=self._snap, + cancel_label="CANCEL", + ) + + def _snap(self) -> None: + tl = self.session.timeline + tl.cues = snap_cues_to_beats(tl.cues, self._beat_times) + self._notify("Snapped timeline cues to beats") + + def _notify(self, message: str) -> None: + if self._on_notification is not None: + self._on_notification(message) diff --git a/cleave/viz/tuning_panel_draw.py b/cleave/viz/tuning_panel_draw.py index 506dacc..c9aa4e5 100644 --- a/cleave/viz/tuning_panel_draw.py +++ b/cleave/viz/tuning_panel_draw.py @@ -538,6 +538,7 @@ def _row_value_color(state: TuningViewState, index: int) -> tuple[int, int, int] RowKind.LAYER_MANAGEMENT_DELETE, RowKind.TRACK_USER_PRESET_ADD, RowKind.TIMELINE_PRESETS, + RowKind.TIMELINE_SNAP_TO_BEATS, }: if kind == RowKind.CONFIG_HEADER and state.solo_active: return DISABLED @@ -904,6 +905,7 @@ def _estimate_row_content_width( RowKind.LAYER_MANAGEMENT_DELETE, RowKind.TRACK_USER_PRESET_ADD, RowKind.TIMELINE_PRESETS, + RowKind.TIMELINE_SNAP_TO_BEATS, } ): label = _row_text(state, index) @@ -1389,6 +1391,7 @@ def _build_row_at_index( RowKind.LAYER_MANAGEMENT_DELETE, RowKind.TRACK_USER_PRESET_ADD, RowKind.TIMELINE_PRESETS, + RowKind.TIMELINE_SNAP_TO_BEATS, } ): label = _row_text(state, index) diff --git a/cleave/viz/wiring.py b/cleave/viz/wiring.py index f1ad481..9f815c1 100644 --- a/cleave/viz/wiring.py +++ b/cleave/viz/wiring.py @@ -430,6 +430,7 @@ def on_chroma_boost_apply_mode_change(old_mode: str, new_mode: str) -> None: "layer_bindings": layer_bindings, "render_post_fx_bindings": render_post_fx_bindings, "layer_manager": layer_manager, + "beat_times": list(signals.beat_times) if signals is not None else [], } if modal_host is not None: kwargs["modal_host"] = modal_host diff --git a/docs/roadmap.md b/docs/roadmap.md index 4e11564..5944d1f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -5,9 +5,12 @@ Aspirational ideas. Not scheduled; revisit when the core workflow feels solid. ## Timeline v2 - Fade in/out on layer transitions -- Beat snap for cue placement - External timeline file for very long cue lists +### Beat detection (cue snap) v2 + +Investigate [madmom](https://github.com/CPJKU/madmom): trained beat and downbeat models for stronger bar alignment and tempo-map quality than librosa alone. Same persisted grid feeds timeline snap and MIDI out; heavier analyse step and new dependency. (v1 librosa snap is in [todos.md](todos.md).) + ## MIDI out Emit MIDI notes or CC from drum onsets (and other signals in `signals.json`) to drive hardware lighting, drum pads, or synths during playback or export. diff --git a/docs/todos.md b/docs/todos.md index 5b53fad..688c4de 100644 --- a/docs/todos.md +++ b/docs/todos.md @@ -15,6 +15,18 @@ Outstanding bugs and issues. --- +## Features + +### Timeline beat snap (v1) + +Done. Batch-quantize committed timeline cues to a librosa beat grid from the drums stem (`beat_times` in `signals.json`). Green **snap to beats** ACTION row under Render: TIMELINE. + +**v1.1** + +- Optional bar snap: every Nth beat (default N=4, assume 4/4 phase). + +--- + ## Architecture ### projectM diff --git a/tests/cleave/test_analyse.py b/tests/cleave/test_analyse.py index 03da33f..81e0ca1 100644 --- a/tests/cleave/test_analyse.py +++ b/tests/cleave/test_analyse.py @@ -7,6 +7,7 @@ from unittest.mock import patch import numpy as np +import pytest from cleave.analyse import run_analyse from cleave.extract import STEM_NAMES, stems_dir @@ -51,11 +52,13 @@ 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_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( _duration: object, _drums: object, + _drums_beats: object, _bass: object, _vocals: object, _other: object, @@ -74,6 +77,37 @@ def test_run_analyse_writes_version_2_full_mix( 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] + + +@patch("cleave.analyse.extract_mix_rms", return_value=_stub_signal()) +@patch("cleave.analyse.extract_mix_onset", return_value=_stub_signal()) +@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_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, + _bass: object, + _vocals: object, + _other: object, + _mix_onset: object, + _mix_rms: object, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + project = tmp_path / "project" + _write_project(project) + + signals_path = run_analyse(project, high_quality=False) + 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 @patch( diff --git a/tests/cleave/test_signals.py b/tests/cleave/test_signals.py index 7a3f7ff..868a96d 100644 --- a/tests/cleave/test_signals.py +++ b/tests/cleave/test_signals.py @@ -38,6 +38,7 @@ def test_load_signals_minimal_fixture( assert signals.path == minimal_signals_json_path.resolve() 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) pitch = signals.array("vocals", "pitch_hz") assert math.isnan(pitch[2]) @@ -49,6 +50,27 @@ def test_load_signals_minimal_fixture( assert full_mix_rms[0] == pytest.approx(0.10) +def test_load_signals_missing_beat_times_defaults_empty(tmp_path: Path) -> None: + path = tmp_path / "signals.json" + path.write_text( + json.dumps( + { + "version": 2, + "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]}, + "other": {"spectral_centroid": [1000.0]}, + "full_mix": {"onset_strength": [0.0], "rms": [0.0]}, + } + ), + encoding="utf-8", + ) + signals = load_signals(path) + assert signals.beat_times == () + + def test_load_signals_rejects_version_1(tmp_path: Path) -> None: path = tmp_path / "signals.json" path.write_text( diff --git a/tests/cleave/test_timeline_snap.py b/tests/cleave/test_timeline_snap.py new file mode 100644 index 0000000..39fdec7 --- /dev/null +++ b/tests/cleave/test_timeline_snap.py @@ -0,0 +1,84 @@ +"""Tests for timeline beat snapping.""" + +from __future__ import annotations + +from cleave.timeline import TimelineCue, snap_cues_to_beats + + +def test_snap_empty_cues_noop() -> None: + beats = (0.0, 1.0, 2.0) + assert snap_cues_to_beats([], beats) == [] + + +def test_snap_empty_beats_noop() -> None: + cues = [TimelineCue(t=0.4, layers={"layer_1": True})] + assert snap_cues_to_beats(cues, []) == cues + + +def test_snap_nearest_beat() -> None: + cues = [ + TimelineCue(t=0.1, layers={"layer_1": True}), + TimelineCue(t=0.9, layers={"layer_2": False}), + ] + result = snap_cues_to_beats(cues, (0.0, 1.0, 2.0)) + assert result == [ + TimelineCue(t=0.0, layers={"layer_1": True}), + TimelineCue(t=1.0, layers={"layer_2": False}), + ] + + +def test_snap_midpoint_tie_prefers_earlier() -> None: + cues = [TimelineCue(t=0.5, layers={"layer_1": True})] + result = snap_cues_to_beats(cues, (0.0, 1.0)) + assert result == [TimelineCue(t=0.0, layers={"layer_1": True})] + + +def test_snap_extrapolates_outside_range_via_median_interval() -> None: + # beats at 1.0, 2.0, 3.0 -> median interval 1.0; grid continues before/after + cues = [ + TimelineCue(t=-0.4, layers={"layer_1": True}), + TimelineCue(t=4.4, layers={"layer_2": False}), + ] + result = snap_cues_to_beats(cues, (1.0, 2.0, 3.0)) + assert result == [ + TimelineCue(t=0.0, layers={"layer_1": True}), + TimelineCue(t=4.0, layers={"layer_2": False}), + ] + + +def test_snap_extrapolation_midpoint_prefers_earlier() -> None: + cues = [TimelineCue(t=3.5, layers={"layer_1": True})] + result = snap_cues_to_beats(cues, (1.0, 2.0, 3.0)) + assert result == [TimelineCue(t=3.0, layers={"layer_1": True})] + + +def test_snap_merges_collisions() -> None: + cues = [ + TimelineCue(t=0.1, layers={"layer_1": True}), + TimelineCue(t=0.2, layers={"layer_2": False}), + TimelineCue(t=0.9, layers={"layer_1": False}), + ] + result = snap_cues_to_beats(cues, (0.0, 1.0)) + assert result == [ + TimelineCue(t=0.0, layers={"layer_1": True, "layer_2": False}), + TimelineCue(t=1.0, layers={"layer_1": False}), + ] + + +def test_snap_single_beat_snaps_everything() -> None: + cues = [ + TimelineCue(t=0.0, layers={"layer_1": True}), + TimelineCue(t=10.0, layers={"layer_2": False}), + ] + result = snap_cues_to_beats(cues, (2.5,)) + assert result == [ + TimelineCue(t=2.5, layers={"layer_1": True, "layer_2": False}), + ] + + +def test_snap_preserves_show_tick() -> None: + cues = [TimelineCue(t=0.1, layers={"layer_1": True}, show_tick=False)] + result = snap_cues_to_beats(cues, (0.0, 1.0)) + assert result == [ + TimelineCue(t=0.0, layers={"layer_1": True}, show_tick=False), + ] diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 2672da1..7eccb5b 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -111,6 +111,7 @@ def _make_controls( timeline_enabled: bool = False, launch_config_path: Path | None = _DEFAULT_ACTIVE_CONFIG, repo_root_example: Path = _REPO_ROOT_EXAMPLE, + beat_times: tuple[float, ...] = (), ) -> TuningControls: preset_root = Path("/tmp/presets") cfg = make_test_cfg(slots, preset_root=preset_root, config_path=launch_config_path or _DEFAULT_ACTIVE_CONFIG) @@ -136,6 +137,7 @@ def _make_controls( duration_sec=120.0, launch_config_path=launch_config_path, repo_root_example=repo_root_example, + beat_times=beat_times, ) @@ -1452,6 +1454,13 @@ def _focus_timeline_presets(controls: TuningControls) -> None: controls.focus_descriptor = _desc(view, presets_row) +def _focus_timeline_snap(controls: TuningControls) -> None: + controls.session.timeline.panel_open = True + view = controls.build_view_state(paused=False) + snap_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BEATS) + controls.focus_descriptor = _desc(view, snap_row) + + def _choose_modal_option(controls: TuningControls, label: str) -> None: modal_view = controls.modal_host.view_state() assert modal_view is not None @@ -1532,6 +1541,73 @@ def test_timeline_presets_cancel_and_escape_leave_unchanged() -> None: assert controls.session.timeline.enabled is False +def test_timeline_snap_enter_opens_yes_cancel_modal() -> None: + controls = _make_controls( + ("layer_1",), + beat_times=(0.0, 1.0, 2.0), + ) + controls.session.timeline.cues = [ + TimelineCue(t=0.4, layers={"layer_1": True}), + ] + _focus_timeline_snap(controls) + assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True + modal_view = controls.modal_host.view_state() + assert modal_view is not None + assert modal_view.kind == ModalKind.YES_NO + assert modal_view.options == ("Yes", "CANCEL") + assert "snap all timeline cues to nearest beat" in modal_view.message.lower() + + +def test_timeline_snap_confirm_mutates_cues() -> None: + controls = _make_controls( + ("layer_1", "layer_2"), + beat_times=(0.0, 1.0, 2.0), + ) + controls.session.timeline.cues = [ + TimelineCue(t=0.4, layers={"layer_1": True}), + TimelineCue(t=1.6, layers={"layer_2": False}), + ] + _focus_timeline_snap(controls) + controls.handle_keydown(_keydown(pygame.K_RETURN)) + _choose_modal_option(controls, "Yes") + assert not controls.modal_host.active + assert controls.session.timeline.cues == [ + TimelineCue(t=0.0, layers={"layer_1": True}), + TimelineCue(t=2.0, layers={"layer_2": False}), + ] + view = controls.build_view_state(paused=False) + assert view.notification_message == "Snapped timeline cues to beats" + + +def test_timeline_snap_recording_blocks() -> None: + controls = _make_controls( + ("layer_1",), + beat_times=(0.0, 1.0), + ) + prior = [TimelineCue(t=0.4, layers={"layer_1": True})] + controls.session.timeline.cues = list(prior) + controls.session.timeline.recording = True + _focus_timeline_snap(controls) + assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True + assert not controls.modal_host.active + assert controls.session.timeline.cues == prior + view = controls.build_view_state(paused=False) + assert view.notification_message is None + + +def test_timeline_snap_no_cues_notifies() -> None: + controls = _make_controls( + ("layer_1",), + beat_times=(0.0, 1.0), + ) + controls.session.timeline.cues = [] + _focus_timeline_snap(controls) + assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True + assert not controls.modal_host.active + view = controls.build_view_state(paused=False) + assert view.notification_message == "No timeline cues to snap" + + def test_render_timeline_header_label_spacing() -> None: controls = _make_controls() view = controls.build_view_state(paused=False) @@ -1661,6 +1737,7 @@ def test_render_timeline_down_enters_submenu() -> None: view = controls.build_view_state(paused=False) header_row = view.layout.find_by_kind(RowKind.RENDER_TIMELINE_HEADER) presets_row = view.layout.find_by_kind(RowKind.TIMELINE_PRESETS) + snap_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BEATS) controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.focus_row = 2 @@ -1668,6 +1745,10 @@ def test_render_timeline_down_enters_submenu() -> None: assert controls.focus_descriptor == _desc(view, presets_row) assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) + assert controls.focus_descriptor == _desc(view, snap_row) + assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 0 @@ -1682,6 +1763,7 @@ def test_render_timeline_down_enters_submenu_and_routes_keys() -> None: controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.focus_row = 2 + controls.handle_keydown(_keydown(pygame.K_DOWN)) controls.handle_keydown(_keydown(pygame.K_DOWN)) controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) @@ -1811,7 +1893,7 @@ def test_render_timeline_submenu_up_returns_to_header() -> None: assert not isinstance(controls.focus_cursor, TimelineFocus) view = controls.build_view_state(paused=False) - assert controls.focus_descriptor == RowDescriptor(RowKind.TIMELINE_PRESETS) + assert controls.focus_descriptor == RowDescriptor(RowKind.TIMELINE_SNAP_TO_BEATS) def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: @@ -1820,8 +1902,8 @@ def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: controls = _make_controls(timeline_enabled=True) controls.session.timeline.panel_open = True view = controls.build_view_state(paused=False) - presets_row = view.layout.find_by_kind(RowKind.TIMELINE_PRESETS) - controls.focus_descriptor = _desc(view, presets_row) + snap_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BEATS) + controls.focus_descriptor = _desc(view, snap_row) controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) diff --git a/tests/cleave/viz/test_row_fields.py b/tests/cleave/viz/test_row_fields.py index 60c7d98..e564dd7 100644 --- a/tests/cleave/viz/test_row_fields.py +++ b/tests/cleave/viz/test_row_fields.py @@ -276,7 +276,7 @@ def test_apply_field_horizontal_track_header_solo_and_expand() -> None: def test_row_fields_count() -> None: - assert len(ROW_FIELDS) == 64 + assert len(ROW_FIELDS) == 65 def test_row_kinds_requiring_fields_registry_complete() -> None: diff --git a/tests/cleave/viz/test_view_state_structure.py b/tests/cleave/viz/test_view_state_structure.py index c8d30f2..5218816 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -144,17 +144,24 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: view_closed = builder.build(paused=False) presets = RowDescriptor(RowKind.TIMELINE_PRESETS) + snap = RowDescriptor(RowKind.TIMELINE_SNAP_TO_BEATS) assert presets not in view_closed.layout.rows + assert snap not in view_closed.layout.rows session.timeline.panel_open = True view_open = builder.build(paused=False) assert view_open.layout is not view_closed.layout assert presets in view_open.layout.rows + assert snap in view_open.layout.rows + presets_idx = view_open.layout.rows.index(presets) + snap_idx = view_open.layout.rows.index(snap) + assert snap_idx == presets_idx + 1 session.timeline.panel_open = False view_closed_again = builder.build(paused=False) assert view_closed_again.layout is not view_open.layout assert presets not in view_closed_again.layout.rows + assert snap not in view_closed_again.layout.rows def test_structure_signature_invalidates_on_highlight_rolloff_mode() -> None: diff --git a/tests/fixtures/minimal_signals.json b/tests/fixtures/minimal_signals.json index 780b2db..4a54891 100644 --- a/tests/fixtures/minimal_signals.json +++ b/tests/fixtures/minimal_signals.json @@ -2,6 +2,7 @@ "version": 2, "sample_rate_hz": 100, "duration_sec": 0.14, + "beat_times": [0.0, 0.5, 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] },