diff --git a/cleave/timeline.py b/cleave/timeline.py index f3c7df4..2c6d6df 100644 --- a/cleave/timeline.py +++ b/cleave/timeline.py @@ -201,6 +201,105 @@ 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( + 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, +) -> 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]) + + +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: + return () + return bar_times_at_phase(beat_times, k, beats_per_bar=beats_per_bar) + + +def grid_period(times: Sequence[float]) -> float | None: + """Median spacing between successive times, or None if fewer than two.""" + if len(times) < 2: + return None + return float(np.median(np.diff(np.asarray(times, dtype=np.float64)))) + + +def bar_period_sec( + bar_times: Sequence[float], + beat_times: Sequence[float] = (), + *, + beats_per_bar: int = 4, +) -> float | None: + """Bar period from bar-grid median spacing, else ``beats_per_bar`` beat medians.""" + period = grid_period(bar_times) + if period is not None: + return period + beat_period = grid_period(beat_times) + if beat_period is not None: + return float(beats_per_bar) * beat_period + return None + + +def shift_lane_times( + lane: TimelineLane, + delta_sec: float, + *, + t_min: float = 0.0, + t_max: float, +) -> TimelineLane: + """Shift cue times by ``delta_sec``, clamp to ``[t_min, t_max]``, canonicalize.""" + if not lane.cues: + return TimelineLane(baseline=lane.baseline, cues=[]) + shifted = [ + SlotCue( + t=max(t_min, min(t_max, cue.t + delta_sec)), + visible=cue.visible, + ) + for cue in lane.cues + ] + return TimelineLane( + baseline=lane.baseline, + cues=canonicalize(lane.baseline, shifted), + ) + + def snap_lane_to_beats( lane: TimelineLane, beat_times: Sequence[float], diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index 6817f5a..155eec3 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -83,6 +83,7 @@ def __init__( modal_host: ModalHost | None = None, layer_manager: LayerManager | None = None, beat_times: Sequence[float] = (), + bar_times: Sequence[float] = (), ) -> None: self.session = session self.cfg = cfg @@ -130,6 +131,7 @@ def __init__( session, self._modal_host, beat_times, + bar_times, on_notification=self.show_notification, ) self._view_state = TuningViewStateBuilder( @@ -388,6 +390,9 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: if kind == RowKind.TIMELINE_SNAP_TO_BEATS: self._timeline_snap.prompt() return True + if kind == RowKind.TIMELINE_SNAP_TO_BARS: + self._timeline_snap.prompt_bars(self.duration_sec) + return True if kind == RowKind.TRACK_PRESET_DIR: slot = self.focus_descriptor.slot if slot is not None: diff --git a/cleave/viz/modal.py b/cleave/viz/modal.py index ae82ec9..556f3b2 100644 --- a/cleave/viz/modal.py +++ b/cleave/viz/modal.py @@ -23,6 +23,13 @@ def modal_options_vertical(option_count: int) -> bool: return option_count > MODAL_VERTICAL_OPTION_THRESHOLD +def clamp_modal_focus_index(index: int, option_count: int) -> int: + """Return ``index`` when in range, otherwise ``0`` (first option).""" + if option_count <= 0 or index < 0 or index >= option_count: + return 0 + return index + + @dataclass class ModalOption: label: str @@ -35,6 +42,7 @@ class ModalRequest: message: str | None options: list[ModalOption] on_dismiss: Callable[[], None] | None = None + initial_focus_index: int = 0 @dataclass(frozen=True) @@ -76,7 +84,10 @@ def view_state(self) -> ModalViewState | None: def prompt(self, request: ModalRequest) -> None: self._request = request - self._focus_index = 0 + self._focus_index = clamp_modal_focus_index( + request.initial_focus_index, + len(request.options), + ) def prompt_yes_no( self, @@ -107,6 +118,8 @@ def prompt_choice( message: str, options: list[ModalOption], on_dismiss: Callable[[], None] | None = None, + *, + initial_focus_index: int = 0, ) -> None: self.prompt( ModalRequest( @@ -114,6 +127,7 @@ def prompt_choice( message=message, options=options, on_dismiss=on_dismiss, + initial_focus_index=initial_focus_index, ) ) diff --git a/cleave/viz/row_fields.py b/cleave/viz/row_fields.py index 75b050c..b72a79b 100644 --- a/cleave/viz/row_fields.py +++ b/cleave/viz/row_fields.py @@ -1267,6 +1267,10 @@ def _apply_transport( panel_label="snap to beats", present_style=RowPresentStyle.FULL_LINE, ), + RowKind.TIMELINE_SNAP_TO_BARS: RowFieldDef( + panel_label="snap to bars", + 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 b5ecfbb..e172ad3 100644 --- a/cleave/viz/row_sections.py +++ b/cleave/viz/row_sections.py @@ -684,6 +684,7 @@ def _build_row_tree_indent_depth() -> dict[RowKind, int]: depths[RowKind.TRACK_USER_PRESET_ADD] = 3 depths[RowKind.TIMELINE_PRESETS] = 1 depths[RowKind.TIMELINE_SNAP_TO_BEATS] = 1 + depths[RowKind.TIMELINE_SNAP_TO_BARS] = 1 return depths @@ -799,6 +800,7 @@ def append_render_section_rows( ): row_list.append(RowDescriptor(RowKind.TIMELINE_PRESETS)) row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_BEATS)) + row_list.append(RowDescriptor(RowKind.TIMELINE_SNAP_TO_BARS)) def append_track_section_rows( diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index 03b08ee..018598c 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -76,6 +76,7 @@ class RowKind(Enum): RENDER_TIMELINE_HEADER = auto() TIMELINE_PRESETS = auto() TIMELINE_SNAP_TO_BEATS = auto() + TIMELINE_SNAP_TO_BARS = auto() SETTINGS_HEADER = auto() SETTINGS_PREVIEW_QUALITY = auto() SETTINGS_UI_HEADER = auto() @@ -702,6 +703,16 @@ class RowBehavior: "(irreversible).", ), ), + RowKind.TIMELINE_SNAP_TO_BARS: RowBehavior( + RowAffordance.ACTION, + navigable=True, + help_title="Snap to bars", + help_entries=(("Enter", "choose bar phase offset"),), + help_description=( + "Snap all committed timeline cues to the nearest bar;", + "Enter opens a phase-offset choice (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 index f1f8a27..abb7237 100644 --- a/cleave/viz/timeline_snap_controls.py +++ b/cleave/viz/timeline_snap_controls.py @@ -1,31 +1,51 @@ -"""Timeline beat-snap confirm modal and cue rewrite.""" +"""Timeline beat/bar-snap confirm modal and cue rewrite.""" from __future__ import annotations from collections.abc import Callable, Sequence -from cleave.timeline import empty_lane, snap_lane_to_beats -from cleave.viz.modal import ModalHost +from cleave.timeline import ( + bar_period_sec, + empty_lane, + shift_lane_times, + 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_OFFSETS = (-3, -2, -1, 0, 1, 2, 3) +_BAR_SNAP_MESSAGE = "Snap timeline cues to nearest bar (choose bar offset)" + class TimelineSnapController: - """Prompt for and apply beat snapping to committed timeline cues.""" + """Prompt for and apply beat/bar snapping to committed timeline cues.""" def __init__( self, session: TuningSession, modal_host: ModalHost, beat_times: Sequence[float], + bar_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._bar_times = tuple(bar_times) self._on_notification = on_notification def prompt(self) -> None: + self._prompt( + self._beat_times, + empty_grid_msg="No beats available; re-run separate", + confirm_msg="Do you want to snap all timeline cues to nearest beat?", + done_msg="Snapped timeline cues to beats", + ) + + def prompt_bars(self, duration_sec: float) -> None: tl = self.session.timeline if tl.recording: return @@ -35,20 +55,83 @@ def prompt(self) -> None: if not self._beat_times: self._notify("No beats available; re-run separate") return + if not self._bar_times: + self._notify("No bars available; re-run separate") + return + dismiss = lambda: None + options: list[ModalOption] = [ + ModalOption( + f"{offset:+d}", + lambda o=offset: self._snap_bars_at_offset(o, duration_sec), + ) + for offset in _BAR_OFFSETS + ] + options.append(ModalOption(_CANCEL_LABEL, dismiss)) + self._modal.prompt_choice( + _BAR_SNAP_MESSAGE, + options, + on_dismiss=dismiss, + initial_focus_index=_BAR_OFFSETS.index(0), + ) + + def _snap_bars_at_offset(self, offset: int, duration_sec: float) -> None: + grid = self._bar_times + label = f"{offset:+d}" + notify_msg = f"Snapped timeline cues to bars ({label})" + period = ( + bar_period_sec( + grid, self._beat_times, beats_per_bar=_BEATS_PER_BAR + ) + if offset != 0 + else None + ) + tl = self.session.timeline + for slot in list(tl.lanes): + lane = snap_lane_to_beats( + tl.lanes.get(slot) or empty_lane(), + grid, + ) + if offset != 0 and period is not None: + lane = shift_lane_times( + lane, + offset * period, + t_min=0.0, + t_max=duration_sec, + ) + tl.lanes[slot] = lane + self._notify(notify_msg) + + def _prompt( + self, + grid: Sequence[float], + *, + empty_grid_msg: str, + confirm_msg: str, + done_msg: str, + ) -> None: + tl = self.session.timeline + if tl.recording: + return + if not any(lane.cues for lane in tl.lanes.values()): + self._notify("No timeline cues to snap") + return + if not grid: + self._notify(empty_grid_msg) + return self._modal.prompt_yes_no( - "Do you want to snap all timeline cues to nearest beat?", - on_confirm=self._snap, + confirm_msg, + on_confirm=lambda: self._snap_to(grid, done_msg), cancel_label="CANCEL", ) - def _snap(self) -> None: + def _snap_to(self, grid: Sequence[float], notify_msg: str) -> None: tl = self.session.timeline for slot in list(tl.lanes): tl.lanes[slot] = snap_lane_to_beats( tl.lanes.get(slot) or empty_lane(), - self._beat_times, + grid, ) - self._notify("Snapped timeline cues to beats") + self._notify(notify_msg) def _notify(self, message: str) -> None: if self._on_notification is not None: diff --git a/cleave/viz/tuning_panel_draw.py b/cleave/viz/tuning_panel_draw.py index c9aa4e5..198d374 100644 --- a/cleave/viz/tuning_panel_draw.py +++ b/cleave/viz/tuning_panel_draw.py @@ -539,6 +539,7 @@ def _row_value_color(state: TuningViewState, index: int) -> tuple[int, int, int] RowKind.TRACK_USER_PRESET_ADD, RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_SNAP_TO_BEATS, + RowKind.TIMELINE_SNAP_TO_BARS, }: if kind == RowKind.CONFIG_HEADER and state.solo_active: return DISABLED @@ -906,6 +907,7 @@ def _estimate_row_content_width( RowKind.TRACK_USER_PRESET_ADD, RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_SNAP_TO_BEATS, + RowKind.TIMELINE_SNAP_TO_BARS, } ): label = _row_text(state, index) @@ -1392,6 +1394,7 @@ def _build_row_at_index( RowKind.TRACK_USER_PRESET_ADD, RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_SNAP_TO_BEATS, + RowKind.TIMELINE_SNAP_TO_BARS, } ): label = _row_text(state, index) diff --git a/cleave/viz/wiring.py b/cleave/viz/wiring.py index 08bb083..e85da60 100644 --- a/cleave/viz/wiring.py +++ b/cleave/viz/wiring.py @@ -46,6 +46,7 @@ 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 @@ -418,6 +419,16 @@ def on_chroma_boost_apply_mode_change(old_mode: str, new_mode: str) -> None: on_seek=on_seek, ) + beat_times: list[float] = [] + 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)) + kwargs: dict = { "session": session, "cfg": cfg, @@ -428,7 +439,8 @@ 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 [], + "beat_times": beat_times, + "bar_times": bar_times, } if modal_host is not None: kwargs["modal_host"] = modal_host diff --git a/docs/todos.md b/docs/todos.md index e5cd796..5b53fad 100644 --- a/docs/todos.md +++ b/docs/todos.md @@ -15,24 +15,8 @@ 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 -### Timeline cues: per-track (or single-slot) model - -Done. Each track owns a `TimelineLane` (explicit `baseline` plus ordered `SlotCue` transitions) in [cleave/timeline.py](../cleave/timeline.py). Persisted as `timeline.lanes` in YAML. Edits (`punch_lane`, snap, presets, record buffer) are lane-local, so armed recording cannot rewrite unarmed tracks. See [roadmap.md](roadmap.md) Timeline v2 for fades and richer cue types on this model. - ### projectM - Tie projectM mesh size to `render_mode` (internal warp mesh resolution, separate from Cleave layer FBO downscaling in [cleave/viz/layer_preview_resolution.py](cleave/viz/layer_preview_resolution.py)). diff --git a/tests/cleave/test_timeline_snap.py b/tests/cleave/test_timeline_snap.py index 0e62d18..cf8c4b6 100644 --- a/tests/cleave/test_timeline_snap.py +++ b/tests/cleave/test_timeline_snap.py @@ -1,8 +1,20 @@ -"""Tests for per-lane timeline beat snapping.""" +"""Tests for per-lane timeline beat snapping and bar-phase derivation.""" from __future__ import annotations -from cleave.timeline import SlotCue, TimelineLane, snap_lane_to_beats +import pytest + +from cleave.timeline import ( + SlotCue, + TimelineLane, + bar_period_sec, + bar_phase_from_beats, + bar_times_at_phase, + bar_times_from_beats, + grid_period, + shift_lane_times, + snap_lane_to_beats, +) def _lane( @@ -74,3 +86,85 @@ def test_snap_preserves_baseline() -> None: result = snap_lane_to_beats(lane, (0.0, 1.0)) assert result.baseline is True 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: + 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) + + +def test_bar_times_at_phase_empty_beats() -> None: + assert bar_times_at_phase((), 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_grid_period_median_spacing() -> None: + assert grid_period((0.0, 4.0, 8.0)) == 4.0 + assert grid_period((0.0,)) is None + assert grid_period(()) is None + + +def test_bar_period_falls_back_to_beat_median() -> None: + assert bar_period_sec((0.0,), (0.0, 1.0, 2.0, 3.0)) == 4.0 + assert bar_period_sec((0.0, 4.0), (0.0, 1.0)) == 4.0 + assert bar_period_sec((0.0,), (0.0,)) is None + + +def test_shift_lane_times_clamps_and_canonicalizes() -> None: + lane = _lane(False, (1.0, True), (5.0, False)) + result = shift_lane_times(lane, -2.0, t_min=0.0, t_max=4.0) + assert result.cues == [ + SlotCue(t=0.0, visible=True), + SlotCue(t=3.0, visible=False), + ] + past_end = shift_lane_times(lane, 2.0, t_min=0.0, t_max=4.0) + assert past_end.cues == [ + SlotCue(t=3.0, visible=True), + SlotCue(t=4.0, visible=False), + ] + merged = shift_lane_times( + _lane(False, (0.5, True), (0.6, False)), + -1.0, + t_min=0.0, + t_max=10.0, + ) + # Both clamp to 0.0; last-wins -> False; matches baseline False -> drop + assert merged.cues == [] diff --git a/tests/cleave/viz/test_confirm.py b/tests/cleave/viz/test_confirm.py index c36d727..8d2521e 100644 --- a/tests/cleave/viz/test_confirm.py +++ b/tests/cleave/viz/test_confirm.py @@ -4,7 +4,7 @@ import pygame -from cleave.viz.modal import ModalHost +from cleave.viz.modal import ModalHost, ModalOption def _keydown(key: int) -> pygame.event.Event: @@ -80,3 +80,59 @@ def test_unsaved_quit_consumes_keys_while_active() -> None: modal.prompt_unsaved_quit(on_save=lambda: None, on_discard=lambda: None) assert modal.handle_keydown(_keydown(pygame.K_a)) is True assert modal.active + + +def test_prompt_choice_initial_focus_index() -> None: + modal = ModalHost() + modal.prompt_choice( + "Pick one", + [ + ModalOption("a", lambda: None), + ModalOption("b", lambda: None), + ModalOption("c", lambda: None), + ModalOption("d", lambda: None), + ], + initial_focus_index=2, + ) + view = modal.view_state() + assert view is not None + assert view.focus_index == 2 + + +def test_prompt_choice_initial_focus_index_out_of_range_defaults_to_zero() -> None: + modal = ModalHost() + modal.prompt_choice( + "Pick one", + [ + ModalOption("a", lambda: None), + ModalOption("b", lambda: None), + ], + initial_focus_index=5, + ) + view = modal.view_state() + assert view is not None + assert view.focus_index == 0 + + modal.prompt_choice( + "Pick one", + [ + ModalOption("a", lambda: None), + ModalOption("b", lambda: None), + ], + initial_focus_index=-1, + ) + assert modal.view_state().focus_index == 0 + + +def test_prompt_choice_omitted_initial_focus_stays_zero() -> None: + modal = ModalHost() + modal.prompt_choice( + "Pick one", + [ + ModalOption("a", lambda: None), + ModalOption("b", lambda: None), + ], + ) + view = modal.view_state() + assert view is not None + assert view.focus_index == 0 diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 8fa8c78..9de6c4d 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -112,6 +112,7 @@ def _make_controls( launch_config_path: Path | None = _DEFAULT_ACTIVE_CONFIG, repo_root_example: Path = _REPO_ROOT_EXAMPLE, beat_times: tuple[float, ...] = (), + bar_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) @@ -138,6 +139,7 @@ def _make_controls( launch_config_path=launch_config_path, repo_root_example=repo_root_example, beat_times=beat_times, + bar_times=bar_times, ) @@ -1461,6 +1463,13 @@ def _focus_timeline_snap(controls: TuningControls) -> None: controls.focus_descriptor = _desc(view, snap_row) +def _focus_timeline_snap_bars(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_BARS) + 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 @@ -1622,6 +1631,208 @@ def test_timeline_snap_no_cues_notifies() -> None: assert view.notification_message == "No timeline cues to snap" +def test_timeline_snap_bars_enter_opens_phase_choice_modal() -> None: + controls = _make_controls( + ("layer_1",), + beat_times=(0.0, 1.0, 2.0, 3.0), + bar_times=(0.0, 4.0), + ) + controls.session.timeline.lanes = { + "layer_1": _lane(None, (0.4, True)), + } + _focus_timeline_snap_bars(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.CHOICE + assert modal_view.options == ( + "-3", + "-2", + "-1", + "+0", + "+1", + "+2", + "+3", + "Cancel", + ) + assert modal_view.focus_index == modal_view.options.index("+0") + assert "choose bar offset" in modal_view.message.lower() + + +def test_timeline_snap_bars_confirm_mutates_cues() -> 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), + bar_times=(0.0, 4.0), + ) + controls.session.timeline.lanes = { + "layer_1": _lane(None, (0.4, True)), + "layer_2": _lane(None, (3.6, False)), + } + _focus_timeline_snap_bars(controls) + controls.handle_keydown(_keydown(pygame.K_RETURN)) + _choose_modal_option(controls, "+0") + assert not controls.modal_host.active + assert controls.session.timeline.lanes["layer_1"].cues == [ + SlotCue(t=0.0, visible=True), + ] + assert controls.session.timeline.lanes["layer_2"].cues == [ + SlotCue(t=4.0, visible=False), + ] + view = controls.build_view_state(paused=False) + assert view.notification_message == "Snapped timeline cues to bars (+0)" + + +def test_timeline_snap_bars_confirm_plus_one_nudges_by_bar_period() -> 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), + bar_times=(0.0, 4.0), + ) + controls.session.timeline.lanes = { + "layer_1": _lane(None, (0.4, True)), + "layer_2": _lane(None, (3.6, False)), + } + _focus_timeline_snap_bars(controls) + controls.handle_keydown(_keydown(pygame.K_RETURN)) + _choose_modal_option(controls, "+1") + assert not controls.modal_host.active + # Snap to bars 0/4, then +1 * bar_period(4.0) + assert controls.session.timeline.lanes["layer_1"].cues == [ + SlotCue(t=4.0, visible=True), + ] + assert controls.session.timeline.lanes["layer_2"].cues == [ + SlotCue(t=8.0, visible=False), + ] + view = controls.build_view_state(paused=False) + assert view.notification_message == "Snapped timeline cues to bars (+1)" + + +def test_timeline_snap_bars_confirm_minus_one_nudges_earlier() -> None: + controls = _make_controls( + ("layer_1", "layer_2"), + beat_times=tuple(float(i) for i in range(12)), + bar_times=(0.0, 4.0, 8.0), + ) + controls.session.timeline.lanes = { + "layer_1": _lane(None, (4.4, True)), + "layer_2": _lane(None, (8.4, False)), + } + _focus_timeline_snap_bars(controls) + controls.handle_keydown(_keydown(pygame.K_RETURN)) + _choose_modal_option(controls, "-1") + assert not controls.modal_host.active + assert controls.session.timeline.lanes["layer_1"].cues == [ + SlotCue(t=0.0, visible=True), + ] + assert controls.session.timeline.lanes["layer_2"].cues == [ + SlotCue(t=4.0, visible=False), + ] + view = controls.build_view_state(paused=False) + assert view.notification_message == "Snapped timeline cues to bars (-1)" + + +def test_timeline_snap_bars_clamp_to_track_bounds() -> None: + controls = _make_controls( + ("layer_1", "layer_2"), + beat_times=tuple(float(i) for i in range(12)), + bar_times=(0.0, 4.0, 8.0), + ) + controls.duration_sec = 6.0 + controls.session.timeline.lanes = { + "layer_1": _lane(None, (0.4, True)), + "layer_2": _lane(None, (4.4, False)), + } + _focus_timeline_snap_bars(controls) + controls.handle_keydown(_keydown(pygame.K_RETURN)) + _choose_modal_option(controls, "-1") + assert controls.session.timeline.lanes["layer_1"].cues == [ + SlotCue(t=0.0, visible=True), + ] + assert controls.session.timeline.lanes["layer_2"].cues == [ + SlotCue(t=0.0, visible=False), + ] + + controls = _make_controls( + ("layer_1",), + beat_times=tuple(float(i) for i in range(12)), + bar_times=(0.0, 4.0, 8.0), + ) + controls.duration_sec = 6.0 + controls.session.timeline.lanes = { + "layer_1": _lane(None, (4.4, True)), + } + _focus_timeline_snap_bars(controls) + controls.handle_keydown(_keydown(pygame.K_RETURN)) + _choose_modal_option(controls, "+1") + assert controls.session.timeline.lanes["layer_1"].cues == [ + SlotCue(t=6.0, visible=True), + ] + + +def test_timeline_snap_bars_recording_blocks() -> None: + controls = _make_controls( + ("layer_1",), + beat_times=(0.0, 1.0, 2.0, 3.0), + bar_times=(0.0,), + ) + prior = {"layer_1": _lane(None, (0.4, True))} + controls.session.timeline.lanes = dict(prior) + controls.session.timeline.recording = True + _focus_timeline_snap_bars(controls) + assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True + assert not controls.modal_host.active + assert controls.session.timeline.lanes == prior + view = controls.build_view_state(paused=False) + assert view.notification_message is None + + +def test_timeline_snap_bars_no_cues_notifies() -> None: + controls = _make_controls( + ("layer_1",), + beat_times=(0.0, 1.0, 2.0, 3.0), + bar_times=(0.0,), + ) + controls.session.timeline.lanes = {} + _focus_timeline_snap_bars(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_timeline_snap_bars_no_bars_notifies() -> None: + controls = _make_controls( + ("layer_1",), + beat_times=(0.0, 1.0), + bar_times=(), + ) + controls.session.timeline.lanes = { + "layer_1": _lane(None, (0.4, True)), + } + _focus_timeline_snap_bars(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 bars available; re-run separate" + + +def test_timeline_snap_bars_no_beats_notifies() -> None: + controls = _make_controls( + ("layer_1",), + beat_times=(), + bar_times=(), + ) + controls.session.timeline.lanes = { + "layer_1": _lane(None, (0.4, True)), + } + _focus_timeline_snap_bars(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 beats available; re-run separate" + + def test_render_timeline_header_label_spacing() -> None: controls = _make_controls() view = controls.build_view_state(paused=False) @@ -1751,7 +1962,8 @@ 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) + snap_beats_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BEATS) + snap_bars_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BARS) controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.focus_row = 2 @@ -1760,7 +1972,11 @@ def test_render_timeline_down_enters_submenu() -> None: assert not isinstance(controls.focus_cursor, TimelineFocus) controls.handle_keydown(_keydown(pygame.K_DOWN)) - assert controls.focus_descriptor == _desc(view, snap_row) + assert controls.focus_descriptor == _desc(view, snap_beats_row) + assert not isinstance(controls.focus_cursor, TimelineFocus) + + controls.handle_keydown(_keydown(pygame.K_DOWN)) + assert controls.focus_descriptor == _desc(view, snap_bars_row) assert not isinstance(controls.focus_cursor, TimelineFocus) controls.handle_keydown(_keydown(pygame.K_DOWN)) @@ -1780,6 +1996,7 @@ def test_render_timeline_down_enters_submenu_and_routes_keys() -> None: controls.handle_keydown(_keydown(pygame.K_DOWN)) 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) assert controls.session.timeline.focus_row == 0 @@ -1907,7 +2124,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_SNAP_TO_BEATS) + assert controls.focus_descriptor == RowDescriptor(RowKind.TIMELINE_SNAP_TO_BARS) def test_render_timeline_submenu_entry_stops_repeat_on_keyup() -> None: @@ -1916,7 +2133,7 @@ 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) - snap_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BEATS) + snap_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_BARS) controls.focus_descriptor = _desc(view, snap_row) controls.handle_keydown(_keydown(pygame.K_DOWN)) diff --git a/tests/cleave/viz/test_row_fields.py b/tests/cleave/viz/test_row_fields.py index e564dd7..74f6b60 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) == 65 + assert len(ROW_FIELDS) == 66 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 5218816..2e37e26 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -144,24 +144,30 @@ 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) + snap_beats = RowDescriptor(RowKind.TIMELINE_SNAP_TO_BEATS) + snap_bars = RowDescriptor(RowKind.TIMELINE_SNAP_TO_BARS) assert presets not in view_closed.layout.rows - assert snap not in view_closed.layout.rows + assert snap_beats not in view_closed.layout.rows + assert snap_bars 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 + assert snap_beats in view_open.layout.rows + assert snap_bars 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 + snap_beats_idx = view_open.layout.rows.index(snap_beats) + snap_bars_idx = view_open.layout.rows.index(snap_bars) + assert snap_beats_idx == presets_idx + 1 + assert snap_bars_idx == snap_beats_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 + assert snap_beats not in view_closed_again.layout.rows + assert snap_bars not in view_closed_again.layout.rows def test_structure_signature_invalidates_on_highlight_rolloff_mode() -> None: