From 2550f6c87ea1d7e63d979cf9e1061fa4fda33f48 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 26 Jul 2026 00:22:05 +0100 Subject: [PATCH 01/12] Complete idea 1 --- .cursor/rules/live-tuning-ui.mdc | 2 +- .cursor/rules/project-context.mdc | 2 +- cleave/config_schema.py | 19 +- cleave/timeline.py | 376 ++++++------- cleave/timeline_presets/arrange.py | 15 +- cleave/timeline_presets/crescendo.py | 89 +-- cleave/timeline_presets/emit.py | 39 +- cleave/viz/layer.py | 2 +- cleave/viz/layer_pipeline.py | 2 +- cleave/viz/layer_visibility.py | 107 ++-- cleave/viz/session.py | 2 +- cleave/viz/timeline_controls.py | 30 +- cleave/viz/timeline_overlay.py | 345 ++++++------ cleave/viz/timeline_panel_cache.py | 26 +- cleave/viz/timeline_preset_controls.py | 2 +- docs/improved-timeline-presets.md | 237 ++++++++ docs/todos.md | 10 + tests/cleave/test_config.py | 84 ++- tests/cleave/test_config_snapshot.py | 50 +- tests/cleave/test_timeline.py | 508 +++++++++--------- tests/cleave/test_timeline_presets.py | 53 +- tests/cleave/test_timeline_snap.py | 47 +- .../cleave/test_timeline_song_marker_snap.py | 63 ++- tests/cleave/viz/test_controls.py | 49 +- tests/cleave/viz/test_layer.py | 231 ++++---- tests/cleave/viz/test_layer_manager.py | 8 +- tests/cleave/viz/test_overlay_targets.py | 4 +- tests/cleave/viz/test_preset_switching.py | 8 +- tests/cleave/viz/test_render.py | 4 +- tests/cleave/viz/test_timeline_controls.py | 96 ++-- tests/cleave/viz/test_timeline_overlay.py | 143 +++-- tests/cleave/viz/test_timeline_panel_cache.py | 28 +- tests/cleave/viz/test_view_state_structure.py | 2 +- 33 files changed, 1580 insertions(+), 1103 deletions(-) create mode 100644 docs/improved-timeline-presets.md diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index 4e794d6..0e8ce91 100644 --- a/.cursor/rules/live-tuning-ui.mdc +++ b/.cursor/rules/live-tuning-ui.mdc @@ -56,7 +56,7 @@ Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always ## Timeline panel -Separate bottom overlay ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py), [cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)). Open with **t** (opens strip and enters submenu on row 0) or **Right** on **Render: TIMELINE** (strip only; **Down** enters submenu), including when `timeline.enabled` is false. **Left** on that header or **t** when the strip is open closes it and returns focus to **Render: TIMELINE**. Strip open does not hide the main overlay or route keys until `submenu_focused` (**t** from the main tree sets this immediately; **Down** from the header when the strip is already open). When the strip is open, **Up**/**Down** use one focus ring ([cleave/viz/focus_nav.py](cleave/viz/focus_nav.py)): main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, with uniform modulo wrap. **Down** from the last timeline row wraps to **Settings**; **Up** from **Settings** wraps to the last timeline row. **Up** at timeline row 0 returns focus to **Render: TIMELINE** without closing the strip. **Up**/**Down** always route through the main tuning controls; other timeline keys route to the timeline strip when `submenu_focused`. **Esc** or **t** while in the submenu closes the strip and returns focus to **Render: TIMELINE**. Rows follow `layer_z_order`; labels use stem abbreviations (D/B/V/O). Each track owns a lane (`TimelineLane`: explicit `baseline` plus ordered single-slot `SlotCue` transitions in [cleave/timeline.py](cleave/timeline.py)); edits (`punch_lane`, snap, presets) are lane-local. Each row shows a monitor eye beside the label, the cue bar, and a committed-timeline eye at the far right: **left** = monitor/output (`effective_layer_enabled`; gold `OVERRIDE_BG` when stem is in `TimelineRuntime.override_slots` or when recording and the row is armed). Armed rows during record use `record_baseline` + per-slot `record_buffer` toggles only, not committed lane cues. Record start baseline is not drawn on the bar; only transition cues in that slot's `record_buffer` show ticks. **right** = committed lane visibility at playhead only (`timeline_committed_visible`; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **a** arms a row (`ARMED_BG` red fill, distinct from override eye). **Space** pause snapshots current output into `monitor` and sets `preview_active` (resume clears both); while recording and playing, **Space** stops the take and pauses instead. Num keys **1**-**8** (main row and numpad) toggle layer visibility while paused (preview via `monitor` or override via `override_visible`; adds stem to override when needed), write record buffer when recording (armed only), toggle `override_visible` while playing for stems in `override_slots` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_slots` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip uses **Shift+Left** / **Shift+Right** for 2s transport seek (not solo). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed lanes in range (playback keeps running). **Ctrl+Space** starts record the same way; while recording it stops the take and pauses (no preview). `preview_active` / `monitor` / `override_slots` / `override_visible` are session-only on `TimelineRuntime`. Per-lane state persists under root `timeline.lanes` in YAML (written last in snapshots). When either timeline fade group is enabled, the strip draws fade-in/out wedge triangles on matching visible segment edges (base width matches that group's fade duration; song-marker edges use `timeline.fades.song_markers`, other cue edges use `timeline.fades.standard`). See [docs/timeline-idea.md](docs/timeline-idea.md). +Separate bottom overlay ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py), [cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)). Open with **t** (opens strip and enters submenu on row 0) or **Right** on **Render: TIMELINE** (strip only; **Down** enters submenu), including when `timeline.enabled` is false. **Left** on that header or **t** when the strip is open closes it and returns focus to **Render: TIMELINE**. Strip open does not hide the main overlay or route keys until `submenu_focused` (**t** from the main tree sets this immediately; **Down** from the header when the strip is already open). When the strip is open, **Up**/**Down** use one focus ring ([cleave/viz/focus_nav.py](cleave/viz/focus_nav.py)): main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, with uniform modulo wrap. **Down** from the last timeline row wraps to **Settings**; **Up** from **Settings** wraps to the last timeline row. **Up** at timeline row 0 returns focus to **Render: TIMELINE** without closing the strip. **Up**/**Down** always route through the main tuning controls; other timeline keys route to the timeline strip when `submenu_focused`. **Esc** or **t** while in the submenu closes the strip and returns focus to **Render: TIMELINE**. Rows follow `layer_z_order`; labels use stem abbreviations (D/B/V/O). Each track owns a lane (`TimelineLane`: explicit `baseline` plus ordered single-slot `SlotCue(t, level)` transitions in [cleave/timeline.py](cleave/timeline.py)); edits (`punch_lane`, snap, presets) are lane-local. Each row shows a monitor eye beside the label, the cue bar, and a committed-timeline eye at the far right: **left** = monitor/output (`effective_layer_enabled`; gold `OVERRIDE_BG` when stem is in `TimelineRuntime.override_slots` or when recording and the row is armed). Armed rows during record use `record_baseline` + per-slot `record_buffer` toggles only, not committed lane cues. Record start baseline is not drawn on the bar; only transition cues in that slot's `record_buffer` show ticks. **right** = committed lane level at playhead (`timeline_committed_level`; eye alpha follows the level; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **a** arms a row (`ARMED_BG` red fill, distinct from override eye). **Space** pause snapshots current output into `monitor` and sets `preview_active` (resume clears both); while recording and playing, **Space** stops the take and pauses instead. Num keys **1**-**8** (main row and numpad) toggle layer visibility while paused (preview via `monitor` or override via `override_visible`; adds stem to override when needed), write record buffer when recording (armed only), toggle `override_visible` while playing for stems in `override_slots` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_slots` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip uses **Shift+Left** / **Shift+Right** for 2s transport seek (not solo). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed lanes in range (playback keeps running). **Ctrl+Space** starts record the same way; while recording it stops the take and pauses (no preview). `preview_active` / `monitor` / `override_slots` / `override_visible` are session-only on `TimelineRuntime`. Per-lane state persists under root `timeline.lanes` in YAML (written last in snapshots). The strip draws each lane as a variable-height level bar from `lane_level_breakpoints` (bottom-filled polygons between breakpoints; remainder dark so a level-0 lane still reads as a full-height bed; colour lerps by mean segment level). Fade groups shape the breakpoint slopes; cue ticks and the bar grid are unchanged. ## Header rows section diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index cbaf798..69b4043 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -5,7 +5,7 @@ alwaysApply: true # Cleave project context -- BAU iterative development. Editor: `python -m cleave play` via [cleave/cli.py](cleave/cli.py) calling `cleave.viz.launch()`; [cleave.py](cleave.py) is an alias; implementation in [cleave/viz/](cleave/viz/) (up to eight Milkdrop/libprojectM layers, default four; add/remove in live tuning, 1280x720 default resolution, live at display frame rate, offline render fps via `render.fps`, stem PCM). Black-key stack in [cleave/gl_compositor.py](cleave/gl_compositor.py). GPU post-FX (bloom, grit, highlight rolloff, chroma boost): [cleave/gl_post_process.py](cleave/gl_post_process.py). Live tuning: session in [cleave/viz/session.py](cleave/viz/session.py), input in [cleave/viz/controls.py](cleave/viz/controls.py), view state in [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py), panel draw in [cleave/viz/tuning_panel_draw.py](cleave/viz/tuning_panel_draw.py); layout in [cleave/viz/row_layout.py](cleave/viz/row_layout.py); section tree in [cleave/viz/row_sections.py](cleave/viz/row_sections.py); panel field manifest in [cleave/viz/row_fields.py](cleave/viz/row_fields.py); shared live/offline frame finish in [cleave/viz/frame_finish.py](cleave/viz/frame_finish.py). projectM user time is a monotonic render clock ([cleave/viz/projectm_frame_clock.py](cleave/viz/projectm_frame_clock.py)), not song playhead. Timeline: per-track lanes (`TimelineLane` / `SlotCue` in [cleave/timeline.py](cleave/timeline.py); `timeline.lanes` in YAML; strip in [cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)). Render credits overlay in [cleave/viz/render_overlay.py](cleave/viz/render_overlay.py) (`render.overlay` in YAML): live preview in play, burned in by [cleave/viz/render.py](cleave/viz/render.py) offline. Cleave effects: [cleave/effects/](cleave/effects/) (dispatch via [cleave/effects/handlers.py](cleave/effects/handlers.py)). Paths: [cleave/paths.py](cleave/paths.py) (repo-root data dir by default, `projects//`; `CLEAVE_DATA` override). Config: parse and defaults in [cleave/config_schema.py](cleave/config_schema.py); repo-root [cleave-viz.yaml](cleave-viz.yaml) copied into projects; `unnamed-N.yaml` snapshots via [cleave/config_snapshot.py](cleave/config_snapshot.py). Shared easing: [cleave/easing.py](cleave/easing.py). Architecture conventions: [.cursor/rules/architecture-principles.mdc](.cursor/rules/architecture-principles.mdc). +- BAU iterative development. Editor: `python -m cleave play` via [cleave/cli.py](cleave/cli.py) calling `cleave.viz.launch()`; [cleave.py](cleave.py) is an alias; implementation in [cleave/viz/](cleave/viz/) (up to eight Milkdrop/libprojectM layers, default four; add/remove in live tuning, 1280x720 default resolution, live at display frame rate, offline render fps via `render.fps`, stem PCM). Black-key stack in [cleave/gl_compositor.py](cleave/gl_compositor.py). GPU post-FX (bloom, grit, highlight rolloff, chroma boost): [cleave/gl_post_process.py](cleave/gl_post_process.py). Live tuning: session in [cleave/viz/session.py](cleave/viz/session.py), input in [cleave/viz/controls.py](cleave/viz/controls.py), view state in [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py), panel draw in [cleave/viz/tuning_panel_draw.py](cleave/viz/tuning_panel_draw.py); layout in [cleave/viz/row_layout.py](cleave/viz/row_layout.py); section tree in [cleave/viz/row_sections.py](cleave/viz/row_sections.py); panel field manifest in [cleave/viz/row_fields.py](cleave/viz/row_fields.py); shared live/offline frame finish in [cleave/viz/frame_finish.py](cleave/viz/frame_finish.py). projectM user time is a monotonic render clock ([cleave/viz/projectm_frame_clock.py](cleave/viz/projectm_frame_clock.py)), not song playhead. Timeline: per-track lanes (`TimelineLane` / `SlotCue(t, level)` in [cleave/timeline.py](cleave/timeline.py); `timeline.lanes` in YAML; strip in [cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)). Render credits overlay in [cleave/viz/render_overlay.py](cleave/viz/render_overlay.py) (`render.overlay` in YAML): live preview in play, burned in by [cleave/viz/render.py](cleave/viz/render.py) offline. Cleave effects: [cleave/effects/](cleave/effects/) (dispatch via [cleave/effects/handlers.py](cleave/effects/handlers.py)). Paths: [cleave/paths.py](cleave/paths.py) (repo-root data dir by default, `projects//`; `CLEAVE_DATA` override). Config: parse and defaults in [cleave/config_schema.py](cleave/config_schema.py); repo-root [cleave-viz.yaml](cleave-viz.yaml) copied into projects; `unnamed-N.yaml` snapshots via [cleave/config_snapshot.py](cleave/config_snapshot.py). Shared easing: [cleave/easing.py](cleave/easing.py). Architecture conventions: [.cursor/rules/architecture-principles.mdc](.cursor/rules/architecture-principles.mdc). - Must-do list: [docs/todos.md](docs/todos.md). Aspirational: [docs/roadmap.md](docs/roadmap.md). - See [README.md](README.md) for usage. - Project layout: `projects//` under repo root (or `CLEAVE_DATA` override) with copied mix audio, `project.yaml`, `signals.json`, `stems/` (four stem wavs), and optional configs. `separate` copies the source file, writes the manifest, runs Demucs, and writes `signals.json`; the editor reads the mix from `project.yaml`. CLI: `python -m cleave separate|play` (or `python cleave.py`, same entry point). diff --git a/cleave/config_schema.py b/cleave/config_schema.py index 739e6a9..32a6787 100644 --- a/cleave/config_schema.py +++ b/cleave/config_schema.py @@ -13,7 +13,7 @@ from cleave.effects.constants import clamp_effect_pct from cleave.effects.registry import validate_effect_entry from cleave.extract import STEM_SOURCES, StemSource -from cleave.timeline import SlotCue, TimelineLane, canonicalize +from cleave.timeline import SlotCue, TimelineLane, canonicalize, clamp_level from cleave.timeline_presets.characters import ( DEFAULT_TIMELINE_PRESET_KIND, TIMELINE_PRESET_KIND_OPTIONS, @@ -2149,9 +2149,9 @@ def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None: lanes: dict[str, TimelineLane] = {} for slot, lane_raw in lanes_map.items(): lane_map = as_mapping(lane_raw, f"timeline.lanes.{slot}") - baseline: bool | None + baseline: float | None if "baseline" in lane_map: - baseline = bool(lane_map["baseline"]) + baseline = clamp_level(float(lane_map["baseline"])) else: baseline = None cues_raw = lane_map.get("cues", []) @@ -2162,9 +2162,9 @@ def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None: cues: list[SlotCue] = [] for index, item in enumerate(cues_raw): cue_map = as_mapping(item, f"timeline.lanes.{slot}.cues[{index}]") - if "visible" not in cue_map: + if "level" not in cue_map: raise ValueError( - f"timeline.lanes.{slot}.cues[{index}] missing visible" + f"timeline.lanes.{slot}.cues[{index}] missing level" ) t = float( require_non_negative_number( @@ -2172,7 +2172,12 @@ def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None: f"timeline.lanes.{slot}.cues[{index}].t", ) ) - cues.append(SlotCue(t=t, visible=bool(cue_map["visible"]))) + cues.append( + SlotCue( + t=t, + level=clamp_level(float(cue_map["level"])), + ) + ) lanes[str(slot)] = TimelineLane( baseline=baseline, cues=canonicalize(baseline, cues), @@ -2221,7 +2226,7 @@ def persist_timeline(ctx: PersistCtx) -> dict[str, Any]: entry["baseline"] = lane.baseline if lane.cues: entry["cues"] = [ - {"t": cue.t, "visible": cue.visible} + {"t": cue.t, "level": cue.level} for cue in lane.cues ] lanes_out[slot] = entry diff --git a/cleave/timeline.py b/cleave/timeline.py index 2285595..73d47aa 100644 --- a/cleave/timeline.py +++ b/cleave/timeline.py @@ -1,9 +1,9 @@ -"""Per-lane timeline evaluation and editing for layer visibility.""" +"""Per-lane timeline evaluation and editing for layer levels.""" from __future__ import annotations from collections.abc import Mapping, Sequence -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import Literal import numpy as np @@ -13,7 +13,8 @@ RECORD_DEBOUNCE_SEC = 0.08 SONG_MARKER_FADE_MATCH_EPS = 1e-3 -_CUE_BOUNDARY_EPS = 1e-9 +LEVEL_QUANTUM = 0.25 +LEVEL_EPS = 1e-6 _STEM_SOURCE_ABBREVIATIONS = { "drums": "D", @@ -27,38 +28,15 @@ @dataclass(frozen=True) class SlotCue: t: float - visible: bool + level: float @dataclass class TimelineLane: - baseline: bool | None # None = inherit session.layers[slot].enabled + baseline: float | None # None = inherit session.layers[slot].enabled as 1.0/0.0 cues: list[SlotCue] # canonical: strictly increasing t, no redundant transitions -@dataclass -class Timeline: - enabled: bool = True - lanes: dict[str, TimelineLane] = field(default_factory=dict) - - def visible_at(self, slot: str, t_sec: float, *, inherit: bool) -> bool: - lane = self.lanes.get(slot) - if lane is None: - return inherit - return lane_visible_at(lane, t_sec, inherit=inherit) - - def visible_state_at( - self, - slots: Sequence[str], - t_sec: float, - inherits: Mapping[str, bool], - ) -> dict[str, bool]: - return { - slot: self.visible_at(slot, t_sec, inherit=inherits[slot]) - for slot in slots - } - - def empty_lane() -> TimelineLane: return TimelineLane(baseline=None, cues=[]) @@ -74,13 +52,27 @@ def stem_abbreviation(stem: StemSource) -> str: return _STEM_SOURCE_ABBREVIATIONS[stem] +def clamp_level(level: float) -> float: + return max(0.0, min(1.0, float(level))) + + +def quantize_level(level: float) -> float: + clamped = clamp_level(level) + steps = round(clamped / LEVEL_QUANTUM) + return clamp_level(steps * LEVEL_QUANTUM) + + +def levels_equal(a: float, b: float) -> bool: + return abs(float(a) - float(b)) <= LEVEL_EPS + + def canonicalize( - baseline: bool | None, + baseline: float | None, cues: Sequence[SlotCue], ) -> list[SlotCue]: """Drop redundant/no-op transitions; last-wins at equal ``t``. - Returns strictly increasing ``t`` cues where each changes visibility from + Returns strictly increasing ``t`` cues where each changes level from the previous state (``baseline``, or the prior cue when baseline is None). """ if not cues: @@ -95,39 +87,39 @@ def canonicalize( result: list[SlotCue] = [] current = baseline for cue in collapsed: - if current is not None and cue.visible == current: + if current is not None and levels_equal(cue.level, current): continue result.append(cue) - current = cue.visible + current = cue.level return result -def lane_visible_at( +def lane_level_at( lane: TimelineLane, t_sec: float, *, - inherit: bool, -) -> bool: - """Visibility at ``t_sec``. If ``baseline`` is None, use ``inherit`` until the first cue.""" - visible = inherit if lane.baseline is None else lane.baseline + inherit: float, +) -> float: + """Stepped level at ``t_sec``. If ``baseline`` is None, use ``inherit`` until the first cue.""" + level = inherit if lane.baseline is None else lane.baseline for cue in lane.cues: if cue.t > t_sec: break - visible = cue.visible - return visible + level = cue.level + return level -def lane_segments( +def lane_level_segments( lane: TimelineLane, duration_sec: float, *, - inherit: bool, -) -> list[tuple[float, float, bool]]: - """Return ``(start_t, end_t, visible)`` segments over ``[0, duration_sec]``.""" + inherit: float, +) -> list[tuple[float, float, float]]: + """Return ``(start_t, end_t, level)`` segments over ``[0, duration_sec]``.""" if duration_sec <= 0: return [] boundaries = sorted({0.0, duration_sec} | {cue.t for cue in lane.cues}) - segments: list[tuple[float, float, bool]] = [] + segments: list[tuple[float, float, float]] = [] for index in range(len(boundaries) - 1): start_t = boundaries[index] end_t = boundaries[index + 1] @@ -139,15 +131,11 @@ def lane_segments( clip_end = min(end_t, duration_sec) if clip_end <= clip_start: continue - visible = lane_visible_at(lane, clip_start, inherit=inherit) - segments.append((clip_start, clip_end, visible)) + level = lane_level_at(lane, clip_start, inherit=inherit) + segments.append((clip_start, clip_end, level)) return segments -def _boundary_has_cue(lane: TimelineLane, t: float) -> bool: - return any(abs(cue.t - t) <= _CUE_BOUNDARY_EPS for cue in lane.cues) - - @dataclass(frozen=True) class TimelineFadeGroup: """Per-edge fade settings for song-marker or standard cue boundaries.""" @@ -173,164 +161,121 @@ def _fade_group_for_edge( return standard_fades -def _edge_fade_duration( - lane: TimelineLane, +def _append_breakpoint( + breakpoints: list[tuple[float, float]], t: float, - *, - is_fade_in: bool, - duration_sec: float, - song_marker_times: Sequence[float], - song_marker_fades: TimelineFadeGroup, - standard_fades: TimelineFadeGroup, -) -> float: - if is_fade_in: - if t <= 0.0 and not _boundary_has_cue(lane, t): - return 0.0 - elif t >= duration_sec and not _boundary_has_cue(lane, t): - return 0.0 - group = _fade_group_for_edge( - t, - song_marker_times=song_marker_times, - song_marker_fades=song_marker_fades, - standard_fades=standard_fades, - ) - if not group.enabled: - return 0.0 - duration = group.fade_in if is_fade_in else group.fade_out - return max(0.0, float(duration)) - - -def _segment_fade_durations( + level: float, +) -> None: + if breakpoints and t < breakpoints[-1][0]: + t = breakpoints[-1][0] + if ( + breakpoints + and breakpoints[-1][0] == t + and levels_equal(breakpoints[-1][1], level) + ): + return + breakpoints.append((float(t), float(level))) + + +def lane_level_breakpoints( lane: TimelineLane, - start: float, - end: float, *, - duration_sec: float, - song_marker_times: Sequence[float], - song_marker_fades: TimelineFadeGroup, - standard_fades: TimelineFadeGroup, -) -> tuple[float, float]: - fade_in = _edge_fade_duration( - lane, - start, - is_fade_in=True, - duration_sec=duration_sec, - song_marker_times=song_marker_times, - song_marker_fades=song_marker_fades, - standard_fades=standard_fades, - ) - fade_out = _edge_fade_duration( - lane, - end, - is_fade_in=False, - duration_sec=duration_sec, - song_marker_times=song_marker_times, - song_marker_fades=song_marker_fades, - standard_fades=standard_fades, - ) - return fade_in, fade_out - - -def _segment_fade_envelope( - t_sec: float, - start: float, - end: float, - *, - fade_in: float, - fade_out: float, -) -> float: - if start <= t_sec < end: - return 1.0 - if fade_in > 0.0 and start - fade_in <= t_sec < start: - return smoothstep((t_sec - (start - fade_in)) / fade_in) - if fade_out > 0.0 and end <= t_sec < end + fade_out: - return smoothstep((end + fade_out - t_sec) / fade_out) - return 0.0 - - -def lane_fade_spans( - lane: TimelineLane, - *, - inherit: bool, + inherit: float, song_marker_fades: TimelineFadeGroup, standard_fades: TimelineFadeGroup, duration_sec: float, song_marker_times: Sequence[float] = (), -) -> list[tuple[float, float, Literal["in", "out"]]]: - """Return clipped ``(t0, t1, kind)`` fade wedges for visible lane segments.""" +) -> list[tuple[float, float]]: + """Build a monotone ``(t, level)`` polyline for the lane envelope. + + For a transition at ``t`` from level ``a`` to ``b``, fade durations act as + slopes: a full-scale move takes the configured duration. Rise completes at + the cue time; fall starts at the cue time. Disabled groups or zero duration + collapse to a hard step. Overlapping rise starts clamp forward. + """ if duration_sec <= 0.0: return [] - spans: list[tuple[float, float, Literal["in", "out"]]] = [] - for start, end, visible in lane_segments(lane, duration_sec, inherit=inherit): - if not visible: + level = inherit if lane.baseline is None else float(lane.baseline) + if not lane.cues: + return [(0.0, float(level))] + + breakpoints: list[tuple[float, float]] = [] + previous = float(level) + for cue in lane.cues: + a = previous + b = float(cue.level) + t = float(cue.t) + previous = b + if levels_equal(a, b): continue - fade_in, fade_out = _segment_fade_durations( - lane, - start, - end, - duration_sec=duration_sec, + group = _fade_group_for_edge( + t, song_marker_times=song_marker_times, song_marker_fades=song_marker_fades, standard_fades=standard_fades, ) - if fade_in > 0.0: - t0 = max(0.0, start - fade_in) - t1 = start - if t1 > t0: - spans.append((t0, t1, "in")) - if fade_out > 0.0: - t0 = end - t1 = min(duration_sec, end + fade_out) - if t1 > t0: - spans.append((t0, t1, "out")) - return spans - - -def lane_fade_alpha( - lane: TimelineLane, + if b > a: + delta = b - a + fade_in = max(0.0, float(group.fade_in)) if group.enabled else 0.0 + ramp = fade_in * delta + if ramp <= 0.0: + _append_breakpoint(breakpoints, t, a) + _append_breakpoint(breakpoints, t, b) + continue + t_start = t - ramp + if breakpoints and t_start < breakpoints[-1][0]: + t_start = breakpoints[-1][0] + if t_start >= t: + _append_breakpoint(breakpoints, t, a) + _append_breakpoint(breakpoints, t, b) + else: + _append_breakpoint(breakpoints, t_start, a) + _append_breakpoint(breakpoints, t, b) + else: + delta = a - b + fade_out = max(0.0, float(group.fade_out)) if group.enabled else 0.0 + ramp = fade_out * delta + if ramp <= 0.0: + _append_breakpoint(breakpoints, t, a) + _append_breakpoint(breakpoints, t, b) + continue + _append_breakpoint(breakpoints, t, a) + _append_breakpoint(breakpoints, t + ramp, b) + + if not breakpoints: + return [(0.0, float(level))] + return breakpoints + + +def lane_level_envelope( t_sec: float, - *, - inherit: bool, - song_marker_fades: TimelineFadeGroup, - standard_fades: TimelineFadeGroup, - duration_sec: float, - song_marker_times: Sequence[float] = (), + breakpoints: Sequence[tuple[float, float]], ) -> float: - """Continuous opacity for visible segments with optional edge fades. - - For each visible ``[A, B)`` from :func:`lane_segments`, fade-in starts - before ``A`` and reaches full at ``A``; fade-out starts at ``B`` and - reaches zero after ``B``. Durations come from the song-marker group when - the edge matches a marker within :data:`SONG_MARKER_FADE_MATCH_EPS`, else - the standard group. Disabled groups (or zero duration) stay abrupt. - Overlapping envelopes use max. Song start/end without a cue at that edge - do not fade. - """ - if duration_sec <= 0.0: + """Interpolate level at ``t_sec`` along breakpoints with smoothstep easing.""" + if not breakpoints: return 0.0 - alpha = 0.0 - for start, end, visible in lane_segments(lane, duration_sec, inherit=inherit): - if not visible: + if t_sec < breakpoints[0][0]: + return float(breakpoints[0][1]) + if t_sec > breakpoints[-1][0]: + return float(breakpoints[-1][1]) + for index in range(len(breakpoints) - 1): + t0, v0 = breakpoints[index] + t1, v1 = breakpoints[index + 1] + # Half-open [t0, t1) so a hard step at t1 wins over the prior segment end. + if t_sec >= t1: continue - fade_in, fade_out = _segment_fade_durations( - lane, - start, - end, - duration_sec=duration_sec, - song_marker_times=song_marker_times, - song_marker_fades=song_marker_fades, - standard_fades=standard_fades, - ) - contrib = _segment_fade_envelope( - t_sec, - start, - end, - fade_in=fade_in, - fade_out=fade_out, - ) - if contrib > alpha: - alpha = contrib - return alpha + if t1 <= t0: + # Hard step: settle to the last value at this instant. + last = index + 1 + while ( + last + 1 < len(breakpoints) + and breakpoints[last + 1][0] <= t0 + ): + last += 1 + return float(breakpoints[last][1]) + u = (t_sec - t0) / (t1 - t0) + return float(v0 + (v1 - v0) * smoothstep(u)) + return float(breakpoints[-1][1]) def lane_tick_times(lane: TimelineLane, duration_sec: float) -> list[float]: @@ -347,27 +292,30 @@ def lane_on_transition_trigger_times( song_marker_fades: TimelineFadeGroup, standard_fades: TimelineFadeGroup, ) -> list[float]: - """Preset-switch trigger times for each rising edge (``visible=True`` cue). + """Preset-switch trigger times for each rise from zero. - Canonical cues alternate, so every ``visible=True`` cue is an off->on edge. - Trigger is ``cue.t - fade_in(edge)`` using the song-marker vs standard fade - group for that edge (same selection as :func:`lane_fade_alpha`). When the - matching group is disabled or ``fade_in`` is 0, the trigger is ``cue.t``. + Fires when ``previous <= LEVEL_EPS < cue.level``, with ``previous`` from + ``baseline`` or ``0.0`` when baseline is None. Trigger is + ``cue.t - fade_in * cue.level`` using the song-marker vs standard fade + group for that edge. When the matching group is disabled or ``fade_in`` is + 0, the trigger is ``cue.t``. """ triggers: list[float] = [] + previous = 0.0 if lane.baseline is None else float(lane.baseline) for cue in lane.cues: - if not cue.visible: - continue - fade_in = _edge_fade_duration( - lane, - cue.t, - is_fade_in=True, - duration_sec=0.0, - song_marker_times=song_marker_times, - song_marker_fades=song_marker_fades, - standard_fades=standard_fades, - ) - triggers.append(cue.t - fade_in) + if previous <= LEVEL_EPS < cue.level: + group = _fade_group_for_edge( + cue.t, + song_marker_times=song_marker_times, + song_marker_fades=song_marker_fades, + standard_fades=standard_fades, + ) + if not group.enabled or group.fade_in <= 0.0: + ramp = 0.0 + else: + ramp = max(0.0, float(group.fade_in)) * float(cue.level) + triggers.append(cue.t - ramp) + previous = float(cue.level) return triggers @@ -426,13 +374,13 @@ def strip_lane_range( def set_lane_cue( lane: TimelineLane, t: float, - visible: bool, + level: float, ) -> TimelineLane: """Set or replace the transition at ``t``; canonicalize.""" others = [cue for cue in lane.cues if cue.t != t] return TimelineLane( baseline=lane.baseline, - cues=canonicalize(lane.baseline, others + [SlotCue(t=t, visible=visible)]), + cues=canonicalize(lane.baseline, others + [SlotCue(t=t, level=level)]), ) @@ -521,7 +469,7 @@ def shift_lane_cues_by_beats( t=float( beats[max(0, min(last, _nearest_beat_index(cue.t, beats) + delta))] ), - visible=cue.visible, + level=cue.level, ) for cue in lane.cues ] @@ -542,7 +490,7 @@ def snap_lane_to_beats( beats = np.asarray(beat_times, dtype=np.float64) if beats.size == 1: sole = float(beats[0]) - snapped = [SlotCue(t=sole, visible=cue.visible) for cue in lane.cues] + snapped = [SlotCue(t=sole, level=cue.level) for cue in lane.cues] return TimelineLane( baseline=lane.baseline, cues=canonicalize(lane.baseline, snapped), @@ -568,7 +516,7 @@ def snap_t(t: float) -> float: (first + lo * interval, first + (lo + 1) * interval), ) - snapped = [SlotCue(t=snap_t(cue.t), visible=cue.visible) for cue in lane.cues] + snapped = [SlotCue(t=snap_t(cue.t), level=cue.level) for cue in lane.cues] return TimelineLane( baseline=lane.baseline, cues=canonicalize(lane.baseline, snapped), @@ -633,7 +581,7 @@ def snap_lanes_to_song_markers( if best_i is None: continue old = cues[best_i] - cues[best_i] = SlotCue(t=marker, visible=old.visible) + cues[best_i] = SlotCue(t=marker, level=old.level) claimed.add(best_i) moved += 1 else: @@ -656,7 +604,7 @@ def snap_lanes_to_song_markers( continue _dist, _t, _z, slot, cue_i = best old = working[slot][cue_i] - working[slot][cue_i] = SlotCue(t=marker, visible=old.visible) + working[slot][cue_i] = SlotCue(t=marker, level=old.level) claimed_pairs.add((slot, cue_i)) moved += 1 diff --git a/cleave/timeline_presets/arrange.py b/cleave/timeline_presets/arrange.py index ede917c..58a6af0 100644 --- a/cleave/timeline_presets/arrange.py +++ b/cleave/timeline_presets/arrange.py @@ -13,7 +13,7 @@ build_vocab, density_score_bonus, ) -from cleave.timeline_presets.emit import cues_from_states +from cleave.timeline_presets.emit import cues_from_states, levels_from_active from cleave.timeline_presets.grid import thin_bar_times_for_arrange from cleave.timeline_presets.motifs import ( MIN_SWITCH_GAP_BARS, @@ -49,7 +49,9 @@ def compose_timeline( if not slot_list or duration_sec <= 0.0: return {} if len(slot_list) == 1: - return cues_from_states(slot_list, [(0.0, frozenset({slot_list[0]}))]) + return cues_from_states( + slot_list, [(0.0, levels_from_active({slot_list[0]}))] + ) order = list(slot_list) rng.shuffle(order) @@ -60,12 +62,12 @@ def compose_timeline( markers = _normalize_song_markers(song_marker_times, duration_sec) if len(bars) < PHRASE_BARS_MIN and not markers: opening = frozenset({order[0]}) - return cues_from_states(slot_list, [(0.0, opening)]) + return cues_from_states(slot_list, [(0.0, levels_from_active(opening))]) phrases = _partition_phrases(bars, duration_sec, rng, markers) if not phrases: opening = frozenset({order[0]}) - return cues_from_states(slot_list, [(0.0, opening)]) + return cues_from_states(slot_list, [(0.0, levels_from_active(opening))]) states: list[tuple[float, frozenset[str]]] = [] prev_active: frozenset[str] | None = None @@ -163,7 +165,10 @@ def compose_timeline( _apply_resolve(profile, vocab, states, duration_sec, bars, rng) states.sort(key=lambda item: item[0]) - return cues_from_states(slot_list, states) + return cues_from_states( + slot_list, + [(t, levels_from_active(active)) for t, active in states], + ) def _normalize_song_markers( diff --git a/cleave/timeline_presets/crescendo.py b/cleave/timeline_presets/crescendo.py index 3119367..431b87b 100644 --- a/cleave/timeline_presets/crescendo.py +++ b/cleave/timeline_presets/crescendo.py @@ -3,11 +3,11 @@ from __future__ import annotations import random -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass from typing import Literal -from cleave.timeline import TimelineLane, lane_visible_at +from cleave.timeline import LEVEL_EPS, TimelineLane, lane_level_at, levels_equal from cleave.timeline_presets.chords import MAX_CONCURRENT_LAYERS from cleave.timeline_presets.emit import cues_from_states @@ -125,11 +125,23 @@ def apply_crescendo( return cues_from_states(slot_list, merged) +def _levels_equal_maps( + a: Mapping[str, float], + b: Mapping[str, float], + slots: Sequence[str] | None = None, +) -> bool: + keys = slots if slots is not None else sorted(set(a) | set(b)) + return all( + levels_equal(float(a.get(slot, 0.0)), float(b.get(slot, 0.0))) + for slot in keys + ) + + def _states_before( lanes: dict[str, TimelineLane], slots: Sequence[str], t_start: float, -) -> list[tuple[float, frozenset[str]]]: +) -> list[tuple[float, dict[str, float]]]: times = {0.0} for slot in slots: lane = lanes.get(slot) @@ -138,11 +150,11 @@ def _states_before( for cue in lane.cues: if cue.t < t_start - 1e-9: times.add(float(cue.t)) - states: list[tuple[float, frozenset[str]]] = [] + states: list[tuple[float, dict[str, float]]] = [] for t in sorted(times): - active = _active_at(lanes, slots, t) - if not states or states[-1][1] != active: - states.append((t, active)) + levels = _active_at(lanes, slots, t) + if not states or not _levels_equal_maps(states[-1][1], levels, slots): + states.append((t, levels)) return states @@ -150,19 +162,18 @@ def _active_at( lanes: dict[str, TimelineLane], slots: Sequence[str], t: float, -) -> frozenset[str]: - active = frozenset( - slot - for slot in slots - if lane_visible_at( - lanes.get(slot) or TimelineLane(baseline=False, cues=[]), +) -> dict[str, float]: + levels = { + slot: lane_level_at( + lanes.get(slot) or TimelineLane(baseline=0.0, cues=[]), t, - inherit=False, + inherit=0.0, ) - ) - if active: - return active - return frozenset({slots[0]}) + for slot in slots + } + if any(level > LEVEL_EPS for level in levels.values()): + return levels + return {slots[0]: 1.0} def _crescendo_states( @@ -172,21 +183,30 @@ def _crescendo_states( duration_sec: float, bar_times: Sequence[float], rng: random.Random, -) -> list[tuple[float, frozenset[str]]]: +) -> list[tuple[float, dict[str, float]]]: order = list(slots) rng.shuffle(order) max_n = min(len(order), MAX_CONCURRENT_LAYERS) stack = order[:max_n] times = _spread_times(window.t_start, window.t_full, max_n, bar_times) - states: list[tuple[float, frozenset[str]]] = [] + states: list[tuple[float, dict[str, float]]] = [] + last_i = len(times) - 1 for i, t in enumerate(times): - n_active = min(i + 1, max_n) - states.append((float(t), frozenset(stack[:n_active]))) + if i == last_i: + levels = {slot: 1.0 for slot in stack} + else: + levels = {slot: 1.0 for slot in stack[:i]} + levels[stack[i]] = 0.5 + states.append((float(t), levels)) # Drop to a single layer at the selected marker; hold through song end. - solo = frozenset({stack[0]}) + solo = {stack[0]: 1.0} peak_end = float(window.t_peak_end) if peak_end < duration_sec - 1e-9: - if not states or abs(states[-1][0] - peak_end) > 1e-9 or states[-1][1] != solo: + if ( + not states + or abs(states[-1][0] - peak_end) > 1e-9 + or not _levels_equal_maps(states[-1][1], solo) + ): states.append((peak_end, solo)) elif states: # Selected marker is at/after duration; force solo on the last step. @@ -232,20 +252,23 @@ def _even_index(i: int, need: int, n: int) -> int: def _merge_states( - prefix: Sequence[tuple[float, frozenset[str]]], - crescendo: Sequence[tuple[float, frozenset[str]]], -) -> list[tuple[float, frozenset[str]]]: + prefix: Sequence[tuple[float, Mapping[str, float]]], + crescendo: Sequence[tuple[float, Mapping[str, float]]], +) -> list[tuple[float, dict[str, float]]]: if not crescendo: - return list(prefix) - merged = list(prefix) + return [(t, dict(levels)) for t, levels in prefix] + merged: list[tuple[float, dict[str, float]]] = [ + (t, dict(levels)) for t, levels in prefix + ] first_t = crescendo[0][0] while merged and merged[-1][0] >= first_t - 1e-9: merged.pop() - for t, active in crescendo: - if merged and merged[-1][1] == active: + for t, levels in crescendo: + level_map = dict(levels) + if merged and _levels_equal_maps(merged[-1][1], level_map): continue if merged and abs(merged[-1][0] - t) < 1e-9: - merged[-1] = (t, active) + merged[-1] = (t, level_map) continue - merged.append((t, active)) + merged.append((t, level_map)) return merged diff --git a/cleave/timeline_presets/emit.py b/cleave/timeline_presets/emit.py index 2ea46e0..0bd355f 100644 --- a/cleave/timeline_presets/emit.py +++ b/cleave/timeline_presets/emit.py @@ -1,31 +1,44 @@ -"""Emit timeline lanes from active-set state sequences.""" +"""Emit timeline lanes from per-slot level state sequences.""" from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Mapping, Sequence -from cleave.timeline import SlotCue, TimelineLane, canonicalize, empty_lane +from cleave.timeline import SlotCue, TimelineLane, canonicalize, empty_lane, levels_equal + + +def levels_from_active( + active: frozenset[str] | set[str], + level: float = 1.0, +) -> dict[str, float]: + """Map an active-slot set to a level mapping (absent slots mean 0.0).""" + return {slot: float(level) for slot in active} def cues_from_states( slots: Sequence[str], - states: Sequence[tuple[float, frozenset[str]]], + states: Sequence[tuple[float, Mapping[str, float]]], ) -> dict[str, TimelineLane]: - """Build per-slot lanes: baseline from the first state, then transitions.""" + """Build per-slot lanes: baseline from the first state, then level transitions. + + Absent slots in a state mapping are treated as level ``0.0``. + """ slot_list = list(slots) if not states: return {slot: empty_lane() for slot in slot_list} - _t0, first_active = states[0] - baselines = {slot: (slot in first_active) for slot in slot_list} + _t0, first_levels = states[0] + baselines = { + slot: float(first_levels.get(slot, 0.0)) for slot in slot_list + } cues_by_slot: dict[str, list[SlotCue]] = {slot: [] for slot in slot_list} - prev = first_active - for t, active in states[1:]: + prev = {slot: baselines[slot] for slot in slot_list} + for t, levels in states[1:]: for slot in slot_list: - now = slot in active - if now != (slot in prev): - cues_by_slot[slot].append(SlotCue(t=float(t), visible=now)) - prev = active + now = float(levels.get(slot, 0.0)) + if not levels_equal(now, prev[slot]): + cues_by_slot[slot].append(SlotCue(t=float(t), level=now)) + prev[slot] = now return { slot: TimelineLane( baseline=baselines[slot], diff --git a/cleave/viz/layer.py b/cleave/viz/layer.py index 7d378a1..d2df15d 100644 --- a/cleave/viz/layer.py +++ b/cleave/viz/layer.py @@ -20,7 +20,7 @@ class StemLayer: playlist: PresetPlaylist projectm_playlist: ProjectMPlaylist | None = None auto_preset_path: Path | None = None - timeline_fade: float = 1.0 + timeline_level: float = 1.0 preset_rotation: PresetRotation | None = None timeline_switch_count: int = 0 rotation_anchor: int = 0 diff --git a/cleave/viz/layer_pipeline.py b/cleave/viz/layer_pipeline.py index 86435cf..5171682 100644 --- a/cleave/viz/layer_pipeline.py +++ b/cleave/viz/layer_pipeline.py @@ -75,7 +75,7 @@ def apply_effect_modifiers( if not layer.fbo.enabled: continue mod = modifiers[slot] - layer.fbo.opacity = mod.opacity * layer.timeline_fade + layer.fbo.opacity = mod.opacity * layer.timeline_level layer.fbo.flash_alpha = mod.flash_alpha layer.fbo.bloom_strength = mod.bloom_strength layer.fbo.hue_rgb = mod.hue_rgb diff --git a/cleave/viz/layer_visibility.py b/cleave/viz/layer_visibility.py index 8f30b73..7edfb95 100644 --- a/cleave/viz/layer_visibility.py +++ b/cleave/viz/layer_visibility.py @@ -1,4 +1,4 @@ -"""Timeline visibility algebra and per-frame layer enablement.""" +"""Timeline level algebra and per-frame layer enablement.""" from __future__ import annotations @@ -6,14 +6,17 @@ from typing import TYPE_CHECKING from cleave.timeline import ( + LEVEL_EPS, SlotCue, TimelineFadeGroup, TimelineLane, canonicalize, copy_lane, empty_lane, - lane_fade_alpha, - lane_visible_at, + lane_level_at, + lane_level_breakpoints, + lane_level_envelope, + levels_equal, punch_lane, shift_bars_by_beats, strip_lane_range, @@ -46,45 +49,35 @@ def _lane_for_slot(session: TuningSession, slot: str) -> TimelineLane: return session.timeline.lanes.get(slot) or empty_lane() -def _inherit_for_slot(session: TuningSession, slot: str) -> bool: - return session.layers[slot].enabled +def _inherit_for_slot(session: TuningSession, slot: str) -> float: + return 1.0 if session.layers[slot].enabled else 0.0 -def timeline_defaults(session: TuningSession) -> dict[str, bool]: - """Per-slot inherit values: concrete lane baseline, else ``layers[slot].enabled``.""" +def timeline_defaults(session: TuningSession) -> dict[str, float]: + """Per-slot inherit values: concrete lane baseline, else layer enabled as 1/0.""" return { slot: ( - lane.baseline + float(lane.baseline) if (lane := session.timeline.lanes.get(slot)) is not None and lane.baseline is not None - else session.layers[slot].enabled + else _inherit_for_slot(session, slot) ) for slot in session.layer_z_order } -def timeline_committed_visible( +def timeline_committed_level( session: TuningSession, slot: str, t_sec: float, -) -> bool: - return lane_visible_at( +) -> float: + return lane_level_at( _lane_for_slot(session, slot), t_sec, inherit=_inherit_for_slot(session, slot), ) -def snapshot_monitor_from_timeline( - session: TuningSession, - t_sec: float, -) -> dict[str, bool]: - return { - slot: timeline_committed_visible(session, slot, t_sec) - for slot in session.layer_z_order - } - - def snapshot_monitor_from_output( session: TuningSession, t_sec: float, @@ -95,7 +88,7 @@ def snapshot_monitor_from_output( } -def armed_recording_defaults(session: TuningSession) -> dict[str, bool]: +def armed_recording_defaults(session: TuningSession) -> dict[str, float]: defaults = timeline_defaults(session) defaults.update(session.timeline.record_baseline) return defaults @@ -107,28 +100,28 @@ def _recording_lane(session: TuningSession, slot: str) -> TimelineLane: return TimelineLane(baseline=baseline, cues=list(cues)) -def armed_recording_visible( +def armed_recording_level( session: TuningSession, slot: str, t_sec: float, -) -> bool: - """Visibility for a record-pass slot during an active take.""" - return lane_visible_at(_recording_lane(session, slot), t_sec, inherit=True) +) -> float: + """Level for a record-pass slot during an active take.""" + return lane_level_at(_recording_lane(session, slot), t_sec, inherit=1.0) -def committed_visible_outside_punch( +def committed_level_outside_punch( session: TuningSession, slot: str, record_start: float, record_stop: float, -) -> bool: - """Committed visibility at *record_stop* ignoring cues inside the punch.""" +) -> float: + """Committed level at *record_stop* ignoring cues inside the punch.""" stripped = strip_lane_range( _lane_for_slot(session, slot), record_start, record_stop, ) - return lane_visible_at( + return lane_level_at( stripped, record_stop, inherit=_inherit_for_slot(session, slot), @@ -156,13 +149,13 @@ def _punch_cues_for_slot( tl = session.timeline punch: list[SlotCue] = [] baseline = tl.record_baseline[slot] - if baseline != timeline_committed_visible(session, slot, record_start): - punch.append(SlotCue(t=record_start, visible=baseline)) + if not levels_equal(baseline, timeline_committed_level(session, slot, record_start)): + punch.append(SlotCue(t=record_start, level=baseline)) punch.extend(tl.record_buffer.get(slot, [])) - end_visible = armed_recording_visible(session, slot, record_stop) - committed_at_stop = timeline_committed_visible(session, slot, record_stop) - if end_visible != committed_at_stop: - punch.append(SlotCue(t=record_stop, visible=committed_at_stop)) + end_level = armed_recording_level(session, slot, record_stop) + committed_at_stop = timeline_committed_level(session, slot, record_stop) + if not levels_equal(end_level, committed_at_stop): + punch.append(SlotCue(t=record_stop, level=committed_at_stop)) return punch @@ -203,15 +196,15 @@ def effective_layer_enabled( tl = session.timeline if tl.recording: if slot in tl.record_baseline: - return armed_recording_visible(session, slot, t_sec) + return armed_recording_level(session, slot, t_sec) > LEVEL_EPS if slot in tl.override_slots: return tl.override_visible.get(slot, True) - return timeline_committed_visible(session, slot, t_sec) + return timeline_committed_level(session, slot, t_sec) > LEVEL_EPS if tl.preview_active: return tl.monitor[slot] if slot in tl.override_slots: return tl.override_visible.get(slot, True) - return timeline_committed_visible(session, slot, t_sec) + return timeline_committed_level(session, slot, t_sec) > LEVEL_EPS def _as_fade_group(group: TimelineFadeGroupRuntime) -> TimelineFadeGroup: @@ -222,8 +215,8 @@ def _as_fade_group(group: TimelineFadeGroupRuntime) -> TimelineFadeGroup: ) -def _timeline_fades_apply(session: TuningSession, slot: str) -> bool: - """True when continuous cue fades drive FBO enable/opacity for *slot*.""" +def _timeline_levels_apply(session: TuningSession, slot: str) -> bool: + """True when the timeline level envelope drives FBO enable/opacity for *slot*.""" from cleave.viz.editor_mode_controls import is_preset_curation_mode if session.solo_slot is not None: @@ -233,8 +226,6 @@ def _timeline_fades_apply(session: TuningSession, slot: str) -> bool: tl = session.timeline if not tl.enabled: return False - if not (tl.song_marker_fades.enabled or tl.standard_cue_fades.enabled): - return False if tl.recording or tl.preview_active: return False if slot in tl.override_slots: @@ -242,18 +233,18 @@ def _timeline_fades_apply(session: TuningSession, slot: str) -> bool: return True -def _fade_eval_duration(lane: TimelineLane, t_sec: float, fade_out: float) -> float: +def _level_eval_duration(lane: TimelineLane, t_sec: float, fade_out: float) -> float: last_cue = max((cue.t for cue in lane.cues), default=0.0) pad = max(0.0, float(fade_out)) + 1.0 return max(t_sec + pad, last_cue + pad, 1.0) -def timeline_fade_multiplier( +def timeline_level_multiplier( session: TuningSession, slot: str, t_sec: float, ) -> float: - """Committed-lane fade opacity in ``[0, 1]`` (ignores solo/override/preview).""" + """Committed-lane level in ``[0, 1]`` (ignores solo/override/preview).""" tl = session.timeline lane = _lane_for_slot(session, slot) song_marker_fades = _as_fade_group(tl.song_marker_fades) @@ -263,15 +254,15 @@ def timeline_fade_multiplier( max_fade_out = max(max_fade_out, song_marker_fades.fade_out) if standard_fades.enabled: max_fade_out = max(max_fade_out, standard_fades.fade_out) - return lane_fade_alpha( + breakpoints = lane_level_breakpoints( lane, - t_sec, inherit=_inherit_for_slot(session, slot), song_marker_fades=song_marker_fades, standard_fades=standard_fades, - duration_sec=_fade_eval_duration(lane, t_sec, max_fade_out), + duration_sec=_level_eval_duration(lane, t_sec, max_fade_out), song_marker_times=session.song_markers.times, ) + return lane_level_envelope(t_sec, breakpoints) def apply_layer_visibility( @@ -280,12 +271,12 @@ def apply_layer_visibility( t_sec: float, ) -> None: for slot, layer in layers_by_slot.items(): - if _timeline_fades_apply(session, slot): - fade = timeline_fade_multiplier(session, slot, t_sec) - layer.timeline_fade = fade - layer.fbo.enabled = fade > 0.0 + if _timeline_levels_apply(session, slot): + level = timeline_level_multiplier(session, slot, t_sec) + layer.timeline_level = level + layer.fbo.enabled = level > LEVEL_EPS else: - layer.timeline_fade = 1.0 + layer.timeline_level = 1.0 layer.fbo.enabled = effective_layer_enabled(session, slot, t_sec) @@ -312,8 +303,8 @@ def build_timeline_view_state( slot: effective_layer_enabled(session, slot, position_sec) for slot in session.layer_z_order } - timeline_visible = { - slot: timeline_committed_visible(session, slot, position_sec) + timeline_level = { + slot: timeline_committed_level(session, slot, position_sec) for slot in session.layer_z_order } return TimelineViewState( @@ -327,7 +318,7 @@ def build_timeline_view_state( duration_sec=duration_sec, focus_row=focus_row, monitor_visible=monitor_visible, - timeline_visible=timeline_visible, + timeline_level=timeline_level, slot_stems={slot: session.layers[slot].stem for slot in session.layer_z_order}, override_slots=set(tl.override_slots), armed_slots=set(tl.armed_slots), diff --git a/cleave/viz/session.py b/cleave/viz/session.py index e57ed6d..d671641 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -181,7 +181,7 @@ class TimelineRuntime: armed_slots: set[str] = field(default_factory=set) recording: bool = False record_buffer: dict[str, list[SlotCue]] = field(default_factory=dict) - record_baseline: dict[str, bool] = field(default_factory=dict) + record_baseline: dict[str, float] = field(default_factory=dict) record_start_sec: float | None = None record_slot_start_sec: dict[str, float] = field(default_factory=dict) record_high_water_mark: float | None = None diff --git a/cleave/viz/timeline_controls.py b/cleave/viz/timeline_controls.py index 4829db1..28f326e 100644 --- a/cleave/viz/timeline_controls.py +++ b/cleave/viz/timeline_controls.py @@ -6,12 +6,18 @@ import pygame -from cleave.timeline import SlotCue, canonicalize, should_accept_toggle, snap_placement_time +from cleave.timeline import ( + LEVEL_EPS, + SlotCue, + canonicalize, + should_accept_toggle, + snap_placement_time, +) from cleave.viz.controls import SEEK_LONG, SEEK_SHORT, SEEK_TINY from cleave.viz.session import TuningSession from cleave.viz.key_repeat import mod_ctrl, mod_shift from cleave.viz.layer_visibility import ( - armed_recording_visible, + armed_recording_level, build_record_punch_cues, effective_layer_enabled, snapshot_monitor_from_output, @@ -203,8 +209,10 @@ def _toggle_arm(self) -> None: armed.add(slot) if tl.recording: t_sec = current_sec(self.playback, self.duration_sec) - tl.record_baseline[slot] = effective_layer_enabled( - self.session, slot, t_sec + tl.record_baseline[slot] = ( + 1.0 + if effective_layer_enabled(self.session, slot, t_sec) + else 0.0 ) tl.record_slot_start_sec[slot] = self._snap_placement(t_sec) self._last_toggle_t.pop(slot, None) @@ -255,7 +263,11 @@ def _start_record(self) -> None: t_sec = current_sec(self.playback, self.duration_sec) snapped = self._snap_placement(t_sec) tl.record_baseline = { - stem: effective_layer_enabled(self.session, stem, t_sec) + stem: ( + 1.0 + if effective_layer_enabled(self.session, stem, t_sec) + else 0.0 + ) for stem in tl.armed_slots } tl.record_slot_start_sec = {stem: snapped for stem in tl.armed_slots} @@ -352,10 +364,10 @@ def _toggle_armed_layer_at(self, slot: str, t_sec: float) -> None: if not should_accept_toggle(self._last_toggle_t.get(slot), t_sec): return - current = armed_recording_visible(self.session, slot, t_sec) + current_on = armed_recording_level(self.session, slot, t_sec) > LEVEL_EPS snapped = self._snap_placement(t_sec) tl.record_buffer.setdefault(slot, []).append( - SlotCue(t=snapped, visible=not current) + SlotCue(t=snapped, level=0.0 if current_on else 1.0) ) self._last_toggle_t[slot] = t_sec @@ -369,14 +381,14 @@ def _fill_record_at_seek(self, old_t: float, new_t: float) -> None: for slot in list(tl.armed_slots): if slot not in tl.record_baseline: continue - v = armed_recording_visible(self.session, slot, old_t) + level = armed_recording_level(self.session, slot, old_t) buf = tl.record_buffer.get(slot, []) kept = [ cue for cue in buf if not (skip_start <= cue.t <= skip_end) ] tl.record_buffer[slot] = canonicalize( tl.record_baseline[slot], - kept + [SlotCue(t=skip_start, visible=v)], + kept + [SlotCue(t=skip_start, level=level)], ) self._last_toggle_t.pop(slot, None) tl.record_high_water_mark = max(tl.record_high_water_mark or 0.0, old_t) diff --git a/cleave/viz/timeline_overlay.py b/cleave/viz/timeline_overlay.py index bbf2d32..be569dc 100644 --- a/cleave/viz/timeline_overlay.py +++ b/cleave/viz/timeline_overlay.py @@ -8,13 +8,14 @@ from cleave.extract import StemSource from cleave.timeline import ( + LEVEL_EPS, SlotCue, TimelineFadeGroup, TimelineLane, empty_lane, - lane_fade_spans, - lane_segments, + lane_level_breakpoints, lane_tick_times, + levels_equal, stem_abbreviation, ) from cleave.viz.material_icons import visibility_icon_slot_width @@ -57,6 +58,11 @@ _timeline_ui = timeline_ui_metrics() TIMELINE_PANEL_GAP: int = _timeline_ui.panel_gap OFF_SEGMENT_COLOR: tuple[int, int, int] = (40, 40, 40) +# Dim end of the level-bar colour ramp (quarter beds stay readable). +_BAR_LEVEL_DIM: tuple[int, int, int] = tuple( + int(round(OFF_SEGMENT_COLOR[i] + (TIMELINE_BAR_ON[i] - OFF_SEGMENT_COLOR[i]) * 0.4)) + for i in range(3) +) def timeline_viewport_reserve_px(row_count: int, *, margin: int | None = None) -> int: @@ -85,19 +91,19 @@ def timeline_viewport_reserve_px(row_count: int, *, margin: int | None = None) - class TimelineViewState: layer_z_order: list[str] lanes: dict[str, TimelineLane] - defaults: dict[str, bool] + defaults: dict[str, float] position_sec: float duration_sec: float focus_row: int # 0..N-1, index into layer_z_order (0 = bottom stem) monitor_visible: dict[str, bool] - timeline_visible: dict[str, bool] + timeline_level: dict[str, float] slot_stems: dict[str, StemSource] = field(default_factory=dict) override_slots: set[str] = field(default_factory=set) armed_slots: set[str] = field(default_factory=set) recording: bool = False record_start_sec: float | None = None record_slot_start_sec: dict[str, float] = field(default_factory=dict) - record_baseline: dict[str, bool] = field(default_factory=dict) + record_baseline: dict[str, float] = field(default_factory=dict) record_buffer: dict[str, list[SlotCue]] = field(default_factory=dict) record_high_water_mark: float | None = None enabled: bool = False @@ -111,16 +117,6 @@ class TimelineViewState: standard_cue_fades: TimelineFadeGroup = field(default_factory=TimelineFadeGroup) -def visibility_segments( - lane: TimelineLane, - duration_sec: float, - *, - inherit: bool, -) -> list[tuple[float, float, bool]]: - """Return ``(start_t, end_t, visible)`` segments over ``[0, duration_sec]``.""" - return lane_segments(lane, duration_sec, inherit=inherit) - - def cue_times_for_stem( lane: TimelineLane, duration_sec: float, @@ -133,8 +129,8 @@ def _lane_for_view(state: TimelineViewState, slot: str) -> TimelineLane: return state.lanes.get(slot) or empty_lane() -def _inherit_for_view(state: TimelineViewState, slot: str) -> bool: - return state.defaults.get(slot, True) +def _inherit_for_view(state: TimelineViewState, slot: str) -> float: + return float(state.defaults.get(slot, 1.0)) def _recording_view_lane(state: TimelineViewState, slot: str) -> TimelineLane: @@ -144,32 +140,115 @@ def _recording_view_lane(state: TimelineViewState, slot: str) -> TimelineLane: ) -def _clip_segments( - segments: list[tuple[float, float, bool]], - range_start: float, - range_end: float, -) -> list[tuple[float, float, bool]]: - clipped: list[tuple[float, float, bool]] = [] - for start_t, end_t, visible in segments: - clip_start = max(start_t, range_start) - clip_end = min(end_t, range_end) - if clip_end > clip_start: - clipped.append((clip_start, clip_end, visible)) +def level_at_breakpoints( + breakpoints: list[tuple[float, float]], + t: float, +) -> float: + """Linear level along a breakpoint polyline (strip geometry, not smoothstep).""" + if not breakpoints: + return 0.0 + if t <= breakpoints[0][0]: + return float(breakpoints[0][1]) + if t >= breakpoints[-1][0]: + return float(breakpoints[-1][1]) + for index in range(len(breakpoints) - 1): + t0, v0 = breakpoints[index] + t1, v1 = breakpoints[index + 1] + # Half-open [t0, t1) so a hard step at t1 wins over the prior segment end. + if t >= t1: + continue + if t1 <= t0: + last = index + 1 + while ( + last + 1 < len(breakpoints) + and breakpoints[last + 1][0] <= t0 + ): + last += 1 + return float(breakpoints[last][1]) + u = (t - t0) / (t1 - t0) + return float(v0 + (v1 - v0) * u) + return float(breakpoints[-1][1]) + + +def clip_breakpoints( + breakpoints: list[tuple[float, float]], + start: float, + end: float, +) -> list[tuple[float, float]]: + """Clip a breakpoint polyline to ``[start, end]``, interpolating at the edges.""" + if end <= start or not breakpoints: + return [] + clipped: list[tuple[float, float]] = [ + (start, level_at_breakpoints(breakpoints, start)) + ] + for t, level in breakpoints: + if start < t < end: + clipped.append((float(t), float(level))) + end_level = level_at_breakpoints(breakpoints, end) + if clipped[-1][0] < end: + clipped.append((end, end_level)) + elif not levels_equal(clipped[-1][1], end_level): + clipped[-1] = (end, end_level) return clipped -def bar_segments_for_row( +def _span_breakpoints_for_lane( + state: TimelineViewState, + lane: TimelineLane, + *, + inherit: float, + duration_sec: float, +) -> list[tuple[float, float]]: + """Breakpoints covering ``[0, duration_sec]`` for strip drawing.""" + if duration_sec <= 0.0: + return [] + breakpoints = lane_level_breakpoints( + lane, + inherit=inherit, + song_marker_fades=state.song_marker_fades, + standard_fades=state.standard_cue_fades, + duration_sec=duration_sec, + song_marker_times=state.song_marker_times, + ) + if not breakpoints: + level = inherit if lane.baseline is None else float(lane.baseline) + return [(0.0, level), (duration_sec, level)] + spanned = list(breakpoints) + if spanned[0][0] > 0.0: + spanned.insert(0, (0.0, spanned[0][1])) + if spanned[-1][0] < duration_sec: + spanned.append((duration_sec, spanned[-1][1])) + return spanned + + +def _extend_breakpoints( + dest: list[tuple[float, float]], + more: list[tuple[float, float]], +) -> None: + for t, level in more: + if ( + dest + and dest[-1][0] == t + and levels_equal(dest[-1][1], level) + ): + continue + dest.append((t, level)) + + +def bar_level_breakpoints_for_row( state: TimelineViewState, slot: str, -) -> list[tuple[float, float, bool]]: - """Visibility segments for one timeline row, including live record preview.""" +) -> list[tuple[float, float]]: + """Level breakpoints for one timeline row, including live record preview.""" duration = state.duration_sec if duration <= 0: return [] inherit = _inherit_for_view(state, slot) lane = _lane_for_view(state, slot) if not (state.recording and slot in state.record_baseline): - return visibility_segments(lane, duration, inherit=inherit) + return _span_breakpoints_for_lane( + state, lane, inherit=inherit, duration_sec=duration + ) record_start = state.record_slot_start_sec.get(slot, state.record_start_sec) if record_start is None: @@ -177,29 +256,31 @@ def bar_segments_for_row( record_start = max(0.0, min(record_start, duration)) playhead = max(0.0, min(state.position_sec, duration)) - segments: list[tuple[float, float, bool]] = [] - committed = visibility_segments(lane, duration, inherit=inherit) + committed = _span_breakpoints_for_lane( + state, lane, inherit=inherit, duration_sec=duration + ) + result: list[tuple[float, float]] = [] if record_start > 0.0: - segments.extend(_clip_segments(committed, 0.0, record_start)) + _extend_breakpoints(result, clip_breakpoints(committed, 0.0, record_start)) effective_end = max(playhead, state.record_high_water_mark or 0.0) if effective_end > record_start: - segments.extend( - _clip_segments( - visibility_segments( - _recording_view_lane(state, slot), - effective_end, - inherit=True, - ), - record_start, - effective_end, - ) + live = _span_breakpoints_for_lane( + state, + _recording_view_lane(state, slot), + inherit=1.0, + duration_sec=effective_end, + ) + _extend_breakpoints( + result, clip_breakpoints(live, record_start, effective_end) ) if effective_end < duration: - segments.extend(_clip_segments(committed, effective_end, duration)) - return segments + _extend_breakpoints( + result, clip_breakpoints(committed, effective_end, duration) + ) + return result def bar_tick_times_for_row(state: TimelineViewState, slot: str) -> list[float]: @@ -227,100 +308,71 @@ def bar_tick_times_for_row(state: TimelineViewState, slot: str) -> list[float]: return sorted(set(committed_ticks) | set(live_ticks)) -def _clip_fade_spans( - spans: list[tuple[float, float, str]], - range_start: float, - range_end: float, -) -> list[tuple[float, float, str]]: - clipped: list[tuple[float, float, str]] = [] - for t0, t1, kind in spans: - clip_start = max(t0, range_start) - clip_end = min(t1, range_end) - if clip_end > clip_start: - clipped.append((clip_start, clip_end, kind)) - return clipped - - -def _fades_active(state: TimelineViewState) -> bool: - return state.song_marker_fades.enabled or state.standard_cue_fades.enabled - - -def _fade_spans_for_lane( - state: TimelineViewState, - lane: TimelineLane, - slot: str, -) -> list[tuple[float, float, str]]: - return lane_fade_spans( - lane, - inherit=_inherit_for_view(state, slot), - song_marker_fades=state.song_marker_fades, - standard_fades=state.standard_cue_fades, - duration_sec=state.duration_sec, - song_marker_times=state.song_marker_times, +def _lerp_rgb( + a: tuple[int, int, int], + b: tuple[int, int, int], + t: float, +) -> tuple[int, int, int]: + u = max(0.0, min(1.0, float(t))) + return ( + int(round(a[0] + (b[0] - a[0]) * u)), + int(round(a[1] + (b[1] - a[1]) * u)), + int(round(a[2] + (b[2] - a[2]) * u)), ) -def bar_fade_spans_for_row( - state: TimelineViewState, - slot: str, -) -> list[tuple[float, float, str]]: - """Fade wedge spans for one timeline row, including live record preview.""" - duration = state.duration_sec - if duration <= 0 or not _fades_active(state): - return [] - lane = _lane_for_view(state, slot) - if not (state.recording and slot in state.record_baseline): - return _fade_spans_for_lane(state, lane, slot) +def bar_level_y(bar_rect: pygame.Rect, level: float) -> int: + """Y of the top of a bottom-filled level within ``bar_rect``.""" + filled = max(0.0, min(1.0, float(level))) * bar_rect.h + return bar_rect.bottom - int(round(filled)) - record_start = state.record_slot_start_sec.get(slot, state.record_start_sec) - if record_start is None: - record_start = state.position_sec - record_start = max(0.0, min(record_start, duration)) - playhead = max(0.0, min(state.position_sec, duration)) - effective_end = max(playhead, state.record_high_water_mark or 0.0) - - spans: list[tuple[float, float, str]] = [] - committed_spans = _fade_spans_for_lane(state, lane, slot) - if record_start > 0.0: - spans.extend(_clip_fade_spans(committed_spans, 0.0, record_start)) - if effective_end > record_start: - live_spans = lane_fade_spans( - _recording_view_lane(state, slot), - inherit=True, - song_marker_fades=state.song_marker_fades, - standard_fades=state.standard_cue_fades, - duration_sec=state.duration_sec, - song_marker_times=state.song_marker_times, - ) - spans.extend(_clip_fade_spans(live_spans, record_start, effective_end)) - if effective_end < duration: - spans.extend(_clip_fade_spans(committed_spans, effective_end, duration)) - return spans - -def _draw_fade_wedge( +def _draw_level_bar( panel: pygame.Surface, *, - t0: float, - t1: float, - kind: str, + breakpoints: list[tuple[float, float]], bar_left: int, bar_width: int, duration_sec: float, bar_rect: pygame.Rect, - color: tuple[int, int, int], ) -> None: - x0 = time_to_x(t0, bar_left, bar_width, duration_sec) - x1 = time_to_x(t1, bar_left, bar_width, duration_sec) - if x1 <= x0: + pygame.draw.rect(panel, OFF_SEGMENT_COLOR, bar_rect) + if duration_sec <= 0.0 or len(breakpoints) < 2: return - top = bar_rect.y - bottom = bar_rect.bottom - 1 - if kind == "in": - points = [(x0, bottom), (x1, bottom), (x1, top)] - else: - points = [(x0, top), (x0, bottom), (x1, bottom)] - pygame.draw.polygon(panel, color, points) + bottom = bar_rect.bottom + for index in range(len(breakpoints) - 1): + t0, level0 = breakpoints[index] + t1, level1 = breakpoints[index + 1] + if t1 <= t0: + continue + x0 = time_to_x(t0, bar_left, bar_width, duration_sec) + x1 = time_to_x(t1, bar_left, bar_width, duration_sec) + if x1 <= x0: + continue + if level0 <= LEVEL_EPS and level1 <= LEVEL_EPS: + continue + y0 = bar_level_y(bar_rect, level0) + y1 = bar_level_y(bar_rect, level1) + mean_level = 0.5 * (level0 + level1) + color = _lerp_rgb(_BAR_LEVEL_DIM, TIMELINE_BAR_ON, mean_level) + pygame.draw.polygon( + panel, + color, + [(x0, y0), (x1, y1), (x1, bottom), (x0, bottom)], + ) + + +def _render_committed_level_icon(level: float, *, line_height: int) -> pygame.Surface: + """Far-right committed eye: full at 1.0, dimmed when partial, DISABLED at 0.""" + if level <= LEVEL_EPS: + return render_visibility_icon(enabled=False, line_height=line_height) + icon = render_visibility_icon(enabled=True, line_height=line_height) + if level >= 1.0 - LEVEL_EPS: + return icon + faded = icon.copy() + alpha = max(1, min(255, int(round(level * 255)))) + faded.fill((255, 255, 255, alpha), special_flags=pygame.BLEND_RGBA_MULT) + return faded def rec_flash_visible(ticks_ms: int | None = None) -> bool: @@ -685,7 +737,7 @@ def _build_static_panel( panel.blit(abbrev_surf, (stem_abbrev_x, abbrev_y)) monitor_enabled = state.monitor_visible.get(slot, True) - timeline_enabled = state.timeline_visible.get(slot, True) + timeline_level = float(state.timeline_level.get(slot, 1.0)) monitor_override = ( slot in state.override_slots and not (state.recording and armed) @@ -697,10 +749,8 @@ def _build_static_panel( line_height=row_h, ) panel.blit(monitor_icon, (monitor_eye_x, row_y)) - timeline_icon = render_visibility_icon( - enabled=timeline_enabled, - solo=False, - line_height=row_h, + timeline_icon = _render_committed_level_icon( + timeline_level, line_height=row_h ) panel.blit(timeline_icon, (timeline_eye_x, row_y)) @@ -708,27 +758,14 @@ def _build_static_panel( if focused: blit_tint(panel, bar_column_rect, HIGHLIGHT) - for start_t, end_t, visible in bar_segments_for_row(state, slot): - x0 = time_to_x(start_t, bar_left, bar_width, state.duration_sec) - x1 = time_to_x(end_t, bar_left, bar_width, state.duration_sec) - if x1 <= x0: - continue - color = TIMELINE_BAR_ON if visible else OFF_SEGMENT_COLOR - seg_rect = pygame.Rect(x0, bar_rect.y, max(1, x1 - x0), bar_rect.h) - pygame.draw.rect(panel, color, seg_rect) - - for t0, t1, kind in bar_fade_spans_for_row(state, slot): - _draw_fade_wedge( - panel, - t0=t0, - t1=t1, - kind=kind, - bar_left=bar_left, - bar_width=bar_width, - duration_sec=state.duration_sec, - bar_rect=bar_rect, - color=TIMELINE_BAR_ON, - ) + _draw_level_bar( + panel, + breakpoints=bar_level_breakpoints_for_row(state, slot), + bar_left=bar_left, + bar_width=bar_width, + duration_sec=state.duration_sec, + bar_rect=bar_rect, + ) for cue_t in bar_tick_times_for_row(state, slot): tick_x = time_to_x(cue_t, bar_left, bar_width, state.duration_sec) diff --git a/cleave/viz/timeline_panel_cache.py b/cleave/viz/timeline_panel_cache.py index 6d18d02..b6d6629 100644 --- a/cleave/viz/timeline_panel_cache.py +++ b/cleave/viz/timeline_panel_cache.py @@ -22,17 +22,17 @@ class TimelineStaticSignature: """Everything affecting strip chrome except live transport/playhead. While recording, ``record_playhead_sec`` is included because armed-row bars - grow with the playhead (see ``bar_segments_for_row``). Outside recording the - playhead is live-patched only. + grow with the playhead (see ``bar_level_breakpoints_for_row``). Outside + recording the playhead is live-patched only. """ layer_z_order: tuple[str, ...] - lanes_fingerprint: tuple[tuple[str, bool | None, tuple[tuple[float, bool], ...]], ...] - defaults: tuple[tuple[str, bool], ...] + lanes_fingerprint: tuple[tuple[str, float | None, tuple[tuple[float, float], ...]], ...] + defaults: tuple[tuple[str, float], ...] duration_sec: float focus_row: int monitor_visible: tuple[tuple[str, bool], ...] - timeline_visible: tuple[tuple[str, bool], ...] + timeline_level: tuple[tuple[str, float], ...] slot_stems: tuple[tuple[str, str], ...] override_slots: frozenset[str] armed_slots: frozenset[str] @@ -40,9 +40,9 @@ class TimelineStaticSignature: submenu_focused: bool record_start_sec: float | None record_slot_start_sec: tuple[tuple[str, float], ...] - record_baseline: tuple[tuple[str, bool], ...] + record_baseline: tuple[tuple[str, float], ...] record_buffer_fingerprint: tuple[ - tuple[str, tuple[tuple[float, bool], ...]], ... + tuple[str, tuple[tuple[float, float], ...]], ... ] record_high_water_mark: float | None record_playhead_sec: float | None @@ -81,19 +81,19 @@ def visibility_bucket(visibility: float) -> int: def _slot_cues_fingerprint( cues: list[SlotCue], -) -> tuple[tuple[float, bool], ...]: - return tuple((cue.t, cue.visible) for cue in cues) +) -> tuple[tuple[float, float], ...]: + return tuple((cue.t, cue.level) for cue in cues) def _lane_fingerprint( lane: TimelineLane, -) -> tuple[bool | None, tuple[tuple[float, bool], ...]]: +) -> tuple[float | None, tuple[tuple[float, float], ...]]: return (lane.baseline, _slot_cues_fingerprint(lane.cues)) def _lanes_fingerprint( lanes: dict[str, TimelineLane], -) -> tuple[tuple[str, bool | None, tuple[tuple[float, bool], ...]], ...]: +) -> tuple[tuple[str, float | None, tuple[tuple[float, float], ...]], ...]: return tuple( (slot, *_lane_fingerprint(lane)) for slot, lane in sorted(lanes.items()) @@ -102,7 +102,7 @@ def _lanes_fingerprint( def _record_buffer_fingerprint( record_buffer: dict[str, list[SlotCue]], -) -> tuple[tuple[str, tuple[tuple[float, bool], ...]], ...]: +) -> tuple[tuple[str, tuple[tuple[float, float], ...]], ...]: return tuple( (slot, _slot_cues_fingerprint(cues)) for slot, cues in sorted(record_buffer.items()) @@ -123,7 +123,7 @@ def timeline_static_signature( duration_sec=state.duration_sec, focus_row=state.focus_row, monitor_visible=tuple(sorted(state.monitor_visible.items())), - timeline_visible=tuple(sorted(state.timeline_visible.items())), + timeline_level=tuple(sorted(state.timeline_level.items())), slot_stems=tuple(sorted((k, str(v)) for k, v in state.slot_stems.items())), override_slots=frozenset(state.override_slots), armed_slots=frozenset(state.armed_slots), diff --git a/cleave/viz/timeline_preset_controls.py b/cleave/viz/timeline_preset_controls.py index b1b2c4e..172bb1e 100644 --- a/cleave/viz/timeline_preset_controls.py +++ b/cleave/viz/timeline_preset_controls.py @@ -156,7 +156,7 @@ def _reset(self, *, all_on: bool) -> None: tl = self.session.timeline tl.enabled = True tl.lanes = { - slot: TimelineLane(baseline=all_on, cues=[]) + slot: TimelineLane(baseline=1.0 if all_on else 0.0, cues=[]) for slot in self.session.layer_z_order } message = ( diff --git a/docs/improved-timeline-presets.md b/docs/improved-timeline-presets.md new file mode 100644 index 0000000..90f2529 --- /dev/null +++ b/docs/improved-timeline-presets.md @@ -0,0 +1,237 @@ +# Improved timeline presets + +Aspirational directions for generative timeline authoring. Goal: visuals that feel tied to the song, avoid stacking too many busy presets, keep multi-layer compositing as the core of Cleave, and need almost no user input beyond Apply. + +Related: [completed/song-markers.md](completed/song-markers.md), [roadmap.md](roadmap.md), [cleave/timeline_presets/](../cleave/timeline_presets/), [cleave/signals.py](../cleave/signals.py). + +Naming: **cue** remains a per-lane transition. **Song markers** remain project-scoped structure points in `project.yaml`. + +--- + +## Current baseline + +What works today: + +- Generative characters (Breathing, Dialogue, Arc, Pulse) in [cleave/timeline_presets/](../cleave/timeline_presets/) arrange layer levels with phrase grids, motif voice-leading, density bias, and optional crescendo. +- Beat This! downbeats in `signals.json` drive the bar grid; manual song markers act as hard section walls and soft latch (~5s) at generation time. +- Each layer is its own projectM instance fed stem PCM; black-key (and other) blends stack them in [cleave/gl_compositor.py](../cleave/gl_compositor.py). +- Cue levels drive continuous opacity: `lane_level_breakpoints` / `lane_level_envelope` in [cleave/timeline.py](../cleave/timeline.py) feed `layer.timeline_level` via `apply_layer_visibility` in [cleave/viz/layer_visibility.py](../cleave/viz/layer_visibility.py). The strip draws the same breakpoints as variable-height bars. +- `preset_switching: timeline` advances a seek-stable rotation on each rise from zero; the milk file is not chosen per cue. +- Per-stem envelopes in `signals.json` drive live Cleave effects ([cleave/effects/](../cleave/effects/)), not timeline generation. + +Gaps the ideas below target: + +1. Cues carry a level but not yet blend mode or preset role, so milk personality and per-cue casting remain out of reach of the arranger. +2. The arranger is musically phrased but not stem-content-aware: it never looks at what the drums or vocals are doing. +3. Song form still needs manual markers for the best results. +4. Busy collisions often come from milk personality, which level-only cues cannot fix alone. +5. Nothing recurs. Chorus 2 gets a fresh roll of the dice, so the output reads as plausible-but-random rather than composed. + +--- + +## Idea 1: Arrangement as a mix, not a gate (shipped: levels) + +**Shipped:** cues are level keyframes (`SlotCue(t, level)`), fade groups become constant-slope ramps on a piecewise-linear envelope, the strip draws variable-height bars, and crescendo ramps entrants in at `0.5` before a full stack. Layer opacity stays the static fader; the lane multiplies into `fbo.opacity` as before. + +**Deferred** (still part of the mix vision, not implemented): + +- Per-cue blend mode writing `LayerFbo.blend_mode` ([cleave/blend_modes.py](../cleave/blend_modes.py)). +- Per-cue preset role for content-aware casting (Idea 3). +- Manual level authoring in the strip (record toggles still write `0.0` / `1.0`). + +### Intent + +- Let the arranger duck a layer instead of muting it. A chorus becomes one lead at full weight and a bed at half, rather than "four layers on". +- Turn the anti-busy rule into a mix rule (total visual weight budget) instead of a layer count. +- Reuse compositing capability the renderer already has but the timeline could not address with booleans alone. + +### What landed + +1. Cue model and persist: `level` required in YAML; baseline is `float | None` ([cleave/config_schema.py](../cleave/config_schema.py)). +2. Envelope: `lane_level_breakpoints` / `lane_level_envelope` replace edge-only fade alpha; rise completes at cue time, fall starts at cue time; slopes scale with level delta. +3. Runtime: `timeline_level` / `timeline_level_multiplier`; levels apply even when both fade groups are disabled (piecewise-constant envelope). +4. Preset-switch trigger: rise from zero only; `cue.t - fade_in * cue.level`. +5. Generator: `cues_from_states` emits level mappings; crescendo uses `0.5` entrants. +6. Strip: polygon fill from breakpoints; committed eye alpha follows level. + +Overlaps [roadmap.md](roadmap.md) richer cue types; blend and role are the remaining slice. + +### User effort + +None for 0/1 lanes. Partial levels come from generative Apply (crescendo) until manual authoring lands. + +--- + +## Idea 2: Stem conductor + +Treat each layer as a player that may only enter when its stem has something to say. Visibility becomes a consequence of audio, not only of motif vocabulary. + +### Intent + +- Tie lane activity to the Demucs split the user already paid for. +- Cap how much of the stack is hot at once so the composite stays readable. +- Keep Breathing / Dialogue / Arc / Pulse as feel knobs (switch rate, climax shape) while the conductor supplies who speaks and how loudly. + +### Design sketch + +1. From full-mix energy and onset novelty, build a **target weight curve** over time (sparse verse near one layer of weight, chorus two to three, drop to zero then punch back in). +2. **Gate and weight each slot** by its assigned stem using the `signals.json` envelopes that already exist at 100 Hz: + +| Stem | Fields | Reads as | +| --- | --- | --- | +| drums | `onset_strength` | onset density, fills | +| bass | `rms`, `sub_bass`, `mid_bass` | foundation presence | +| vocals | `rms`, `pitch_hz` | lead activity, phrase ends | +| other | `spectral_centroid` | color and brightness | +| full_mix | `onset_strength`, `rms` | global energy, section contrast | + +3. Apply **anti-busy rules** while filling the budget: prefer one bright accent over several; solo when one stem dominates; duck competing hot layers instead of cutting them (needs Idea 1 levels, which are available). +4. Emit ordinary lane cues. Phrase grid and song-marker walls still bound switch times. + +### Fits existing code + +- Arranger entry: [cleave/timeline_presets/arrange.py](../cleave/timeline_presets/arrange.py) `compose_timeline`, which already computes a per-phrase `budget` from the character envelope. The conductor replaces that scalar with an audio-derived curve and adds per-slot eligibility to motif scoring. +- Signal load: [cleave/signals.py](../cleave/signals.py); extract path: [cleave/extract.py](../cleave/extract.py). No new analyse output required. + +### Libraries + +Current stack; librosa envelopes are already written at analyse time. Optional later: [madmom](https://github.com/CPJKU/madmom) for cleaner onsets. + +### User effort + +Run `separate` (already required for beats), then Apply. No new markers. + +--- + +## Idea 3: Preset reactivity fingerprints + +Classify presets by how they **respond** to audio, not only by how they look, then cast presets to the stem driving that slot. A preset that erupts on kick drums is a great drums layer and a dead bass bed; a static luma histogram cannot tell those apart. + +### Intent + +- Stop three dense presets from lighting at once under black-key (or any) blend. +- Make `preset_switching: timeline` content-aware instead of a shuffled bag. +- Know which presets can sit at low opacity as a bed without looking inert (needs a motion floor, not a brightness average). +- Reuse curation (`favourites/`) and the existing scan harness rather than asking users to tag files. + +### Design sketch + +The probe harness is most of the way there already. [cleave/preset_scan.py](../cleave/preset_scan.py) boots headless projectM into a 480x270 FBO, feeds PCM per frame, and reads back per-frame metrics with a warmup window and a metrics cache. + +1. Replace the single constant probe (`_synthetic_pcm_burst`, a sine plus noise that is never silent) with a **probe set**: silence baseline, sub-bass sine, kick impulse train at fixed tempo, hi-hat noise bursts, sustained vocal-band tone. +2. Extend `FrameMetrics` in [cleave/preset_scan_metrics.py](../cleave/preset_scan_metrics.py) beyond luma and coverage with temporal frame delta and an edge or high-frequency energy proxy. Bump the metrics cache version. +3. Reduce to a per-preset vector: response to bass, response to transients, motion floor under silence, brightness, screen coverage, busyness. +4. Derive pools from that vector, favourites first, then directory: + +| Role | Vector signature | Casting rule | +| --- | --- | --- | +| Bed | low busyness, non-zero motion floor, mostly black | bass or other foundation slots | +| Pulse | high transient response, mid coverage | drums slots | +| Lead | high busyness or brightness | at most one hot at a time | +| Accent | short bright bursts, high delta | chorus hits, marker edges | + +5. Cast on each on-transition in [cleave/viz/preset_switching.py](../cleave/viz/preset_switching.py), or explicitly through a role carried on a rich cue (deferred Idea 1 work). Keep play and scan in agreement on the rotation set (see [.cursor/rules/preset-scan-rotation-set.mdc](../.cursor/rules/preset-scan-rotation-set.mdc)). + +### Fits existing code + +- Scan CLI, golden set, and threshold tuning: [completed/presets-check-proposal.md](completed/presets-check-proposal.md), [completed/presets-scan-plan.md](completed/presets-scan-plan.md). +- Timeline rotation advance already keys off committed on-transitions. + +### Libraries + +Heuristic frame stats inside the existing harness should be enough. Optional: CLIP or similar embeddings on short clips if heuristics mislabel calm versus chaotic too often. + +### User effort + +Curate favourites once, run the scan once per pack (cached). Casting is automatic on Apply and playback. + +--- + +## Idea 4: Reprise, and form from self-similarity + +Detect song form automatically, and when the song repeats, bring back the **same** arrangement: same cast, same milk files, same blend plan, with one thing escalated. Recognition is what makes a video read as composed; random-but-musical still reads as random. + +### Intent + +- Close the structure gap without asking the user to drop every marker by hand. +- Give section-scale drama (breakdown solo, chorus lift) that bar-only partitioning cannot invent. +- Make the visuals have motifs at song scale, not only at phrase scale. + +### Design sketch + +1. At analyse time in [cleave/extract.py](../cleave/extract.py) and [cleave/analyse.py](../cleave/analyse.py), compute a beat-synchronous self-similarity matrix from chroma plus MFCC (librosa, no new heavy dependency), and write segment boundaries plus similarity cluster ids into `signals.json` (schema version bump). +2. Boundaries become **suggested song markers**, shown as today's red ticks and saved to `project.yaml` under the same deferred-write model as [completed/song-markers.md](completed/song-markers.md). The user corrects a few if needed. +3. `compose_timeline` generates one arrangement **per cluster**, not per section, and replays it at each recurrence with a deterministic variation seeded by occurrence index: add a layer, raise the lead, tighten switch rate. +4. Non-repeating material (bridge, breakdown) is identifiable precisely because it is unlike everything else in the matrix, so it gets the contrast treatment: solo one stem, or cut to black and re-enter on the next bar. +5. Section roles bias the conductor curve rather than replacing it: + +| Section | Visual role | +| --- | --- | +| Intro / outro | single calm foundation layer | +| Verse | two layers, slow switches | +| Pre-chorus | rising weight toward the next wall | +| Chorus | peak weight, still under the busyness cap | +| Bridge / breakdown | solo one stem, others ducked | +| Drop | hard cut to black, re-entry on the next bar | + +6. Hard walls, soft latch, and exclusive marker claiming stay as today; auto markers simply feed the same walls. + +### Libraries + +librosa first (chroma, MFCC, `segment.recurrence_matrix`, `agglomerative`). Escalate only if that misfires: [all-in-one](https://github.com/mir-aidj/all-in-one) (structure plus beats), [msaf](https://github.com/urinieto/msaf), or Essentia. Prefer one analyse-time dependency writing into the existing marker list rather than a parallel timeline format. + +### User effort + +Run `separate`, glance at suggested markers, Apply. Manual drop remains for stubborn tracks. + +--- + +## Companion: closed-loop visual limiter + +Small, cheap, and catches collisions no offline classifier predicted. The post-FX stack in [cleave/gl_post_process.py](../cleave/gl_post_process.py) already resolves the composited frame, so a coarse mean-luma and frame-delta reduction per frame costs almost nothing. When the stack exceeds a busyness threshold, duck the lowest-priority hot layer; release when it drops. A sidechain compressor for the composite. + +Depends on Idea 1 levels (available) and on offline determinism: the limiter must be a pure function of frame content so live preview and offline render in [cleave/viz/render.py](../cleave/viz/render.py) agree. + +--- + +## Combined pipeline + +Strongest end state with almost no UX surface: + +1. **Form** proposes markers and similarity clusters at analyse time. +2. **Stem conductor** builds a visual weight budget and per-stem gates inside those sections. +3. **Rich cues** express the result as a mix: levels and blends, not just on and off. +4. **Role casting** fills each active slot from a reactivity-matched pool so only one Lead is hot. +5. **Reprise** replays each cluster's arrangement with escalation on recurrence. +6. **Limiter** cleans up whatever still collides. + +Breathing / Dialogue / Arc / Pulse remain character profiles that bias switch rate, climax placement, and density, not the only intelligence in the system. + +--- + +## Suggested implementation order + +| Order | Item | Why here | Payoff | Effort | +| --- | --- | --- | --- | --- | +| 1 | Rich cues: levels (Idea 1, shipped) | Enabler; everything else wants levels, not booleans | Better output immediately, even with today's arranger | Done | +| 1b | Rich cues: blend and role (deferred) | Completes the mix vision; casting needs role | Medium | Medium (cue fields, strip, persist, record) | +| 2 | Stem conductor (Idea 2) | Uses data already in `signals.json`; first thing that makes lanes song-tied | High | Medium (arranger plus signals) | +| 3 | Closed-loop limiter (companion) | Cheap once levels exist; buys headroom before casting lands | Medium | Low | +| 4 | Reactivity fingerprints and casting (Idea 3) | Probe harness exists; fixes busy-on-busy at the milk level | High | High (probe set, metrics, pools, casting) | +| 5 | Reprise and auto form (Idea 4) | Highest ceiling, most analysis risk; benefits from 1 to 4 being in place | High | High (analyse dependency, marker suggestions, arranger restructure) | + +Sequencing notes: + +- Levels (1) and stem conductor (2) are independently shippable and together cover most of the "feels tied to the song" goal. +- Step 4 can ship in halves: fingerprints and a scan report first, casting second. +- Step 5 can ship in halves: suggested markers first (useful alone), cluster reprise second. + +--- + +## Non-goals + +- Undo/redo (tracked in [roadmap.md](roadmap.md)). +- MIDI out, web or Butterchurn port, live sliding-window Demucs. +- Six-stem Demucs as a prerequisite; named guitar and piano voices would strengthen the conductor but are not required by any step above. +- Replacing manual timeline edit, arm and record, or snap tools. Generative Apply remains a starting point the user can polish. +- Per-cue arbitrary parameter automation (effect depths, post-FX). Idea 1's remaining slice is blend and preset role only. diff --git a/docs/todos.md b/docs/todos.md index f810991..d00312a 100644 --- a/docs/todos.md +++ b/docs/todos.md @@ -13,6 +13,16 @@ Outstanding bugs and issues. --- +## Timeline follow-ups + +Rich cue levels are shipped (see [improved-timeline-presets.md](improved-timeline-presets.md) Idea 1). Remaining mix-cue work: + +- **Per-cue blend mode.** Carry an optional blend on `SlotCue` and write `LayerFbo.blend_mode` from the active cue so the arranger can switch compositing per transition ([cleave/blend_modes.py](../cleave/blend_modes.py)). +- **Per-cue preset role.** Optional role on the cue for content-aware casting once reactivity fingerprints exist (Idea 3). +- **Manual level authoring.** Strip and record path still toggle `0.0` / `1.0`; add a way to set partial levels by hand without Apply. + +--- + ## Architecture diff --git a/tests/cleave/test_config.py b/tests/cleave/test_config.py index 04b4214..3ff7461 100644 --- a/tests/cleave/test_config.py +++ b/tests/cleave/test_config.py @@ -1090,7 +1090,7 @@ def test_parse_timeline_reads_fades() -> None: ) -def test_persist_timeline_fades_round_trip() -> None: +def test_persist_timeline_levels_round_trip() -> None: from cleave.viz.session import TimelineFadeGroupRuntime, TimelineRuntime session = TuningSession( @@ -1287,12 +1287,12 @@ def test_parse_timeline_reads_lanes() -> None: "enabled": True, "lanes": { "layer_1": { - "baseline": True, - "cues": [{"t": 10.0, "visible": False}], + "baseline": 1.0, + "cues": [{"t": 10.0, "level": 0.0}], }, "layer_2": { - "baseline": False, - "cues": [{"t": 2.5, "visible": True}], + "baseline": 0.0, + "cues": [{"t": 2.5, "level": 1.0}], }, }, } @@ -1302,15 +1302,67 @@ def test_parse_timeline_reads_lanes() -> None: assert timeline is not None assert timeline.enabled is True assert timeline.lanes["layer_1"] == TimelineLane( - baseline=True, - cues=[SlotCue(t=10.0, visible=False)], + baseline=1.0, + cues=[SlotCue(t=10.0, level=0.0)], ) assert timeline.lanes["layer_2"] == TimelineLane( - baseline=False, - cues=[SlotCue(t=2.5, visible=True)], + baseline=0.0, + cues=[SlotCue(t=2.5, level=1.0)], ) +def test_persist_timeline_cue_levels_round_trip() -> None: + from cleave.viz.session import TimelineRuntime + + session = TuningSession( + layer_z_order=list(DEFAULT_LAYER_SLOTS), + timeline=TimelineRuntime( + enabled=True, + lanes={ + "layer_1": TimelineLane( + baseline=1.0, + cues=[SlotCue(t=4.0, level=0.5), SlotCue(t=8.0, level=0.0)], + ), + }, + ), + ) + cfg = CleaveConfig( + paths=PathsConfig(preset_root=Path("/tmp"), texture_paths=()), + layers={}, + editor=EditorConfig(), + config_path=Path("/tmp/cleave-viz.yaml"), + user_config_path=Path("/tmp/user.yaml"), + layer_z_order=list(DEFAULT_LAYER_SLOTS), + ) + payload = persist_timeline(PersistCtx(cfg=cfg, session=session, cfg_dir=None)) + assert payload["lanes"]["layer_1"] == { + "baseline": 1.0, + "cues": [{"t": 4.0, "level": 0.5}, {"t": 8.0, "level": 0.0}], + } + round_trip = parse_timeline_section( + {"timeline": payload}, + _timeline_parse_ctx(), + ) + assert round_trip is not None + assert round_trip.lanes["layer_1"] == session.timeline.lanes["layer_1"] + + +def test_parse_timeline_rejects_cue_missing_level() -> None: + with pytest.raises(ValueError, match="missing level"): + parse_timeline_section( + { + "timeline": { + "lanes": { + "layer_1": { + "cues": [{"t": 1.0}], + } + } + } + }, + _timeline_parse_ctx(), + ) + + def test_parse_timeline_ignores_legacy_cues() -> None: timeline = parse_timeline_section( { @@ -1331,7 +1383,7 @@ def test_parse_timeline_rejects_unknown_lane_slot() -> None: { "timeline": { "lanes": { - "layer_9": {"baseline": True, "cues": []}, + "layer_9": {"baseline": 1.0, "cues": []}, } } }, @@ -1346,7 +1398,7 @@ def test_parse_timeline_rejects_negative_t() -> None: "timeline": { "lanes": { "layer_1": { - "cues": [{"t": -1.0, "visible": False}], + "cues": [{"t": -1.0, "level": 0.0}], } } } @@ -1362,8 +1414,8 @@ def test_load_config_reads_timeline(minimal_project: Path) -> None: "enabled": True, "lanes": { "layer_3": { - "baseline": True, - "cues": [{"t": 3.0, "visible": False}], + "baseline": 1.0, + "cues": [{"t": 3.0, "level": 0.0}], } }, } @@ -1374,8 +1426,8 @@ def test_load_config_reads_timeline(minimal_project: Path) -> None: assert cfg.timeline is not None assert cfg.timeline.enabled is True assert cfg.timeline.lanes["layer_3"] == TimelineLane( - baseline=True, - cues=[SlotCue(t=3.0, visible=False)], + baseline=1.0, + cues=[SlotCue(t=3.0, level=0.0)], ) @@ -1423,7 +1475,7 @@ def test_parse_timeline_rejects_layer_not_in_config(tmp_path: Path) -> None: data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) data["timeline"] = { "lanes": { - "layer_3": {"baseline": True, "cues": [{"t": 1.0, "visible": True}]}, + "layer_3": {"baseline": 1.0, "cues": [{"t": 1.0, "level": 1.0}]}, } } with cfg_path.open("w", encoding="utf-8") as handle: diff --git a/tests/cleave/test_config_snapshot.py b/tests/cleave/test_config_snapshot.py index e21c972..cff60be 100644 --- a/tests/cleave/test_config_snapshot.py +++ b/tests/cleave/test_config_snapshot.py @@ -948,16 +948,16 @@ def test_write_session_snapshot_persists_timeline_at_bottom(tmp_path: Path) -> N session.timeline.enabled = True session.timeline.lanes = { "layer_1": TimelineLane( - baseline=False, - cues=[SlotCue(t=2.5, visible=True)], + baseline=0.0, + cues=[SlotCue(t=2.5, level=1.0)], ), "layer_2": TimelineLane( - baseline=False, - cues=[SlotCue(t=2.5, visible=True)], + baseline=0.0, + cues=[SlotCue(t=2.5, level=1.0)], ), "layer_3": TimelineLane( - baseline=True, - cues=[SlotCue(t=10.0, visible=False)], + baseline=1.0, + cues=[SlotCue(t=10.0, level=0.0)], ), } write_session_snapshot(out_path, cfg=cfg, session=session) @@ -967,9 +967,9 @@ def test_write_session_snapshot_persists_timeline_at_bottom(tmp_path: Path) -> N assert list(data.keys())[-1] == "timeline" assert data["timeline"]["enabled"] is True assert data["timeline"]["lanes"] == { - "layer_1": {"baseline": False, "cues": [{"t": 2.5, "visible": True}]}, - "layer_2": {"baseline": False, "cues": [{"t": 2.5, "visible": True}]}, - "layer_3": {"baseline": True, "cues": [{"t": 10.0, "visible": False}]}, + "layer_1": {"baseline": 0.0, "cues": [{"t": 2.5, "level": 1.0}]}, + "layer_2": {"baseline": 0.0, "cues": [{"t": 2.5, "level": 1.0}]}, + "layer_3": {"baseline": 1.0, "cues": [{"t": 10.0, "level": 0.0}]}, } timeline = parse_timeline_section( @@ -999,8 +999,8 @@ def test_write_session_snapshot_round_trips_lane_baseline_and_cues(tmp_path: Pat session.timeline.enabled = True session.timeline.lanes = { "layer_1": TimelineLane( - baseline=True, - cues=[SlotCue(t=12.0, visible=False)], + baseline=1.0, + cues=[SlotCue(t=12.0, level=0.0)], ), } write_session_snapshot(out_path, cfg=cfg, session=session) @@ -1008,8 +1008,8 @@ def test_write_session_snapshot_round_trips_lane_baseline_and_cues(tmp_path: Pat data = yaml.safe_load(out_path.read_text(encoding="utf-8")) assert data["timeline"]["lanes"] == { "layer_1": { - "baseline": True, - "cues": [{"t": 12.0, "visible": False}], + "baseline": 1.0, + "cues": [{"t": 12.0, "level": 0.0}], }, } @@ -1209,12 +1209,12 @@ def test_session_snapshot_full_round_trip(tmp_path: Path) -> None: "enabled": True, "lanes": { "layer_1": { - "baseline": False, - "cues": [{"t": 1.0, "visible": False}], + "baseline": 0.0, + "cues": [{"t": 1.0, "level": 0.0}], }, "layer_2": { - "baseline": False, - "cues": [{"t": 1.0, "visible": True}], + "baseline": 0.0, + "cues": [{"t": 1.0, "level": 1.0}], }, }, }, @@ -1242,20 +1242,20 @@ def test_session_snapshot_full_round_trip(tmp_path: Path) -> None: session.render_post_fx.fade_out = 2.0 session.timeline.lanes = { "layer_1": TimelineLane( - baseline=False, - cues=[SlotCue(t=1.0, visible=True)], + baseline=0.0, + cues=[SlotCue(t=1.0, level=1.0)], ), "layer_2": TimelineLane( - baseline=False, - cues=[SlotCue(t=1.0, visible=True)], + baseline=0.0, + cues=[SlotCue(t=1.0, level=1.0)], ), "layer_3": TimelineLane( - baseline=True, - cues=[SlotCue(t=12.5, visible=False)], + baseline=1.0, + cues=[SlotCue(t=12.5, level=0.0)], ), "layer_4": TimelineLane( - baseline=True, - cues=[SlotCue(t=12.5, visible=False)], + baseline=1.0, + cues=[SlotCue(t=12.5, level=0.0)], ), } diff --git a/tests/cleave/test_timeline.py b/tests/cleave/test_timeline.py index 445747f..51ca5de 100644 --- a/tests/cleave/test_timeline.py +++ b/tests/cleave/test_timeline.py @@ -5,19 +5,20 @@ import pytest from cleave.config_schema import DEFAULT_LAYER_SLOTS +from cleave.easing import smoothstep from cleave.timeline import ( + LEVEL_EPS, RECORD_DEBOUNCE_SEC, SlotCue, - Timeline, TimelineFadeGroup, TimelineLane, canonicalize, empty_lane, - lane_fade_alpha, - lane_fade_spans, + lane_level_at, + lane_level_breakpoints, + lane_level_envelope, lane_on_transition_count, lane_on_transition_trigger_times, - lane_visible_at, punch_lane, set_lane_cue, should_accept_toggle, @@ -37,12 +38,45 @@ def _markers(*, fade_in: float = 2.0, fade_out: float = 2.0) -> TimelineFadeGrou return TimelineFadeGroup(enabled=True, fade_in=fade_in, fade_out=fade_out) +def _as_level(value: float | bool | None) -> float | None: + if value is None: + return None + if isinstance(value, bool): + return 1.0 if value else 0.0 + return float(value) + + def _lane( - baseline: bool | None, - *transitions: tuple[float, bool], + baseline: float | bool | None, + *transitions: tuple[float, float | bool], ) -> TimelineLane: - cues = [SlotCue(t=t, visible=v) for t, v in transitions] - return TimelineLane(baseline=baseline, cues=canonicalize(baseline, cues)) + base = _as_level(baseline) + cues = [ + SlotCue(t=t, level=float(_as_level(level))) + for t, level in transitions + ] + return TimelineLane(baseline=base, cues=canonicalize(base, cues)) + + +def _env( + lane: TimelineLane, + t_sec: float, + *, + inherit: float | bool, + song_marker_fades: TimelineFadeGroup, + standard_fades: TimelineFadeGroup, + duration_sec: float, + song_marker_times: tuple[float, ...] = (), +) -> float: + breakpoints = lane_level_breakpoints( + lane, + inherit=float(_as_level(inherit) or 0.0), + song_marker_fades=song_marker_fades, + standard_fades=standard_fades, + duration_sec=duration_sec, + song_marker_times=song_marker_times, + ) + return lane_level_envelope(t_sec, breakpoints) def test_stem_abbreviation_maps_known_stems() -> None: @@ -61,105 +95,85 @@ def test_stem_abbreviation_rejects_unknown_stem() -> None: stem_abbreviation("synth") # type: ignore[arg-type] -def test_lane_visible_at_uses_inherit_when_baseline_none() -> None: +def test_lane_level_at_uses_inherit_when_baseline_none() -> None: lane = empty_lane() - assert lane_visible_at(lane, 10.0, inherit=False) is False - assert lane_visible_at(lane, 10.0, inherit=True) is True + assert lane_level_at(lane, 10.0, inherit=0.0) == 0.0 + assert lane_level_at(lane, 10.0, inherit=1.0) == 1.0 + +def test_lane_level_at_uses_concrete_baseline() -> None: + lane = _lane(0.0) + assert lane_level_at(lane, 10.0, inherit=1.0) == 0.0 -def test_lane_visible_at_uses_concrete_baseline() -> None: - lane = _lane(False) - assert lane_visible_at(lane, 10.0, inherit=True) is False +def test_lane_level_at_applies_cues_up_to_t_sec() -> None: + lane = _lane(1.0, (5.0, 0.0), (10.0, 1.0), (15.0, 0.0)) + assert lane_level_at(lane, 4.9, inherit=1.0) == 1.0 + assert lane_level_at(lane, 5.0, inherit=1.0) == 0.0 + assert lane_level_at(lane, 12.0, inherit=1.0) == 1.0 + assert lane_level_at(lane, 14.9, inherit=1.0) == 1.0 + assert lane_level_at(lane, 20.0, inherit=1.0) == 0.0 -def test_lane_visible_at_applies_cues_up_to_t_sec() -> None: - lane = _lane(True, (5.0, False), (10.0, True), (15.0, False)) - assert lane_visible_at(lane, 4.9, inherit=True) is True - assert lane_visible_at(lane, 5.0, inherit=True) is False - assert lane_visible_at(lane, 12.0, inherit=True) is True - assert lane_visible_at(lane, 14.9, inherit=True) is True - assert lane_visible_at(lane, 20.0, inherit=True) is False + +def test_lane_level_at_across_slots() -> None: + lanes = { + "layer_1": _lane(0.0), + "layer_2": _lane(1.0, (1.0, 0.0)), + } + inherits = {slot: 1.0 for slot in DEFAULT_LAYER_SLOTS} + state = { + slot: lane_level_at( + lanes.get(slot) or empty_lane(), + 2.0, + inherit=inherits[slot], + ) + for slot in DEFAULT_LAYER_SLOTS + } + assert set(state) == set(DEFAULT_LAYER_SLOTS) + assert state["layer_1"] == 0.0 + assert state["layer_2"] == 0.0 + assert state["layer_3"] == 1.0 + assert state["layer_4"] == 1.0 def test_canonicalize_last_write_wins_at_equal_t() -> None: cues = canonicalize( - True, - [SlotCue(t=1.0, visible=False), SlotCue(t=1.0, visible=True)], + 1.0, + [SlotCue(t=1.0, level=0.0), SlotCue(t=1.0, level=1.0)], ) assert cues == [] def test_canonicalize_drops_redundant_transitions() -> None: cues = canonicalize( - False, - [SlotCue(t=1.0, visible=False), SlotCue(t=2.0, visible=True)], - ) - assert cues == [SlotCue(t=2.0, visible=True)] - - -def test_timeline_visible_state_at_returns_all_slots() -> None: - timeline = Timeline( - lanes={ - "layer_1": _lane(False), - "layer_2": _lane(True, (1.0, False)), - } - ) - inherits = {slot: True for slot in DEFAULT_LAYER_SLOTS} - state = timeline.visible_state_at(list(DEFAULT_LAYER_SLOTS), 2.0, inherits) - assert set(state) == set(DEFAULT_LAYER_SLOTS) - assert state["layer_1"] is False - assert state["layer_2"] is False - assert state["layer_3"] is True - assert state["layer_4"] is True - - -def test_timeline_visible_state_at_with_six_slots() -> None: - slots = [f"layer_{i}" for i in range(1, 7)] - inherits = {slot: True for slot in slots} - inherits["layer_3"] = False - timeline = Timeline( - lanes={ - "layer_3": empty_lane(), - "layer_4": _lane(True, (1.0, False)), - } + 0.0, + [SlotCue(t=1.0, level=0.0), SlotCue(t=2.0, level=1.0)], ) - state = timeline.visible_state_at(slots, 2.0, inherits) - assert set(state) == set(slots) - assert state["layer_3"] is False - assert state["layer_4"] is False - assert state["layer_1"] is True - assert state["layer_6"] is True + assert cues == [SlotCue(t=2.0, level=1.0)] def test_punch_lane_replaces_cues_in_range() -> None: - lane = _lane(True, (1.0, False), (5.0, True), (8.0, False), (12.0, True)) + lane = _lane(1.0, (1.0, 0.0), (5.0, 1.0), (8.0, 0.0), (12.0, 1.0)) result = punch_lane( lane, 4.0, 10.0, - [SlotCue(t=6.0, visible=False)], + [SlotCue(t=6.0, level=0.0)], ) - assert result.baseline is True + assert result.baseline == 1.0 assert result.cues == [ - SlotCue(t=1.0, visible=False), - SlotCue(t=12.0, visible=True), + SlotCue(t=1.0, level=0.0), + SlotCue(t=12.0, level=1.0), ] def test_punch_lane_isolation_leaves_other_lanes_untouched() -> None: - """Lane isolation: punching one track cannot rewrite another (by construction).""" lanes = { - "layer_1": _lane( - True, - (0.0, True), # redundant with baseline; dropped - (10.0, False), - ), - "layer_2": _lane(False, (10.0, True)), - "layer_3": _lane(True, (20.0, False)), - "layer_4": _lane(False), + "layer_1": _lane(1.0, (10.0, 0.0)), + "layer_2": _lane(0.0, (10.0, 1.0)), + "layer_3": _lane(1.0, (20.0, 0.0)), + "layer_4": _lane(0.0), } - # Fold baselines the way presets would: concrete baselines, transition cues. - lanes["layer_1"] = _lane(True, (10.0, False)) before_unarmed = { slot: TimelineLane(baseline=lane.baseline, cues=list(lane.cues)) for slot, lane in lanes.items() @@ -169,41 +183,40 @@ def test_punch_lane_isolation_leaves_other_lanes_untouched() -> None: lanes["layer_1"], 0.0, 15.0, - [SlotCue(t=0.0, visible=False), SlotCue(t=5.0, visible=True)], + [SlotCue(t=0.0, level=0.0), SlotCue(t=5.0, level=1.0)], ) for slot, expected in before_unarmed.items(): assert lanes[slot].baseline == expected.baseline assert lanes[slot].cues == expected.cues assert lanes["layer_1"].cues == [ - SlotCue(t=0.0, visible=False), - SlotCue(t=5.0, visible=True), + SlotCue(t=0.0, level=0.0), + SlotCue(t=5.0, level=1.0), ] def test_punch_lane_does_not_touch_sibling_lane_at_same_t() -> None: - """Collision at the same t stays per-lane; no shared cue / no_tick merge.""" - layer_2 = _lane(False, (12.0, True)) + layer_2 = _lane(0.0, (12.0, 1.0)) layer_1 = punch_lane( - _lane(False), + _lane(0.0), 5.0, 12.0, - [SlotCue(t=12.0, visible=True)], + [SlotCue(t=12.0, level=1.0)], ) - assert layer_2.cues == [SlotCue(t=12.0, visible=True)] - assert lane_visible_at(layer_2, 6.0, inherit=True) is False - assert layer_1.cues == [SlotCue(t=12.0, visible=True)] + assert layer_2.cues == [SlotCue(t=12.0, level=1.0)] + assert lane_level_at(layer_2, 6.0, inherit=1.0) == 0.0 + assert layer_1.cues == [SlotCue(t=12.0, level=1.0)] def test_strip_lane_range_removes_cues_in_range() -> None: - lane = _lane(True, (1.0, False), (5.0, True), (8.0, False)) + lane = _lane(1.0, (1.0, 0.0), (5.0, 1.0), (8.0, 0.0)) result = strip_lane_range(lane, 4.0, 6.0) - assert result.cues == [SlotCue(t=1.0, visible=False)] + assert result.cues == [SlotCue(t=1.0, level=0.0)] def test_set_lane_cue_replaces_at_t() -> None: - lane = _lane(True, (5.0, False)) - result = set_lane_cue(lane, 5.0, True) - assert result.cues == [] # True matches baseline after canonicalize + lane = _lane(1.0, (5.0, 0.0)) + result = set_lane_cue(lane, 5.0, 1.0) + assert result.cues == [] def test_should_accept_toggle_debounces() -> None: @@ -212,169 +225,195 @@ def test_should_accept_toggle_debounces() -> None: assert should_accept_toggle(1.0, 1.0 + RECORD_DEBOUNCE_SEC) is True -def test_lane_fade_alpha_full_inside_visible_segment() -> None: - lane = _lane(False, (5.0, True), (15.0, False)) - assert lane_fade_alpha( +def test_lane_level_envelope_full_inside_segment() -> None: + lane = _lane(0.0, (5.0, 1.0), (15.0, 0.0)) + assert _env( lane, 10.0, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, standard_fades=_STD, duration_sec=60.0, ) == pytest.approx(1.0) -def test_lane_fade_alpha_fade_in_before_on_cue() -> None: - from cleave.easing import smoothstep - - lane = _lane(False, (10.0, True), (20.0, False)) - mid = lane_fade_alpha( +def test_lane_level_envelope_rise_completes_at_cue_time() -> None: + lane = _lane(0.0, (10.0, 1.0), (20.0, 0.0)) + mid = _env( lane, 9.0, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, standard_fades=_STD, duration_sec=60.0, ) assert mid == pytest.approx(smoothstep(0.5)) - assert lane_fade_alpha( + assert _env( lane, 8.0, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, standard_fades=_STD, duration_sec=60.0, ) == pytest.approx(0.0) - assert lane_fade_alpha( + assert _env( lane, 10.0, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, standard_fades=_STD, duration_sec=60.0, ) == pytest.approx(1.0) -def test_lane_fade_alpha_fade_out_after_off_cue() -> None: - from cleave.easing import smoothstep - - lane = _lane(False, (5.0, True), (15.0, False)) - mid = lane_fade_alpha( +def test_lane_level_envelope_fall_starts_at_cue_time() -> None: + lane = _lane(0.0, (5.0, 1.0), (15.0, 0.0)) + mid = _env( lane, 16.0, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, standard_fades=_STD, duration_sec=60.0, ) - assert mid == pytest.approx(smoothstep(0.5)) - assert lane_fade_alpha( + assert mid == pytest.approx(1.0 - smoothstep(0.5)) + assert _env( lane, 17.0, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, standard_fades=_STD, duration_sec=60.0, ) == pytest.approx(0.0) - assert lane_fade_alpha( + assert _env( lane, 14.9, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, standard_fades=_STD, duration_sec=60.0, ) == pytest.approx(1.0) -def test_lane_fade_alpha_no_fade_at_song_edges_without_cue() -> None: - lane = _lane(True) - assert lane_fade_alpha( +def test_lane_level_envelope_constant_slope_partial_fall() -> None: + """A 1.0 to 0.5 fall with 2s fade-out reaches 0.5 at t + 1.0.""" + lane = _lane(1.0, (10.0, 0.5)) + assert _env( lane, - 0.5, - inherit=False, + 11.0, + inherit=1.0, song_marker_fades=_OFF, standard_fades=_STD, - duration_sec=30.0, - ) == pytest.approx(1.0) - assert lane_fade_alpha( + duration_sec=60.0, + ) == pytest.approx(0.5) + mid = _env( lane, - 29.5, - inherit=False, + 10.5, + inherit=1.0, + song_marker_fades=_OFF, + standard_fades=_STD, + duration_sec=60.0, + ) + assert mid == pytest.approx(1.0 + (0.5 - 1.0) * smoothstep(0.5)) + + +def test_lane_level_envelope_no_fade_at_song_edges_without_cue() -> None: + lane = _lane(1.0) + assert _env( + lane, + 0.5, + inherit=0.0, song_marker_fades=_OFF, standard_fades=_STD, duration_sec=30.0, ) == pytest.approx(1.0) - assert lane_fade_alpha( + assert _env( lane, - 0.0, - inherit=False, + 29.5, + inherit=0.0, song_marker_fades=_OFF, standard_fades=_STD, duration_sec=30.0, ) == pytest.approx(1.0) -def test_lane_fade_alpha_zero_durations_match_boolean() -> None: - lane = _lane(False, (10.0, True), (20.0, False)) +def test_lane_level_envelope_zero_durations_match_stepped() -> None: + lane = _lane(0.0, (10.0, 1.0), (20.0, 0.0)) zero = _std(fade_in=0.0, fade_out=0.0) - assert lane_fade_alpha( + assert _env( lane, 9.9, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, standard_fades=zero, duration_sec=60.0, ) == pytest.approx(0.0) - assert lane_fade_alpha( + assert _env( lane, 10.0, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, standard_fades=zero, duration_sec=60.0, ) == pytest.approx(1.0) - assert lane_fade_alpha( + assert _env( lane, 20.0, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, standard_fades=zero, duration_sec=60.0, ) == pytest.approx(0.0) -def test_lane_fade_alpha_song_marker_group_disabled_makes_edge_abrupt() -> None: - lane = _lane(False, (10.0, True), (20.0, False)) - before = lane_fade_alpha( +def test_lane_level_envelope_fades_disabled_piecewise_constant() -> None: + lane = _lane(0.0, (10.0, 0.5), (20.0, 1.0)) + assert _env( lane, 9.0, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, - standard_fades=_STD, + standard_fades=_OFF, duration_sec=60.0, - song_marker_times=(10.0,), - ) - assert before == pytest.approx(0.0) - after = lane_fade_alpha( + ) == pytest.approx(0.0) + assert _env( + lane, + 10.0, + inherit=0.0, + song_marker_fades=_OFF, + standard_fades=_OFF, + duration_sec=60.0, + ) == pytest.approx(0.5) + assert _env( + lane, + 20.0, + inherit=0.0, + song_marker_fades=_OFF, + standard_fades=_OFF, + duration_sec=60.0, + ) == pytest.approx(1.0) + + +def test_lane_level_envelope_song_marker_group_disabled_makes_edge_abrupt() -> None: + lane = _lane(0.0, (10.0, 1.0), (20.0, 0.0)) + before = _env( lane, - 21.0, - inherit=False, + 9.0, + inherit=0.0, song_marker_fades=_OFF, standard_fades=_STD, duration_sec=60.0, - song_marker_times=(20.0,), + song_marker_times=(10.0,), ) - assert after == pytest.approx(0.0) - + assert before == pytest.approx(0.0) -def test_lane_fade_alpha_marker_edge_uses_song_marker_durations() -> None: - from cleave.easing import smoothstep - lane = _lane(False, (10.0, True), (20.0, False)) - mid = lane_fade_alpha( +def test_lane_level_envelope_marker_edge_uses_song_marker_durations() -> None: + lane = _lane(0.0, (10.0, 1.0), (20.0, 0.0)) + mid = _env( lane, 9.0, - inherit=False, + inherit=0.0, song_marker_fades=_markers(fade_in=2.0, fade_out=2.0), standard_fades=_std(fade_in=4.0, fade_out=4.0), duration_sec=60.0, @@ -383,14 +422,12 @@ def test_lane_fade_alpha_marker_edge_uses_song_marker_durations() -> None: assert mid == pytest.approx(smoothstep(0.5)) -def test_lane_fade_alpha_non_marker_edge_uses_standard_durations() -> None: - from cleave.easing import smoothstep - - lane = _lane(False, (10.0, True), (20.0, False)) - mid = lane_fade_alpha( +def test_lane_level_envelope_non_marker_edge_uses_standard_durations() -> None: + lane = _lane(0.0, (10.0, 1.0), (20.0, 0.0)) + mid = _env( lane, 8.0, - inherit=False, + inherit=0.0, song_marker_fades=_markers(fade_in=2.0, fade_out=2.0), standard_fades=_std(fade_in=4.0, fade_out=4.0), duration_sec=60.0, @@ -399,120 +436,63 @@ def test_lane_fade_alpha_non_marker_edge_uses_standard_durations() -> None: assert mid == pytest.approx(smoothstep(0.5)) -def test_lane_fade_alpha_standard_group_disabled_makes_non_marker_abrupt() -> None: - lane = _lane(False, (10.0, True), (20.0, False)) - before = lane_fade_alpha( - lane, - 9.0, - inherit=False, - song_marker_fades=_markers(), - standard_fades=_OFF, - duration_sec=60.0, - song_marker_times=(), - ) - assert before == pytest.approx(0.0) - - -def test_lane_fade_alpha_overlapping_segments_take_max() -> None: - from cleave.easing import smoothstep - - # Visible [5, 10) fading out and [11, 20) fading in overlap in gap with long fades. - lane = _lane(False, (5.0, True), (10.0, False), (11.0, True), (20.0, False)) - t = 10.5 - out_env = smoothstep((10.0 + 2.0 - t) / 2.0) - in_env = smoothstep((t - (11.0 - 2.0)) / 2.0) - alpha = lane_fade_alpha( - lane, - t, - inherit=False, - song_marker_fades=_OFF, - standard_fades=_STD, - duration_sec=60.0, - ) - assert alpha == pytest.approx(max(out_env, in_env)) - - -def test_lane_fade_spans_fade_in_before_on_cue() -> None: - lane = _lane(False, (10.0, True), (20.0, False)) - spans = lane_fade_spans( +def test_lane_level_breakpoints_rise_and_fall() -> None: + lane = _lane(0.0, (10.0, 1.0), (20.0, 0.0)) + bps = lane_level_breakpoints( lane, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, standard_fades=_STD, duration_sec=60.0, ) - assert (8.0, 10.0, "in") in spans - assert (20.0, 22.0, "out") in spans + assert (8.0, 0.0) in bps + assert (10.0, 1.0) in bps + assert (20.0, 1.0) in bps + assert (22.0, 0.0) in bps -def test_lane_fade_spans_no_fade_at_song_edges_without_cue() -> None: - lane = _lane(True) - spans = lane_fade_spans( +def test_lane_level_breakpoints_no_cues_holds_baseline() -> None: + lane = _lane(1.0) + bps = lane_level_breakpoints( lane, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, standard_fades=_STD, duration_sec=30.0, ) - assert spans == [] - - -def test_lane_fade_spans_zero_durations_empty() -> None: - lane = _lane(False, (10.0, True), (20.0, False)) - assert ( - lane_fade_spans( - lane, - inherit=False, - song_marker_fades=_OFF, - standard_fades=_std(fade_in=0.0, fade_out=0.0), - duration_sec=60.0, - ) - == [] - ) - - -def test_lane_fade_spans_song_marker_group_disabled() -> None: - lane = _lane(False, (10.0, True), (20.0, False)) - spans = lane_fade_spans( - lane, - inherit=False, - song_marker_fades=_OFF, - standard_fades=_STD, - duration_sec=60.0, - song_marker_times=(10.0, 20.0), - ) - assert (8.0, 10.0, "in") not in spans - assert (20.0, 22.0, "out") not in spans + assert bps == [(0.0, 1.0)] -def test_lane_fade_spans_clips_to_duration() -> None: - lane = _lane(False, (50.0, True), (59.0, False)) - spans = lane_fade_spans( +def test_lane_level_breakpoints_zero_durations_hard_step() -> None: + lane = _lane(0.0, (10.0, 1.0), (20.0, 0.0)) + bps = lane_level_breakpoints( lane, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, - standard_fades=_std(fade_in=2.0, fade_out=5.0), + standard_fades=_std(fade_in=0.0, fade_out=0.0), duration_sec=60.0, ) - fade_out = next(span for span in spans if span[2] == "out") - assert fade_out == (59.0, 60.0, "out") + assert bps == [(10.0, 0.0), (10.0, 1.0), (20.0, 1.0), (20.0, 0.0)] -def test_lane_fade_spans_clips_fade_in_at_song_start() -> None: - lane = _lane(False, (1.0, True)) - spans = lane_fade_spans( +def test_lane_level_breakpoints_overlapping_rise_clamped_monotone() -> None: + """Overlapping ramps stay monotone in t (clamped rise start).""" + lane = _lane(0.0, (5.0, 1.0), (10.0, 0.0), (11.0, 1.0)) + bps = lane_level_breakpoints( lane, - inherit=False, + inherit=0.0, song_marker_fades=_OFF, - standard_fades=_std(fade_in=5.0, fade_out=2.0), + standard_fades=_STD, duration_sec=60.0, ) - fade_in = next(span for span in spans if span[2] == "in") - assert fade_in == (0.0, 1.0, "in") + times = [t for t, _ in bps] + assert times == sorted(times) + for prev, cur in zip(times, times[1:]): + assert cur >= prev def test_on_transition_triggers_at_cue_when_fades_off() -> None: - lane = _lane(False, (5.0, True), (10.0, False), (15.0, True)) + lane = _lane(0.0, (5.0, 1.0), (10.0, 0.0), (15.0, 1.0)) triggers = lane_on_transition_trigger_times( lane, song_marker_fades=_OFF, @@ -522,7 +502,7 @@ def test_on_transition_triggers_at_cue_when_fades_off() -> None: def test_on_transition_triggers_lead_by_standard_fade_in() -> None: - lane = _lane(False, (10.0, True), (20.0, False), (30.0, True)) + lane = _lane(0.0, (10.0, 1.0), (20.0, 0.0), (30.0, 1.0)) triggers = lane_on_transition_trigger_times( lane, song_marker_fades=_OFF, @@ -531,8 +511,19 @@ def test_on_transition_triggers_lead_by_standard_fade_in() -> None: assert triggers == [8.0, 28.0] +def test_on_transition_triggers_ignores_non_zero_rise() -> None: + """Ignores 0.25 to 0.75; fires on 0 to 0.5 at t - fade_in * 0.5.""" + lane = _lane(0.25, (10.0, 0.75), (20.0, 0.0), (30.0, 0.5)) + triggers = lane_on_transition_trigger_times( + lane, + song_marker_fades=_OFF, + standard_fades=_std(fade_in=2.0, fade_out=2.0), + ) + assert triggers == [30.0 - 2.0 * 0.5] + + def test_on_transition_triggers_song_marker_vs_standard() -> None: - lane = _lane(False, (10.0, True), (20.0, False), (30.0, True)) + lane = _lane(0.0, (10.0, 1.0), (20.0, 0.0), (30.0, 1.0)) triggers = lane_on_transition_trigger_times( lane, song_marker_times=(10.0,), @@ -543,7 +534,7 @@ def test_on_transition_triggers_song_marker_vs_standard() -> None: def test_on_transition_count_seek_stable() -> None: - lane = _lane(False, (10.0, True), (20.0, False), (30.0, True)) + lane = _lane(0.0, (10.0, 1.0), (20.0, 0.0), (30.0, 1.0)) kwargs = dict( song_marker_fades=_OFF, standard_fades=_std(fade_in=2.0, fade_out=2.0), @@ -552,13 +543,12 @@ def test_on_transition_count_seek_stable() -> None: assert lane_on_transition_count(lane, 8.0, **kwargs) == 1 assert lane_on_transition_count(lane, 27.9, **kwargs) == 1 assert lane_on_transition_count(lane, 28.0, **kwargs) == 2 - # Same t always same count. assert lane_on_transition_count(lane, 28.0, **kwargs) == 2 assert lane_on_transition_count(lane, 100.0, **kwargs) == 2 def test_on_transition_count_hard_cut_uses_cue_time() -> None: - lane = _lane(False, (10.0, True)) + lane = _lane(0.0, (10.0, 1.0)) zero = _std(fade_in=0.0, fade_out=2.0) assert lane_on_transition_count( lane, @@ -572,3 +562,13 @@ def test_on_transition_count_hard_cut_uses_cue_time() -> None: song_marker_fades=_OFF, standard_fades=zero, ) == 1 + + +def test_boolean_parity_zero_one_lane_levels() -> None: + """0/1 lanes produce the same stepped levels as the former boolean path.""" + lane = _lane(1.0, (5.0, 0.0), (10.0, 1.0)) + assert lane_level_at(lane, 0.0, inherit=1.0) == 1.0 + assert lane_level_at(lane, 5.0, inherit=1.0) == 0.0 + assert lane_level_at(lane, 10.0, inherit=1.0) == 1.0 + assert lane_level_at(lane, 5.0, inherit=1.0) <= LEVEL_EPS + assert lane_level_at(lane, 10.0, inherit=1.0) > LEVEL_EPS diff --git a/tests/cleave/test_timeline_presets.py b/tests/cleave/test_timeline_presets.py index 93d2c4c..360c532 100644 --- a/tests/cleave/test_timeline_presets.py +++ b/tests/cleave/test_timeline_presets.py @@ -7,7 +7,7 @@ import pytest -from cleave.timeline import Timeline, TimelineLane, lane_visible_at +from cleave.timeline import LEVEL_EPS, TimelineLane, empty_lane, lane_level_at from cleave.timeline_presets import ( MIN_SWITCH_GAP_BARS, MIN_SWITCH_GAP_SEC, @@ -70,12 +70,20 @@ def _dense_middle_bars(duration_sec: float = 60.0) -> list[float]: return bars -def _inherits_false(slots: list[str]) -> dict[str, bool]: - return {slot: False for slot in slots} +def _inherits_zero(slots: list[str]) -> dict[str, float]: + return {slot: 0.0 for slot in slots} -def _timeline(lanes: dict[str, TimelineLane]) -> Timeline: - return Timeline(lanes=lanes) +def _level_at( + lanes: dict[str, TimelineLane], + slots: list[str], + t: float, +) -> dict[str, float]: + inherits = _inherits_zero(slots) + return { + slot: lane_level_at(lanes.get(slot) or empty_lane(), t, inherit=inherits[slot]) + for slot in slots + } def _visible_at( @@ -83,8 +91,7 @@ def _visible_at( slots: list[str], t: float, ) -> dict[str, bool]: - inherits = _inherits_false(slots) - return _timeline(lanes).visible_state_at(slots, t, inherits) + return {slot: level > LEVEL_EPS for slot, level in _level_at(lanes, slots, t).items()} def _all_transition_times(lanes: dict[str, TimelineLane]) -> list[float]: @@ -279,7 +286,7 @@ def test_preset_invariants(builder, n: int, duration_sec: float) -> None: _assert_cues_on_bars(lanes, thinned if thinned else bars) _assert_min_gaps(lanes, bars, duration_sec) if n == 1: - assert lanes[slots[0]].baseline is True + assert lanes[slots[0]].baseline == 1.0 assert lanes[slots[0]].cues == [] @@ -374,17 +381,17 @@ def test_pulse_rotates_singles() -> None: for t in _all_transition_times(lanes): if t == 0.0: continue - active = [s for s in slots if lane_visible_at(lanes[s], t, inherit=False)] + active = [s for s in slots if lane_level_at(lanes[s], t, inherit=0.0)] if len(active) == 1: solo_seen.add(active[0]) assert len(solo_seen) >= 2 -def test_lane_visible_at_with_all_false_inherits() -> None: +def test_lane_level_at_with_all_false_inherits() -> None: slots = _slots(3) lanes, _bars = _build(build_dialogue_cues, slots, 60.0, random.Random(3)) for slot in slots: - lane_visible_at(lanes[slot], 0.0, inherit=False) + lane_level_at(lanes[slot], 0.0, inherit=0.0) def test_in_climax_window_bounds() -> None: @@ -712,6 +719,30 @@ def test_resolve_crescendo_window_requires_three_markers() -> None: assert resolve_crescendo_window([10.0, 50.0], 100.0, "last") is None +def test_crescendo_states_emit_half_entrant_then_full_stack() -> None: + from cleave.timeline_presets.crescendo import CrescendoWindow, _crescendo_states + + slots = _slots(4) + bars = _bar_times_for(120.0) + window = CrescendoWindow(t_start=50.0, t_full=80.0, t_peak_end=100.0) + states = _crescendo_states( + slots, + window, + duration_sec=120.0, + bar_times=bars, + rng=random.Random(0), + ) + assert states + # Steps before t_full include a 0.5 entrant; t_full is a full stack. + half_steps = [levels for t, levels in states if t < window.t_full - 1e-9] + assert half_steps + for levels in half_steps: + assert 0.5 in levels.values() + full = next(levels for t, levels in states if abs(t - window.t_full) < 1e-9) + assert set(full.values()) == {1.0} + assert len(full) == min(4, MAX_CONCURRENT_LAYERS) + + def test_apply_crescendo_ramps_holds_then_solos() -> None: slots = _slots(4) duration_sec = 120.0 diff --git a/tests/cleave/test_timeline_snap.py b/tests/cleave/test_timeline_snap.py index 0e63bbf..e11534b 100644 --- a/tests/cleave/test_timeline_snap.py +++ b/tests/cleave/test_timeline_snap.py @@ -13,13 +13,22 @@ ) +def _as_level(value): + if value is None: + return None + if isinstance(value, bool): + return 1.0 if value else 0.0 + return float(value) + + def _lane( - baseline: bool | None, - *transitions: tuple[float, bool], + baseline, + *transitions, ) -> TimelineLane: + base = _as_level(baseline) return TimelineLane( - baseline=baseline, - cues=[SlotCue(t=t, visible=v) for t, v in transitions], + baseline=base, + cues=[SlotCue(t=t, level=float(_as_level(level))) for t, level in transitions], ) @@ -59,57 +68,57 @@ def test_snap_empty_cues_noop() -> None: def test_snap_empty_beats_noop() -> None: lane = _lane(None, (0.4, True)) result = snap_lane_to_beats(lane, []) - assert result.cues == [SlotCue(t=0.4, visible=True)] + assert result.cues == [SlotCue(t=0.4, level=1.0)] def test_snap_nearest_beat() -> None: lane = _lane(False, (0.1, True), (0.9, False)) result = snap_lane_to_beats(lane, (0.0, 1.0, 2.0)) assert result.cues == [ - SlotCue(t=0.0, visible=True), - SlotCue(t=1.0, visible=False), + SlotCue(t=0.0, level=1.0), + SlotCue(t=1.0, level=0.0), ] def test_snap_midpoint_tie_prefers_earlier() -> None: lane = _lane(False, (0.5, True)) result = snap_lane_to_beats(lane, (0.0, 1.0)) - assert result.cues == [SlotCue(t=0.0, visible=True)] + assert result.cues == [SlotCue(t=0.0, level=1.0)] def test_snap_extrapolates_outside_range_via_median_interval() -> None: lane = _lane(False, (-0.4, True), (4.4, False)) result = snap_lane_to_beats(lane, (1.0, 2.0, 3.0)) assert result.cues == [ - SlotCue(t=0.0, visible=True), - SlotCue(t=4.0, visible=False), + SlotCue(t=0.0, level=1.0), + SlotCue(t=4.0, level=0.0), ] def test_snap_extrapolation_midpoint_prefers_earlier() -> None: lane = _lane(False, (3.5, True)) result = snap_lane_to_beats(lane, (1.0, 2.0, 3.0)) - assert result.cues == [SlotCue(t=3.0, visible=True)] + assert result.cues == [SlotCue(t=3.0, level=1.0)] def test_snap_merges_collisions_within_lane() -> None: lane = _lane(False, (0.1, True), (0.2, False), (0.9, True)) result = snap_lane_to_beats(lane, (0.0, 1.0)) # 0.1 and 0.2 both snap to 0.0; last-wins -> False; 0.9 -> 1.0 True - assert result.cues == [SlotCue(t=1.0, visible=True)] + assert result.cues == [SlotCue(t=1.0, level=1.0)] def test_snap_single_beat_snaps_everything() -> None: lane = _lane(True, (0.0, True), (10.0, False)) result = snap_lane_to_beats(lane, (2.5,)) - assert result.cues == [SlotCue(t=2.5, visible=False)] + assert result.cues == [SlotCue(t=2.5, level=0.0)] def test_snap_preserves_baseline() -> None: lane = _lane(True, (0.1, False)) result = snap_lane_to_beats(lane, (0.0, 1.0)) - assert result.baseline is True - assert result.cues == [SlotCue(t=0.0, visible=False)] + assert result.baseline == 1.0 + assert result.cues == [SlotCue(t=0.0, level=0.0)] def test_shift_bars_by_beats_offsets() -> None: @@ -138,8 +147,8 @@ def test_shift_lane_cues_by_beats_offsets() -> None: lane = _lane(False, (0.1, True), (2.1, False)) result = shift_lane_cues_by_beats(lane, (0.0, 1.0, 2.0, 3.0), 1) assert result.cues == [ - SlotCue(t=1.0, visible=True), - SlotCue(t=3.0, visible=False), + SlotCue(t=1.0, level=1.0), + SlotCue(t=3.0, level=0.0), ] @@ -152,10 +161,10 @@ def test_shift_lane_cues_by_beats_clamps_and_canonicalizes() -> None: assert result_fwd.cues == [] lane_on = _lane(False, (0.0, True), (0.9, True)) result_on = shift_lane_cues_by_beats(lane_on, (0.0, 1.0), 1) - assert result_on.cues == [SlotCue(t=1.0, visible=True)] + assert result_on.cues == [SlotCue(t=1.0, level=1.0)] def test_shift_lane_cues_by_beats_empty_noop() -> None: lane = _lane(None, (0.4, True)) - assert shift_lane_cues_by_beats(lane, (), 1).cues == [SlotCue(t=0.4, visible=True)] + assert shift_lane_cues_by_beats(lane, (), 1).cues == [SlotCue(t=0.4, level=1.0)] assert shift_lane_cues_by_beats(_lane(True), (0.0, 1.0), 1).cues == [] diff --git a/tests/cleave/test_timeline_song_marker_snap.py b/tests/cleave/test_timeline_song_marker_snap.py index a70a071..3d1443c 100644 --- a/tests/cleave/test_timeline_song_marker_snap.py +++ b/tests/cleave/test_timeline_song_marker_snap.py @@ -9,13 +9,22 @@ ) +def _as_level(value): + if value is None: + return None + if isinstance(value, bool): + return 1.0 if value else 0.0 + return float(value) + + def _lane( - baseline: bool | None, - *transitions: tuple[float, bool], + baseline, + *transitions, ) -> TimelineLane: + base = _as_level(baseline) return TimelineLane( - baseline=baseline, - cues=[SlotCue(t=t, visible=v) for t, v in transitions], + baseline=base, + cues=[SlotCue(t=t, level=float(_as_level(level))) for t, level in transitions], ) @@ -29,7 +38,7 @@ def test_proximity_no_op_when_outside_window() -> None: slots=("layer_1",), ) assert moved == 0 - assert result["layer_1"].cues == [SlotCue(t=10.0, visible=True)] + assert result["layer_1"].cues == [SlotCue(t=10.0, level=1.0)] def test_proximity_zero_no_op() -> None: @@ -42,7 +51,7 @@ def test_proximity_zero_no_op() -> None: slots=("layer_1",), ) assert moved == 0 - assert result["layer_1"].cues == [SlotCue(t=1.0, visible=True)] + assert result["layer_1"].cues == [SlotCue(t=1.0, level=1.0)] def test_empty_markers_no_op() -> None: @@ -55,7 +64,7 @@ def test_empty_markers_no_op() -> None: slots=("layer_1",), ) assert moved == 0 - assert result["layer_1"].cues == [SlotCue(t=1.0, visible=True)] + assert result["layer_1"].cues == [SlotCue(t=1.0, level=1.0)] def test_closest_pick_within_proximity() -> None: @@ -69,8 +78,8 @@ def test_closest_pick_within_proximity() -> None: ) assert moved == 1 assert result["layer_1"].cues == [ - SlotCue(t=5.0, visible=True), - SlotCue(t=8.0, visible=False), + SlotCue(t=5.0, level=1.0), + SlotCue(t=8.0, level=0.0), ] @@ -85,8 +94,8 @@ def test_distance_tie_prefers_earlier_cue_time() -> None: ) assert moved == 1 assert result["layer_1"].cues == [ - SlotCue(t=5.0, visible=True), - SlotCue(t=6.0, visible=False), + SlotCue(t=5.0, level=1.0), + SlotCue(t=6.0, level=0.0), ] @@ -103,8 +112,8 @@ def test_exclusive_claim_later_marker_skips_claimed_cue() -> None: # remaining cue within 5s of an unclaimed cue (12.0 is 6s away). assert moved == 1 assert result["layer_1"].cues == [ - SlotCue(t=5.5, visible=True), - SlotCue(t=12.0, visible=False), + SlotCue(t=5.5, level=1.0), + SlotCue(t=12.0, level=0.0), ] @@ -119,8 +128,8 @@ def test_exclusive_claim_second_marker_takes_next_cue() -> None: ) assert moved == 2 assert result["layer_1"].cues == [ - SlotCue(t=5.5, visible=True), - SlotCue(t=10.0, visible=False), + SlotCue(t=5.5, level=1.0), + SlotCue(t=10.0, level=0.0), ] @@ -141,8 +150,8 @@ def test_each_layer_vs_closest_wins() -> None: mode="each_layer", ) assert each_moved == 2 - assert each["layer_1"].cues == [SlotCue(t=5.0, visible=True)] - assert each["layer_2"].cues == [SlotCue(t=5.0, visible=False)] + assert each["layer_1"].cues == [SlotCue(t=5.0, level=1.0)] + assert each["layer_2"].cues == [SlotCue(t=5.0, level=0.0)] closest, closest_moved = snap_lanes_to_song_markers( lanes, @@ -153,8 +162,8 @@ def test_each_layer_vs_closest_wins() -> None: mode="closest_wins", ) assert closest_moved == 1 - assert closest["layer_1"].cues == [SlotCue(t=4.5, visible=True)] - assert closest["layer_2"].cues == [SlotCue(t=5.0, visible=False)] + assert closest["layer_1"].cues == [SlotCue(t=4.5, level=1.0)] + assert closest["layer_2"].cues == [SlotCue(t=5.0, level=0.0)] def test_closest_wins_tie_prefers_earlier_layer_in_z_order() -> None: @@ -172,8 +181,8 @@ def test_closest_wins_tie_prefers_earlier_layer_in_z_order() -> None: mode="closest_wins", ) assert moved == 1 - assert result["layer_1"].cues == [SlotCue(t=5.0, visible=True)] - assert result["layer_2"].cues == [SlotCue(t=6.0, visible=False)] + assert result["layer_1"].cues == [SlotCue(t=5.0, level=1.0)] + assert result["layer_2"].cues == [SlotCue(t=6.0, level=0.0)] # Equal distance and equal times: earlier z-order wins. lanes_eq = { @@ -189,8 +198,8 @@ def test_closest_wins_tie_prefers_earlier_layer_in_z_order() -> None: mode="closest_wins", ) assert moved_eq == 1 - assert result_eq["layer_1"].cues == [SlotCue(t=5.0, visible=True)] - assert result_eq["layer_2"].cues == [SlotCue(t=4.5, visible=False)] + assert result_eq["layer_1"].cues == [SlotCue(t=5.0, level=1.0)] + assert result_eq["layer_2"].cues == [SlotCue(t=4.5, level=0.0)] def test_canonicalize_after_collision() -> None: @@ -218,8 +227,8 @@ def test_preserves_visibility_and_baseline() -> None: slots=("layer_1",), ) assert moved == 1 - assert result["layer_1"].baseline is True - assert result["layer_1"].cues == [SlotCue(t=5.0, visible=False)] + assert result["layer_1"].baseline == 1.0 + assert result["layer_1"].cues == [SlotCue(t=5.0, level=0.0)] def test_single_slot_leaves_other_lanes_unchanged() -> None: @@ -235,5 +244,5 @@ def test_single_slot_leaves_other_lanes_unchanged() -> None: slots=("layer_1",), ) assert moved == 1 - assert result["layer_1"].cues == [SlotCue(t=5.0, visible=True)] - assert result["layer_2"].cues == [SlotCue(t=4.0, visible=False)] + assert result["layer_1"].cues == [SlotCue(t=5.0, level=1.0)] + assert result["layer_2"].cues == [SlotCue(t=4.0, level=0.0)] diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index adb0572..72ef370 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -1609,12 +1609,21 @@ def test_timeline_presets_enter_opens_yes_cancel_modal() -> None: ) +def _as_level(value): + if value is None: + return None + if isinstance(value, bool): + return 1.0 if value else 0.0 + return float(value) + + def _lane( - baseline: bool | None, - *transitions: tuple[float, bool], + baseline, + *transitions, ) -> TimelineLane: - cues = [SlotCue(t=t, visible=v) for t, v in transitions] - return TimelineLane(baseline=baseline, cues=canonicalize(baseline, cues)) + base = _as_level(baseline) + cues = [SlotCue(t=t, level=float(_as_level(level))) for t, level in transitions] + return TimelineLane(baseline=base, cues=canonicalize(base, cues)) def _confirm_timeline_preset(controls: TuningControls) -> None: @@ -1750,7 +1759,7 @@ def test_timeline_reset_all_off() -> None: assert not controls.session.timeline.armed_slots lanes = controls.session.timeline.lanes assert set(lanes) == {"layer_1", "layer_2", "layer_3"} - assert all(lane.baseline is False and lane.cues == [] for lane in lanes.values()) + assert all(lane.baseline == 0.0 and lane.cues == [] for lane in lanes.values()) view = controls.build_view_state(paused=False) assert view.notification_message == "Reset timeline: all layers off" @@ -1768,7 +1777,7 @@ def test_timeline_reset_all_on() -> None: assert controls.session.timeline.enabled is True lanes = controls.session.timeline.lanes assert set(lanes) == {"layer_1", "layer_2"} - assert all(lane.baseline is True and lane.cues == [] for lane in lanes.values()) + assert all(lane.baseline == 1.0 and lane.cues == [] for lane in lanes.values()) view = controls.build_view_state(paused=False) assert view.notification_message == "Reset timeline: all layers on" @@ -1848,10 +1857,10 @@ def test_timeline_snap_grid_beats_mutates_cues() -> None: _choose_modal_option(controls, "Beats") assert not controls.modal_host.active assert controls.session.timeline.lanes["layer_1"].cues == [ - SlotCue(t=0.0, visible=True), + SlotCue(t=0.0, level=1.0), ] assert controls.session.timeline.lanes["layer_2"].cues == [ - SlotCue(t=2.0, visible=False), + SlotCue(t=2.0, level=0.0), ] view = controls.build_view_state(paused=False) assert view.notification_message == "Snapped timeline cues to beats" @@ -1872,10 +1881,10 @@ def test_timeline_snap_grid_bars_mutates_cues() -> None: _choose_modal_option(controls, "Bars") assert not controls.modal_host.active assert controls.session.timeline.lanes["layer_1"].cues == [ - SlotCue(t=0.0, visible=True), + SlotCue(t=0.0, level=1.0), ] assert controls.session.timeline.lanes["layer_2"].cues == [ - SlotCue(t=4.0, visible=False), + SlotCue(t=4.0, level=0.0), ] view = controls.build_view_state(paused=False) assert view.notification_message == "Snapped timeline cues to bars" @@ -1925,10 +1934,10 @@ def test_timeline_bar_phase_right_nudges_cues_and_offset() -> None: assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True assert controls.session.timeline.bar_phase_offset == 1 assert controls.session.timeline.lanes["layer_1"].cues == [ - SlotCue(t=1.0, visible=True), + SlotCue(t=1.0, level=1.0), ] assert controls.session.timeline.lanes["layer_2"].cues == [ - SlotCue(t=5.0, visible=False), + SlotCue(t=5.0, level=0.0), ] view = controls.build_view_state(paused=False) assert view.notification_message == "Bar phase +1" @@ -1970,7 +1979,7 @@ def test_timeline_bar_phase_left_wraps_offset() -> None: assert controls.handle_keydown(_keydown(pygame.K_LEFT)) is True assert controls.session.timeline.bar_phase_offset == 3 assert controls.session.timeline.lanes["layer_1"].cues == [ - SlotCue(t=0.0, visible=True), + SlotCue(t=0.0, level=1.0), ] view = controls.build_view_state(paused=False) assert view.notification_message == "Bar phase +3" @@ -2063,10 +2072,10 @@ def test_timeline_snap_song_markers_layer_mutates_only_that_lane() -> None: _confirm_snap_song_markers(controls, scope_label="Layer 1") assert not controls.modal_host.active assert controls.session.timeline.lanes["layer_1"].cues == [ - SlotCue(t=5.0, visible=True), + SlotCue(t=5.0, level=1.0), ] assert controls.session.timeline.lanes["layer_2"].cues == [ - SlotCue(t=4.2, visible=False), + SlotCue(t=4.2, level=0.0), ] view = controls.build_view_state(paused=False) assert view.notification_message == "Snapped 1 cue to song markers" @@ -2082,10 +2091,10 @@ def test_timeline_snap_song_markers_each_layer_vs_closest_wins() -> None: _focus_timeline_snap_song_markers(controls) _confirm_snap_song_markers(controls, scope_label="All Layers") assert controls.session.timeline.lanes["layer_1"].cues == [ - SlotCue(t=5.0, visible=True), + SlotCue(t=5.0, level=1.0), ] assert controls.session.timeline.lanes["layer_2"].cues == [ - SlotCue(t=5.0, visible=False), + SlotCue(t=5.0, level=0.0), ] view = controls.build_view_state(paused=False) assert view.notification_message == "Snapped 2 cues to song markers" @@ -2097,10 +2106,10 @@ def test_timeline_snap_song_markers_each_layer_vs_closest_wins() -> None: _focus_timeline_snap_song_markers(controls) _confirm_snap_song_markers(controls, scope_label="Closest Wins") assert controls.session.timeline.lanes["layer_1"].cues == [ - SlotCue(t=4.5, visible=True), + SlotCue(t=4.5, level=1.0), ] assert controls.session.timeline.lanes["layer_2"].cues == [ - SlotCue(t=5.0, visible=False), + SlotCue(t=5.0, level=0.0), ] view = controls.build_view_state(paused=False) assert view.notification_message == "Snapped 1 cue to song markers" @@ -2169,7 +2178,7 @@ def test_timeline_snap_song_markers_uses_proximity() -> None: _focus_timeline_snap_song_markers(controls) _confirm_snap_song_markers(controls, proximity_label="1.0s") assert controls.session.timeline.lanes["layer_1"].cues == [ - SlotCue(t=3.0, visible=True), + SlotCue(t=3.0, level=1.0), ] view = controls.build_view_state(paused=False) assert view.notification_message == "No cues within snap proximity" diff --git a/tests/cleave/viz/test_layer.py b/tests/cleave/viz/test_layer.py index 08c1400..a2cb45f 100644 --- a/tests/cleave/viz/test_layer.py +++ b/tests/cleave/viz/test_layer.py @@ -17,19 +17,19 @@ from cleave.viz.layer import StemLayer from cleave.viz.layer_visibility import ( apply_layer_visibility, - armed_recording_visible, + armed_recording_level, build_record_punch_cues, build_timeline_view_state, - committed_visible_outside_punch, + committed_level_outside_punch, effective_layer_enabled, - snapshot_monitor_from_timeline, - timeline_committed_visible, + snapshot_monitor_from_output, + timeline_committed_level, timeline_defaults, - timeline_fade_multiplier, + timeline_level_multiplier, ) from cleave.viz.layer_pipeline import apply_effect_modifiers from cleave.viz.timeline_overlay import ( - bar_segments_for_row, + bar_level_breakpoints_for_row, bar_tick_times_for_row, cue_times_for_stem, ) @@ -45,12 +45,21 @@ def _playlist(name: str) -> PresetPlaylist: DEFAULT_LAYER_SLOTS_LIST = list(DEFAULT_LAYER_SLOTS) +def _as_level(value): + if value is None: + return None + if isinstance(value, bool): + return 1.0 if value else 0.0 + return float(value) + + def _lane( - baseline: bool | None, - *transitions: tuple[float, bool], + baseline, + *transitions, ) -> TimelineLane: - cues = [SlotCue(t=t, visible=v) for t, v in transitions] - return TimelineLane(baseline=baseline, cues=canonicalize(baseline, cues)) + base = _as_level(baseline) + cues = [SlotCue(t=t, level=float(_as_level(level))) for t, level in transitions] + return TimelineLane(baseline=base, cues=canonicalize(base, cues)) def _session( @@ -91,11 +100,12 @@ def _stem_layer(slot: str) -> StemLayer: def test_timeline_defaults_from_layer_runtime() -> None: session = _session(layer_enabled={"layer_1": False, "layer_2": True, "layer_3": True, "layer_4": False}) + # float defaults from layer.enabled assert timeline_defaults(session) == { - "layer_1": False, - "layer_2": True, - "layer_3": True, - "layer_4": False, + "layer_1": 0.0, + "layer_2": 1.0, + "layer_3": 1.0, + "layer_4": 0.0, } @@ -203,8 +213,8 @@ def test_effective_layer_enabled_uses_record_buffer_while_recording() -> None: ) session.timeline.recording = True session.timeline.armed_slots = {"layer_1"} - session.timeline.record_baseline = {"layer_1": True} - session.timeline.record_buffer = {"layer_1": [SlotCue(t=1.0, visible=False)]} + session.timeline.record_baseline={"layer_1": 1.0} + session.timeline.record_buffer = {"layer_1": [SlotCue(t=1.0, level=0.0)]} assert effective_layer_enabled(session, "layer_1", 1.0) is False assert effective_layer_enabled(session, "layer_2", 1.0) is True @@ -227,11 +237,11 @@ def test_timeline_record_buffer_visible_while_recording() -> None: session = _session(timeline_enabled=True, lanes={"layer_1": _lane(False)}) session.timeline.recording = True session.timeline.armed_slots = {"layer_1"} - session.timeline.record_baseline = {"layer_1": True} - session.timeline.record_buffer = {"layer_1": [SlotCue(t=1.0, visible=False)]} + session.timeline.record_baseline={"layer_1": 1.0} + session.timeline.record_buffer = {"layer_1": [SlotCue(t=1.0, level=0.0)]} assert effective_layer_enabled(session, "layer_1", 1.0) is False assert session.timeline.lanes["layer_1"] == _lane(False) - assert session.timeline.record_buffer["layer_1"] == [SlotCue(t=1.0, visible=False)] + assert session.timeline.record_buffer["layer_1"] == [SlotCue(t=1.0, level=0.0)] def test_build_timeline_view_state_uses_record_buffer_while_recording() -> None: @@ -240,10 +250,10 @@ def test_build_timeline_view_state_uses_record_buffer_while_recording() -> None: lanes={"layer_1": _lane(False)}, ) session.timeline.recording = True - session.timeline.record_buffer = {"layer_2": [SlotCue(t=1.0, visible=False)]} + session.timeline.record_buffer = {"layer_2": [SlotCue(t=1.0, level=0.0)]} state = build_timeline_view_state(session, position_sec=1.0, duration_sec=60.0) assert state.lanes["layer_1"] == _lane(False) - assert state.record_buffer == {"layer_2": [SlotCue(t=1.0, visible=False)]} + assert state.record_buffer == {"layer_2": [SlotCue(t=1.0, level=0.0)]} def test_apply_layer_visibility_sets_fbo_enabled_from_timeline() -> None: @@ -275,12 +285,12 @@ def test_apply_layer_visibility_fades_enable_before_on_cue() -> None: apply_layer_visibility(session, layers_by_slot, 9.0) assert layers_by_slot["layer_1"].fbo.enabled is True - assert 0.0 < layers_by_slot["layer_1"].timeline_fade < 1.0 + assert 0.0 < layers_by_slot["layer_1"].timeline_level < 1.0 assert effective_layer_enabled(session, "layer_1", 9.0) is False apply_layer_visibility(session, layers_by_slot, 7.0) assert layers_by_slot["layer_1"].fbo.enabled is False - assert layers_by_slot["layer_1"].timeline_fade == pytest.approx(0.0) + assert layers_by_slot["layer_1"].timeline_level == pytest.approx(0.0) def test_apply_layer_visibility_fades_off_stays_abrupt() -> None: @@ -295,7 +305,10 @@ def test_apply_layer_visibility_fades_off_stays_abrupt() -> None: apply_layer_visibility(session, layers_by_slot, 9.0) assert layers_by_slot["layer_1"].fbo.enabled is False - assert layers_by_slot["layer_1"].timeline_fade == pytest.approx(1.0) + assert layers_by_slot["layer_1"].timeline_level == pytest.approx(0.0) + apply_layer_visibility(session, layers_by_slot, 10.0) + assert layers_by_slot["layer_1"].fbo.enabled is True + assert layers_by_slot["layer_1"].timeline_level == pytest.approx(1.0) def test_apply_layer_visibility_override_stays_abrupt_with_fades() -> None: @@ -312,7 +325,7 @@ def test_apply_layer_visibility_override_stays_abrupt_with_fades() -> None: apply_layer_visibility(session, layers_by_slot, 9.0) assert layers_by_slot["layer_1"].fbo.enabled is False - assert layers_by_slot["layer_1"].timeline_fade == pytest.approx(1.0) + assert layers_by_slot["layer_1"].timeline_level == pytest.approx(1.0) def test_apply_layer_visibility_preview_stays_abrupt_with_fades() -> None: @@ -334,17 +347,17 @@ def test_apply_layer_visibility_preview_stays_abrupt_with_fades() -> None: apply_layer_visibility(session, layers_by_slot, 9.0) assert layers_by_slot["layer_1"].fbo.enabled is False - assert layers_by_slot["layer_1"].timeline_fade == pytest.approx(1.0) + assert layers_by_slot["layer_1"].timeline_level == pytest.approx(1.0) -def test_apply_effect_modifiers_scales_opacity_by_timeline_fade() -> None: +def test_apply_effect_modifiers_scales_opacity_by_timeline_level() -> None: session = _session( layer_enabled={"layer_1": True, "layer_2": True, "layer_3": True, "layer_4": True}, timeline_enabled=True, ) layer = _stem_layer("layer_1") layer.fbo.enabled = True - layer.timeline_fade = 0.5 + layer.timeline_level = 0.5 layer.fbo.opacity = 1.0 effect_runtime = MagicMock() mod = MagicMock( @@ -408,7 +421,7 @@ def test_apply_effect_modifiers_identity_in_curation_mode() -> None: assert layer.fbo.blend_mode == "black-key" -def test_timeline_fade_multiplier_matches_lane_math() -> None: +def test_timeline_level_multiplier_matches_lane_math() -> None: from cleave.easing import smoothstep session = _session( @@ -419,7 +432,7 @@ def test_timeline_fade_multiplier_matches_lane_math() -> None: session.timeline.standard_cue_fades.enabled = True session.timeline.standard_cue_fades.fade_in = 2.0 session.timeline.standard_cue_fades.fade_out = 2.0 - assert timeline_fade_multiplier(session, "layer_1", 9.0) == pytest.approx( + assert timeline_level_multiplier(session, "layer_1", 9.0) == pytest.approx( smoothstep(0.5) ) @@ -464,23 +477,23 @@ def test_build_record_punch_cues_writes_baseline_only_when_it_differs() -> None: lanes={"layer_1": _lane(False)}, ) session.timeline.armed_slots = {"layer_1"} - session.timeline.record_baseline = {"layer_1": True} - session.timeline.record_buffer = {"layer_1": [SlotCue(t=12.0, visible=False)]} + session.timeline.record_baseline={"layer_1": 1.0} + session.timeline.record_buffer = {"layer_1": [SlotCue(t=12.0, level=0.0)]} build_record_punch_cues(session, record_start=10.0, record_stop=20.0) lane = session.timeline.lanes["layer_1"] - assert lane.baseline is False - assert SlotCue(t=10.0, visible=True) in lane.cues - assert SlotCue(t=12.0, visible=False) in lane.cues + assert lane.baseline == 0.0 + assert SlotCue(t=10.0, level=1.0) in lane.cues + assert SlotCue(t=12.0, level=0.0) in lane.cues - session.timeline.record_baseline = {"layer_1": False} + session.timeline.record_baseline={"layer_1": 0.0} session.timeline.record_buffer = {} unchanged = _session( layer_enabled={"layer_1": True, "layer_2": True, "layer_3": True, "layer_4": True}, timeline_enabled=True, lanes={"layer_1": _lane(False)}, ) - unchanged.timeline.record_baseline = {"layer_1": False} + unchanged.timeline.record_baseline={"layer_1": 0.0} build_record_punch_cues(unchanged, record_start=10.0, record_stop=20.0) assert unchanged.timeline.lanes["layer_1"] == _lane(False) @@ -494,12 +507,12 @@ def test_effective_layer_enabled_recording_armed_ignores_committed_cues() -> Non session.timeline.recording = True session.timeline.record_start_sec = 10.0 session.timeline.armed_slots = {"layer_1"} - session.timeline.record_baseline = {"layer_1": True} + session.timeline.record_baseline={"layer_1": 1.0} session.timeline.record_buffer = {} assert effective_layer_enabled(session, "layer_1", 11.5) is True - assert armed_recording_visible(session, "layer_1", 11.5) is True - assert timeline_committed_visible(session, "layer_1", 11.5) is False + assert armed_recording_level(session, "layer_1", 11.5) == 1.0 + assert timeline_committed_level(session, "layer_1", 11.5) == 0.0 def test_build_timeline_view_state_recording_baseline_not_in_cue_ticks() -> None: @@ -511,7 +524,7 @@ def test_build_timeline_view_state_recording_baseline_not_in_cue_ticks() -> None session.timeline.recording = True session.timeline.record_start_sec = 10.0 session.timeline.armed_slots = {"layer_1"} - session.timeline.record_baseline = {"layer_1": True} + session.timeline.record_baseline={"layer_1": 1.0} session.timeline.record_buffer = {} state = build_timeline_view_state(session, position_sec=10.0, duration_sec=60.0) @@ -527,12 +540,12 @@ def test_build_timeline_view_state_armed_recording_monitor_ignores_committed() - session.timeline.recording = True session.timeline.record_start_sec = 10.0 session.timeline.armed_slots = {"layer_1"} - session.timeline.record_baseline = {"layer_1": True} + session.timeline.record_baseline={"layer_1": 1.0} session.timeline.record_buffer = {} state = build_timeline_view_state(session, position_sec=11.5, duration_sec=60.0) assert state.monitor_visible["layer_1"] is True - assert state.timeline_visible["layer_1"] is False + assert state.timeline_level["layer_1"] == 0.0 def test_effective_layer_enabled_recording_armed_vs_unarmed() -> None: @@ -543,13 +556,13 @@ def test_effective_layer_enabled_recording_armed_vs_unarmed() -> None: ) session.timeline.recording = True session.timeline.armed_slots = {"layer_1"} - session.timeline.record_baseline = {"layer_1": True} - session.timeline.record_buffer = {"layer_1": [SlotCue(t=1.0, visible=False)], "layer_2": [SlotCue(t=1.0, visible=True)]} + session.timeline.record_baseline={"layer_1": 1.0} + session.timeline.record_buffer = {"layer_1": [SlotCue(t=1.0, level=0.0)], "layer_2": [SlotCue(t=1.0, level=1.0)]} assert effective_layer_enabled(session, "layer_1", 1.0) is False assert effective_layer_enabled(session, "layer_2", 1.0) is False -def test_timeline_committed_visible_ignores_record_buffer() -> None: +def test_timeline_committed_level_ignores_record_buffer() -> None: session = _session( layer_enabled={"layer_1": True, "layer_2": True, "layer_3": True, "layer_4": True}, timeline_enabled=True, @@ -557,9 +570,9 @@ def test_timeline_committed_visible_ignores_record_buffer() -> None: ) session.timeline.recording = True session.timeline.armed_slots = {"layer_1"} - session.timeline.record_buffer = {"layer_1": [SlotCue(t=1.0, visible=False)], "layer_2": [SlotCue(t=1.0, visible=True)]} - assert timeline_committed_visible(session, "layer_1", 1.0) is True - assert timeline_committed_visible(session, "layer_2", 1.0) is False + session.timeline.record_buffer = {"layer_1": [SlotCue(t=1.0, level=0.0)], "layer_2": [SlotCue(t=1.0, level=1.0)]} + assert timeline_committed_level(session, "layer_1", 1.0) == 1.0 + assert timeline_committed_level(session, "layer_2", 1.0) == 0.0 def test_build_timeline_view_state_populates_visibility_playing() -> None: @@ -573,8 +586,8 @@ def test_build_timeline_view_state_populates_visibility_playing() -> None: state = build_timeline_view_state(session, position_sec=5.0, duration_sec=60.0) assert state.monitor_visible["layer_1"] is False assert state.monitor_visible["layer_2"] is True - assert state.timeline_visible["layer_1"] is False - assert state.timeline_visible["layer_2"] is True + assert state.timeline_level["layer_1"] == 0.0 + assert state.timeline_level["layer_2"] == 1.0 assert state.override_slots == {"layer_2"} @@ -593,11 +606,11 @@ def test_build_timeline_view_state_monitor_preview_active() -> None: } state = build_timeline_view_state(session, position_sec=0.0, duration_sec=60.0) assert state.monitor_visible == session.timeline.monitor - assert state.timeline_visible["layer_1"] is False - assert state.timeline_visible["layer_2"] is True + assert state.timeline_level["layer_1"] == 0.0 + assert state.timeline_level["layer_2"] == 1.0 -def test_snapshot_monitor_from_timeline_populates_from_committed() -> None: +def test_snapshot_monitor_from_output_populates_from_committed() -> None: session = _session( layer_enabled={"layer_1": False, "layer_2": True, "layer_3": True, "layer_4": False}, timeline_enabled=True, @@ -606,7 +619,7 @@ def test_snapshot_monitor_from_timeline_populates_from_committed() -> None: "layer_3": _lane(True, (2.0, False)), }, ) - monitor = snapshot_monitor_from_timeline(session, 2.5) + monitor = snapshot_monitor_from_output(session, 2.5) assert monitor == { "layer_1": True, "layer_2": True, @@ -624,16 +637,19 @@ def test_armed_recording_bar_blends_committed_and_live() -> None: session.timeline.recording = True session.timeline.record_start_sec = 10.0 session.timeline.armed_slots = {"layer_1"} - session.timeline.record_baseline = {"layer_1": True} - session.timeline.record_buffer = {"layer_1": [SlotCue(t=12.0, visible=False)]} + session.timeline.record_baseline={"layer_1": 1.0} + session.timeline.record_buffer = {"layer_1": [SlotCue(t=12.0, level=0.0)]} state = build_timeline_view_state(session, position_sec=12.0, duration_sec=60.0) - segments = bar_segments_for_row(state, "layer_1") + segments = bar_level_breakpoints_for_row(state, "layer_1") assert segments == [ - (0.0, 10.0, False), - (10.0, 12.0, True), - (12.0, 30.0, False), - (30.0, 60.0, True), + (0.0, 0.0), + (10.0, 0.0), + (10.0, 1.0), + (12.0, 0.0), + (30.0, 0.0), + (30.0, 1.0), + (60.0, 1.0), ] @@ -645,15 +661,18 @@ def test_armed_recording_bar_uses_committed_timeline_after_disarm() -> None: ) session.timeline.recording = False session.timeline.armed_slots = set() - session.timeline.record_baseline = {} + session.timeline.record_baseline={} session.timeline.record_buffer = {} state = build_timeline_view_state(session, position_sec=12.0, duration_sec=60.0) - segments = bar_segments_for_row(state, "layer_1") + segments = bar_level_breakpoints_for_row(state, "layer_1") assert segments == [ - (0.0, 10.0, False), - (10.0, 12.0, True), - (12.0, 60.0, False), + (0.0, 0.0), + (10.0, 0.0), + (10.0, 1.0), + (12.0, 1.0), + (12.0, 0.0), + (60.0, 0.0), ] assert effective_layer_enabled(session, "layer_1", 12.0) is False @@ -666,12 +685,12 @@ def test_build_record_punch_cues_uses_record_baseline_when_disarmed() -> None: ) session.timeline.recording = True session.timeline.armed_slots = set() - session.timeline.record_baseline = {"layer_1": True} - session.timeline.record_buffer = {"layer_1": [SlotCue(t=12.0, visible=False)]} + session.timeline.record_baseline={"layer_1": 1.0} + session.timeline.record_buffer = {"layer_1": [SlotCue(t=12.0, level=0.0)]} build_record_punch_cues(session, record_start=10.0, record_stop=15.0) lane = session.timeline.lanes["layer_1"] - assert SlotCue(t=12.0, visible=False) in lane.cues + assert SlotCue(t=12.0, level=0.0) in lane.cues def test_build_record_punch_cues_restores_committed_at_stop() -> None: @@ -681,14 +700,14 @@ def test_build_record_punch_cues_restores_committed_at_stop() -> None: lanes={"layer_1": _lane(False, (30.0, True))}, ) session.timeline.armed_slots = {"layer_1"} - session.timeline.record_baseline = {"layer_1": False} - session.timeline.record_buffer = {"layer_1": [SlotCue(t=15.0, visible=True)]} + session.timeline.record_baseline={"layer_1": 0.0} + session.timeline.record_buffer = {"layer_1": [SlotCue(t=15.0, level=1.0)]} build_record_punch_cues(session, record_start=10.0, record_stop=20.0) lane = session.timeline.lanes["layer_1"] - assert SlotCue(t=15.0, visible=True) in lane.cues - assert SlotCue(t=20.0, visible=False) in lane.cues - assert timeline_committed_visible(session, "layer_1", 20.0) is False + assert SlotCue(t=15.0, level=1.0) in lane.cues + assert SlotCue(t=20.0, level=0.0) in lane.cues + assert timeline_committed_level(session, "layer_1", 20.0) == 0.0 def test_build_record_punch_cues_restores_when_disable_only_inside_punch() -> None: @@ -698,23 +717,23 @@ def test_build_record_punch_cues_restores_when_disable_only_inside_punch() -> No lanes={"layer_1": _lane(True, (15.0, False), (25.0, True))}, ) session.timeline.armed_slots = {"layer_1"} - session.timeline.record_baseline = {"layer_1": True} - session.timeline.record_buffer = {"layer_1": [SlotCue(t=18.0, visible=True)]} + session.timeline.record_baseline={"layer_1": 1.0} + session.timeline.record_buffer = {"layer_1": [SlotCue(t=18.0, level=1.0)]} build_record_punch_cues(session, record_start=10.0, record_stop=22.0) lane = session.timeline.lanes["layer_1"] - assert SlotCue(t=22.0, visible=False) in lane.cues - assert committed_visible_outside_punch(session, "layer_1", 10.0, 22.0) is True - assert timeline_committed_visible(session, "layer_1", 22.0) is False + assert SlotCue(t=22.0, level=0.0) in lane.cues + assert committed_level_outside_punch(session, "layer_1", 10.0, 22.0) == 1.0 + assert timeline_committed_level(session, "layer_1", 22.0) == 0.0 def test_timeline_defaults_tracks_layer_enabled_before_first_record() -> None: session = _session( layer_enabled={"layer_1": False, "layer_2": True, "layer_3": True, "layer_4": True}, ) - assert timeline_defaults(session)["layer_1"] is False + assert timeline_defaults(session)["layer_1"] == 0.0 session.layers["layer_1"].enabled = True - assert timeline_defaults(session)["layer_1"] is True + assert timeline_defaults(session)["layer_1"] == 1.0 def test_timeline_defaults_stable_after_layer_toggle_when_slot_has_cues() -> None: @@ -723,11 +742,11 @@ def test_timeline_defaults_stable_after_layer_toggle_when_slot_has_cues() -> Non timeline_enabled=True, lanes={"layer_1": _lane(False, (5.0, True), (10.0, False), (15.0, True))}, ) - assert timeline_defaults(session)["layer_1"] is False + assert timeline_defaults(session)["layer_1"] == 0.0 session.layers["layer_1"].enabled = False - assert timeline_defaults(session)["layer_1"] is False - assert timeline_committed_visible(session, "layer_1", 2.0) is False - assert timeline_committed_visible(session, "layer_1", 7.0) is True + assert timeline_defaults(session)["layer_1"] == 0.0 + assert timeline_committed_level(session, "layer_1", 2.0) == 0.0 + assert timeline_committed_level(session, "layer_1", 7.0) == 1.0 def test_build_record_punch_cues_adds_t0_anchor_on_first_record() -> None: @@ -737,21 +756,21 @@ def test_build_record_punch_cues_adds_t0_anchor_on_first_record() -> None: lanes={}, ) session.timeline.armed_slots = {"layer_1"} - session.timeline.record_baseline = {"layer_1": False} + session.timeline.record_baseline={"layer_1": 0.0} session.timeline.record_buffer = { "layer_1": [ - SlotCue(t=5.0, visible=True), - SlotCue(t=10.0, visible=False), - SlotCue(t=15.0, visible=True), + SlotCue(t=5.0, level=1.0), + SlotCue(t=10.0, level=0.0), + SlotCue(t=15.0, level=1.0), ], } build_record_punch_cues(session, record_start=2.0, record_stop=20.0) lane = session.timeline.lanes["layer_1"] - assert lane.baseline is False - assert SlotCue(t=5.0, visible=True) in lane.cues - assert SlotCue(t=10.0, visible=False) in lane.cues - assert SlotCue(t=15.0, visible=True) in lane.cues + assert lane.baseline == 0.0 + assert SlotCue(t=5.0, level=1.0) in lane.cues + assert SlotCue(t=10.0, level=0.0) in lane.cues + assert SlotCue(t=15.0, level=1.0) in lane.cues def test_timeline_bar_preserves_pre_first_cue_after_layer_toggle() -> None: @@ -760,22 +779,22 @@ def test_timeline_bar_preserves_pre_first_cue_after_layer_toggle() -> None: timeline_enabled=True, lanes={"layer_1": _lane(False, (5.0, True), (10.0, False), (15.0, True))}, ) - state = build_timeline_view_state(session, position_sec=0.0, duration_sec=60.0) - assert bar_segments_for_row(state, "layer_1") == [ - (0.0, 5.0, False), - (5.0, 10.0, True), - (10.0, 15.0, False), - (15.0, 60.0, True), + expected = [ + (0.0, 0.0), + (5.0, 0.0), + (5.0, 1.0), + (10.0, 1.0), + (10.0, 0.0), + (15.0, 0.0), + (15.0, 1.0), + (60.0, 1.0), ] + state = build_timeline_view_state(session, position_sec=0.0, duration_sec=60.0) + assert bar_level_breakpoints_for_row(state, "layer_1") == expected session.layers["layer_1"].enabled = True state = build_timeline_view_state(session, position_sec=0.0, duration_sec=60.0) - assert bar_segments_for_row(state, "layer_1") == [ - (0.0, 5.0, False), - (5.0, 10.0, True), - (10.0, 15.0, False), - (15.0, 60.0, True), - ] + assert bar_level_breakpoints_for_row(state, "layer_1") == expected def test_build_timeline_view_state_phase_shifts_bar_grid_times() -> None: session = _session( diff --git a/tests/cleave/viz/test_layer_manager.py b/tests/cleave/viz/test_layer_manager.py index 19af055..71fa7a4 100644 --- a/tests/cleave/viz/test_layer_manager.py +++ b/tests/cleave/viz/test_layer_manager.py @@ -149,10 +149,10 @@ def test_discard_timeline_slot_strips_slot_from_timeline_state() -> None: session.timeline.override_visible["layer_2"] = True session.timeline.arm_flash_start_ms["layer_2"] = 100 session.timeline.record_buffer = { - "layer_1": [SlotCue(t=1.0, visible=True)], + "layer_1": [SlotCue(t=1.0, level=1.0)], "layer_2": [ - SlotCue(t=1.0, visible=False), - SlotCue(t=2.0, visible=True), + SlotCue(t=1.0, level=0.0), + SlotCue(t=2.0, level=1.0), ], } @@ -167,5 +167,5 @@ def test_discard_timeline_slot_strips_slot_from_timeline_state() -> None: assert "layer_2" not in session.timeline.arm_flash_start_ms assert "layer_2" not in session.timeline.record_buffer assert session.timeline.record_buffer == { - "layer_1": [SlotCue(t=1.0, visible=True)], + "layer_1": [SlotCue(t=1.0, level=1.0)], } diff --git a/tests/cleave/viz/test_overlay_targets.py b/tests/cleave/viz/test_overlay_targets.py index 7358ea5..94d7a0f 100644 --- a/tests/cleave/viz/test_overlay_targets.py +++ b/tests/cleave/viz/test_overlay_targets.py @@ -231,7 +231,7 @@ def test_draw_timeline_overlay_uses_display_target() -> None: duration_sec=120.0, focus_row=0, monitor_visible={"layer_1": True}, - timeline_visible={"layer_1": True}, + timeline_level={"layer_1": 1.0}, override_slots=set(), armed_slots=set(), recording=False, @@ -273,7 +273,7 @@ def test_draw_timeline_overlay_applies_visibility_alpha() -> None: duration_sec=120.0, focus_row=0, monitor_visible={"layer_1": True}, - timeline_visible={"layer_1": True}, + timeline_level={"layer_1": 1.0}, override_slots=set(), armed_slots=set(), recording=False, diff --git a/tests/cleave/viz/test_preset_switching.py b/tests/cleave/viz/test_preset_switching.py index cc241e8..27d24e4 100644 --- a/tests/cleave/viz/test_preset_switching.py +++ b/tests/cleave/viz/test_preset_switching.py @@ -463,11 +463,11 @@ def _timeline_session(layer: StemLayer, *, enabled: bool = True) -> TuningSessio ) session.timeline.enabled = enabled session.timeline.lanes["layer_1"] = TimelineLane( - baseline=False, + baseline=0.0, cues=[ - SlotCue(t=5.0, visible=True), - SlotCue(t=10.0, visible=False), - SlotCue(t=15.0, visible=True), + SlotCue(t=5.0, level=1.0), + SlotCue(t=10.0, level=0.0), + SlotCue(t=15.0, level=1.0), ], ) return session diff --git a/tests/cleave/viz/test_render.py b/tests/cleave/viz/test_render.py index 3884129..bdebf9b 100644 --- a/tests/cleave/viz/test_render.py +++ b/tests/cleave/viz/test_render.py @@ -76,8 +76,8 @@ def _playlist(slot: str) -> PresetPlaylist: def _session_with_enable_cue_at(beat_t: float) -> TuningSession: """Timeline on; layer_1 off until a cue at beat_t turns it on.""" - cues = canonicalize(False, [SlotCue(t=beat_t, visible=True)]) - lane = TimelineLane(baseline=False, cues=cues) + cues = canonicalize(0.0, [SlotCue(t=beat_t, level=1.0)]) + lane = TimelineLane(baseline=0.0, cues=cues) return TuningSession( layer_z_order=list(DEFAULT_LAYER_SLOTS), timeline=TimelineRuntime( diff --git a/tests/cleave/viz/test_timeline_controls.py b/tests/cleave/viz/test_timeline_controls.py index 675be9c..3fe1d28 100644 --- a/tests/cleave/viz/test_timeline_controls.py +++ b/tests/cleave/viz/test_timeline_controls.py @@ -9,20 +9,29 @@ from cleave.config_schema import DEFAULT_LAYER_SLOTS from tests.support.config import TEST_LAYER_STEMS from cleave.extract import STEM_NAMES -from cleave.timeline import SlotCue, TimelineLane, canonicalize, copy_lane, empty_lane, lane_visible_at +from cleave.timeline import SlotCue, TimelineLane, canonicalize, copy_lane, empty_lane, lane_level_at from cleave.viz.controls import SEEK_LONG, SEEK_SHORT, SEEK_TINY, TuningControls from cleave.viz.session import LayerRuntime, TuningSession -from cleave.viz.layer_visibility import armed_recording_visible, effective_layer_enabled +from cleave.viz.layer_visibility import armed_recording_level, effective_layer_enabled from cleave.viz.timeline_controls import TimelineControls from tests.support.viz import keydown, make_playlist, stub_playback_state +def _as_level(value): + if value is None: + return None + if isinstance(value, bool): + return 1.0 if value else 0.0 + return float(value) + + def _lane( - baseline: bool | None, - *transitions: tuple[float, bool], + baseline, + *transitions, ) -> TimelineLane: - cues = [SlotCue(t=t, visible=v) for t, v in transitions] - return TimelineLane(baseline=baseline, cues=canonicalize(baseline, cues)) + base = _as_level(baseline) + cues = [SlotCue(t=t, level=float(_as_level(level))) for t, level in transitions] + return TimelineLane(baseline=base, cues=canonicalize(base, cues)) def _preset_lanes() -> dict[str, TimelineLane]: @@ -305,7 +314,7 @@ def test_record_from_pause_stores_wysiwyg_baseline_when_monitor_differs() -> Non assert session.timeline.preview_active is False assert session.timeline.monitor == {} assert session.timeline.record_buffer == {} - assert session.timeline.record_baseline == {"layer_1": False} + assert session.timeline.record_baseline == {"layer_1": 0.0} def test_record_preserves_override() -> None: @@ -508,7 +517,7 @@ def test_record_start_stores_baseline_not_buffer() -> None: controls.handle_keydown(keydown(pygame.K_r)) assert session.timeline.record_buffer == {} - assert session.timeline.record_baseline == {"layer_1": True, "layer_2": False} + assert session.timeline.record_baseline == {"layer_1": 1.0, "layer_2": 0.0} def test_arm_during_recording_joins_take_and_accepts_toggle() -> None: @@ -522,13 +531,13 @@ def test_arm_during_recording_joins_take_and_accepts_toggle() -> None: session.layers["layer_2"].enabled = True controls.handle_keydown(keydown(pygame.K_r)) - assert session.timeline.record_baseline == {"layer_1": True} + assert session.timeline.record_baseline == {"layer_1": 1.0} assert session.timeline.record_slot_start_sec == {"layer_1": 4.0} controls.playback.player.seek(8.0) controls.handle_keydown(keydown(pygame.K_a)) assert session.timeline.armed_slots == {"layer_1", "layer_2"} - assert session.timeline.record_baseline == {"layer_1": True, "layer_2": True} + assert session.timeline.record_baseline == {"layer_1": 1.0, "layer_2": 1.0} assert session.timeline.record_slot_start_sec == { "layer_1": 4.0, "layer_2": 8.0, @@ -536,7 +545,7 @@ def test_arm_during_recording_joins_take_and_accepts_toggle() -> None: controls.handle_keydown(keydown(pygame.K_2)) assert "layer_2" in session.timeline.record_buffer - assert session.timeline.record_buffer["layer_2"][0].visible is False + assert session.timeline.record_buffer["layer_2"][0].level == 0.0 assert session.timeline.record_buffer["layer_2"][0].t == 8.0 @@ -556,7 +565,7 @@ def test_layer_keys_only_affect_armed_stems() -> None: controls.handle_keydown(keydown(pygame.K_1)) assert len(session.timeline.record_buffer) == 1 # slot buffer - assert session.timeline.record_buffer == {"layer_1": [SlotCue(t=2.0, visible=False)]} + assert session.timeline.record_buffer == {"layer_1": [SlotCue(t=2.0, level=0.0)]} def test_numpad_layer_keys_work_while_recording() -> None: @@ -570,7 +579,7 @@ def test_numpad_layer_keys_work_while_recording() -> None: controls.handle_keydown(keydown(pygame.K_KP1)) assert len(session.timeline.record_buffer) == 1 # slot buffer - assert session.timeline.record_buffer == {"layer_1": [SlotCue(t=6.0, visible=False)]} + assert session.timeline.record_buffer == {"layer_1": [SlotCue(t=6.0, level=0.0)]} def test_layer_key_debounce_ignores_rapid_press() -> None: @@ -600,10 +609,10 @@ def test_disarm_during_recording_commits_slot_and_exits_recording() -> None: assert session.timeline.recording is False assert session.timeline.record_baseline == {} assert session.timeline.record_buffer == {} - from cleave.viz.layer_visibility import timeline_committed_visible + from cleave.viz.layer_visibility import timeline_committed_level lane = session.timeline.lanes["layer_1"] - assert timeline_committed_visible(session, "layer_1", 5.0) is False + assert timeline_committed_level(session, "layer_1", 5.0) == 0.0 assert visibility_calls @@ -622,7 +631,7 @@ def test_disarm_one_slot_keeps_recording_on_remaining_armed() -> None: controls.handle_keydown(keydown(pygame.K_a)) assert session.timeline.armed_slots == {"layer_2"} assert session.timeline.recording is True - assert session.timeline.record_baseline == {"layer_2": False} + assert session.timeline.record_baseline == {"layer_2": 0.0} assert "layer_1" not in session.timeline.record_baseline assert "layer_1" not in session.timeline.record_buffer assert visibility_calls @@ -660,13 +669,13 @@ def test_forward_seek_during_record_fills_with_active_state() -> None: session.layers["layer_1"].enabled = True controls.handle_keydown(keydown(pygame.K_r)) - assert session.timeline.record_baseline == {"layer_1": True} - active_at_start = armed_recording_visible(session, "layer_1", 10.0) + assert session.timeline.record_baseline == {"layer_1": 1.0} + active_at_start = armed_recording_level(session, "layer_1", 10.0) controls.handle_keydown(keydown(pygame.K_RIGHT)) assert seeks == [SEEK_SHORT] - assert armed_recording_visible(session, "layer_1", 15.0) == active_at_start + assert armed_recording_level(session, "layer_1", 15.0) == active_at_start assert visibility_calls @@ -678,13 +687,13 @@ def test_backward_seek_during_record_fills_and_expands_punch_start() -> None: session.layers["layer_1"].enabled = True controls.handle_keydown(keydown(pygame.K_r)) - active_before_seek = armed_recording_visible(session, "layer_1", 20.0) + active_before_seek = armed_recording_level(session, "layer_1", 20.0) assert session.timeline.record_start_sec == 20.0 controls.handle_keydown(keydown(pygame.K_LEFT)) assert session.timeline.record_start_sec == 10.0 - assert armed_recording_visible(session, "layer_1", 15.0) == active_before_seek + assert armed_recording_level(session, "layer_1", 15.0) == active_before_seek assert visibility_calls @@ -724,7 +733,7 @@ def test_r_stop_punches_cues_and_clears_record_state() -> None: assert session.timeline.record_start_sec is None assert session.timeline.lanes["layer_2"] == _lane(True, (5.0, False)) lane = session.timeline.lanes["layer_1"] - assert SlotCue(t=10.0, visible=False) in lane.cues + assert SlotCue(t=10.0, level=0.0) in lane.cues assert visibility_calls[-1] is True @@ -751,7 +760,7 @@ def test_record_toggle_snaps_cue_time() -> None: controls.playback.player.seek(0.6) controls.handle_keydown(keydown(pygame.K_1)) assert session.timeline.record_buffer["layer_1"] == [ - SlotCue(t=1.0, visible=False) + SlotCue(t=1.0, level=0.0) ] @@ -768,8 +777,8 @@ def test_record_stop_snaps_punch_end() -> None: controls.playback.player.seek(2.4) controls.handle_keydown(keydown(pygame.K_r)) assert session.timeline.lanes["layer_1"].cues == [ - SlotCue(t=1.0, visible=False), - SlotCue(t=2.0, visible=True), + SlotCue(t=1.0, level=0.0), + SlotCue(t=2.0, level=1.0), ] @@ -785,16 +794,17 @@ def test_record_placement_snap_off_keeps_audible_times() -> None: assert session.timeline.record_start_sec == 0.6 controls.handle_keydown(keydown(pygame.K_1)) assert session.timeline.record_buffer["layer_1"] == [ - SlotCue(t=0.6, visible=False) + SlotCue(t=0.6, level=0.0) ] def _lane_visible(session, slot: str, t: float) -> bool: + from cleave.timeline import LEVEL_EPS from cleave.viz.layer_visibility import timeline_defaults lane = session.timeline.lanes.get(slot) or empty_lane() - return lane_visible_at(lane, t, inherit=timeline_defaults(session)[slot]) + return lane_level_at(lane, t, inherit=timeline_defaults(session)[slot]) > LEVEL_EPS def test_stop_record_restores_committed_after_punch_range() -> None: @@ -815,7 +825,7 @@ def test_stop_record_restores_committed_after_punch_range() -> None: assert _lane_visible(session, "layer_1", 20.0) is False assert _lane_visible(session, "layer_1", 29.9) is False assert _lane_visible(session, "layer_1", 30.0) is True - assert SlotCue(t=20.0, visible=False) in session.timeline.lanes["layer_1"].cues + assert SlotCue(t=20.0, level=0.0) in session.timeline.lanes["layer_1"].cues def test_stop_record_restores_disabled_tail_when_disable_inside_punch() -> None: @@ -877,12 +887,12 @@ def test_stop_record_preserves_unarmed_cues_in_punch_range() -> None: controls.handle_keydown(keydown(pygame.K_r)) assert session.timeline.lanes["layer_2"] == bass_lane - assert SlotCue(t=11.0, visible=True) not in session.timeline.lanes["layer_1"].cues + assert SlotCue(t=11.0, level=1.0) not in session.timeline.lanes["layer_1"].cues def test_stop_record_preserves_unarmed_slots_in_preset_style_cues() -> None: """Recording one armed layer must not rewrite unarmed lanes.""" - from cleave.viz.layer_visibility import timeline_committed_visible + from cleave.viz.layer_visibility import timeline_committed_level slots = tuple(DEFAULT_LAYER_SLOTS) lanes = _preset_lanes() @@ -892,12 +902,12 @@ def test_stop_record_preserves_unarmed_slots_in_preset_style_cues() -> None: lanes=lanes, ) for slot in slots: - session.layers[slot].enabled = lanes[slot].baseline is True + session.layers[slot].enabled = lanes[slot].baseline == 1.0 unarmed = ("layer_2", "layer_3", "layer_4") sample = (0.0, 4.0, 8.0, 12.0, 16.0, 20.0, 24.0, 30.0) before = { - slot: [timeline_committed_visible(session, slot, t) for t in sample] + slot: [timeline_committed_level(session, slot, t) for t in sample] for slot in unarmed } @@ -909,7 +919,7 @@ def test_stop_record_preserves_unarmed_slots_in_preset_style_cues() -> None: controls.handle_keydown(keydown(pygame.K_r)) after = { - slot: [timeline_committed_visible(session, slot, t) for t in sample] + slot: [timeline_committed_level(session, slot, t) for t in sample] for slot in unarmed } assert after == before @@ -923,7 +933,7 @@ def test_stop_record_preserves_unarmed_after_breathing_preset() -> None: import random from cleave.timeline_presets import build_breathing_cues - from cleave.viz.layer_visibility import timeline_committed_visible + from cleave.viz.layer_visibility import timeline_committed_level slots = list(DEFAULT_LAYER_SLOTS) lanes = build_breathing_cues(slots, 60.0, random.Random(42)) @@ -938,7 +948,7 @@ def test_stop_record_preserves_unarmed_after_breathing_preset() -> None: unarmed = ("layer_2", "layer_3", "layer_4") sample = tuple(i * 2.5 for i in range(25)) before = { - slot: [timeline_committed_visible(session, slot, t) for t in sample] + slot: [timeline_committed_level(session, slot, t) for t in sample] for slot in unarmed } @@ -950,7 +960,7 @@ def test_stop_record_preserves_unarmed_after_breathing_preset() -> None: controls.handle_keydown(keydown(pygame.K_r)) after = { - slot: [timeline_committed_visible(session, slot, t) for t in sample] + slot: [timeline_committed_level(session, slot, t) for t in sample] for slot in unarmed } assert after == before @@ -958,7 +968,7 @@ def test_stop_record_preserves_unarmed_after_breathing_preset() -> None: def test_stop_record_does_not_flip_unarmed_leading_section() -> None: """Regression: recording an armed layer must not change an unarmed lane.""" - from cleave.viz.layer_visibility import timeline_committed_visible + from cleave.viz.layer_visibility import timeline_committed_level controls, session, _, _, _, _ = _make_timeline_controls( armed_slots={"layer_1"}, @@ -971,7 +981,7 @@ def test_stop_record_does_not_flip_unarmed_leading_section() -> None: session.layers["layer_2"].enabled = False sample = (0.0, 6.0, 11.9, 12.0, 20.0) - before = [timeline_committed_visible(session, "layer_2", t) for t in sample] + before = [timeline_committed_level(session, "layer_2", t) for t in sample] assert before == [False, False, False, True, True] controls.handle_keydown(keydown(pygame.K_r)) @@ -982,7 +992,7 @@ def test_stop_record_does_not_flip_unarmed_leading_section() -> None: assert session.timeline.lanes["layer_2"] == _lane(None, (12.0, True)) - after = [timeline_committed_visible(session, "layer_2", t) for t in sample] + after = [timeline_committed_level(session, "layer_2", t) for t in sample] assert after == before @@ -1090,7 +1100,7 @@ def test_ctrl_space_without_armed_stems_notifies() -> None: def test_recorded_timeline_bar_unchanged_after_disable_layer_toggle_reenable() -> None: from cleave.viz.layer_visibility import build_timeline_view_state - from cleave.viz.timeline_overlay import bar_segments_for_row + from cleave.viz.timeline_overlay import bar_level_breakpoints_for_row controls, session, _, _, _, _ = _make_timeline_controls( armed_slots={"layer_1"}, @@ -1105,9 +1115,9 @@ def test_recorded_timeline_bar_unchanged_after_disable_layer_toggle_reenable() - controls.playback.player.seek(18.0) controls.handle_keydown(keydown(pygame.K_r)) - def segments() -> list[tuple[float, float, bool]]: + def segments() -> list[tuple[float, float]]: state = build_timeline_view_state(session, position_sec=0.0, duration_sec=60.0) - return bar_segments_for_row(state, "layer_1") + return bar_level_breakpoints_for_row(state, "layer_1") expected = segments() @@ -1115,4 +1125,4 @@ def segments() -> list[tuple[float, float, bool]]: session.layers["layer_1"].enabled = True session.timeline.enabled = True assert segments() == expected - assert expected[0] == (0.0, 3.0, False) \ No newline at end of file + assert expected[0][0] == 0.0 \ No newline at end of file diff --git a/tests/cleave/viz/test_timeline_overlay.py b/tests/cleave/viz/test_timeline_overlay.py index 8b14685..fd20efc 100644 --- a/tests/cleave/viz/test_timeline_overlay.py +++ b/tests/cleave/viz/test_timeline_overlay.py @@ -3,16 +3,19 @@ from __future__ import annotations import pygame +import pytest from cleave.config_schema import DEFAULT_LAYER_SLOTS from tests.support.config import TEST_LAYER_STEMS from cleave.extract import STEM_NAMES from cleave.timeline import ( + LEVEL_EPS, SlotCue, TimelineFadeGroup, TimelineLane, canonicalize, - lane_visible_at, + lane_level_at, + lane_level_segments, stem_abbreviation, ) from cleave.viz.material_icons import visibility_icon_slot_width @@ -40,7 +43,7 @@ arm_abbrev_flash_active, arm_abbrev_flash_visible, armed_abbrev_bg_visible, - bar_segments_for_row, + bar_level_breakpoints_for_row, bar_tick_times_for_row, cue_times_for_stem, layer_num_prefix, @@ -54,22 +57,31 @@ stem_label_text, transport_time_text, time_to_x, - visibility_segments, + clip_breakpoints, ) +def _as_level(value): + if value is None: + return None + if isinstance(value, bool): + return 1.0 if value else 0.0 + return float(value) + + def _lane( - baseline: bool | None, - *transitions: tuple[float, bool], + baseline, + *transitions, ) -> TimelineLane: - cues = [SlotCue(t=t, visible=v) for t, v in transitions] - return TimelineLane(baseline=baseline, cues=canonicalize(baseline, cues)) + base = _as_level(baseline) + cues = [SlotCue(t=t, level=float(_as_level(level))) for t, level in transitions] + return TimelineLane(baseline=base, cues=canonicalize(base, cues)) def _view_state( *, lanes: dict[str, TimelineLane] | None = None, - defaults: dict[str, bool] | None = None, + defaults: dict[str, float] | None = None, position_sec: float = 0.0, duration_sec: float = 100.0, focus_row: int = 0, @@ -77,13 +89,13 @@ def _view_state( armed_slots: set[str] | None = None, recording: bool = False, record_start_sec: float | None = None, - record_baseline: dict[str, bool] | None = None, + record_baseline: dict[str, float] | None = None, record_buffer: dict[str, list[SlotCue]] | None = None, record_high_water_mark: float | None = None, enabled: bool = True, layer_z_order: list[str] | None = None, monitor_visible: dict[str, bool] | None = None, - timeline_visible: dict[str, bool] | None = None, + timeline_level: dict[str, float] | None = None, override_slots: set[str] | None = None, arm_flash_start_ms: dict[str, int] | None = None, show_bar_grid: bool = False, @@ -99,20 +111,31 @@ def _view_state( ) -> TimelineViewState: order = list(layer_z_order or list(DEFAULT_LAYER_SLOTS)) lane_map = dict(lanes or {}) - default_map = dict( - defaults or {slot: True for slot in (layer_z_order or list(DEFAULT_LAYER_SLOTS))} - ) - if monitor_visible is None: - monitor_visible = { - stem: lane_visible_at( - lane_map.get(stem) or TimelineLane(baseline=None, cues=[]), - position_sec, - inherit=default_map[stem], + default_map = { + slot: float(_as_level(level)) + for slot, level in ( + defaults or {slot: 1.0 for slot in (layer_z_order or list(DEFAULT_LAYER_SLOTS))} + ).items() + } + if timeline_level is None: + timeline_level = { + stem: float( + lane_level_at( + lane_map.get(stem) or TimelineLane(baseline=None, cues=[]), + position_sec, + inherit=float(_as_level(default_map[stem])), + ) ) for stem in order } - if timeline_visible is None: - timeline_visible = dict(monitor_visible) + else: + timeline_level = { + slot: float(_as_level(level)) for slot, level in timeline_level.items() + } + if monitor_visible is None: + monitor_visible = { + stem: timeline_level[stem] > LEVEL_EPS for stem in order + } return TimelineViewState( layer_z_order=order, slot_stems={ @@ -125,12 +148,12 @@ def _view_state( duration_sec=duration_sec, focus_row=focus_row, monitor_visible=monitor_visible, - timeline_visible=timeline_visible, + timeline_level=timeline_level, override_slots=set(override_slots or ()), armed_slots=set(armed_slots or ()), recording=recording, record_start_sec=record_start_sec, - record_baseline=dict(record_baseline or ()), + record_baseline={slot: float(_as_level(level)) for slot, level in dict(record_baseline or ()).items()}, record_buffer=dict(record_buffer or ()), record_high_water_mark=record_high_water_mark, enabled=enabled, @@ -562,7 +585,7 @@ def test_draw_dual_eye_state_does_not_crash() -> None: armed_slots={"layer_2"}, recording=True, monitor_visible={"layer_1": True, "layer_2": False, "layer_3": True, "layer_4": True}, - timeline_visible={"layer_1": False, "layer_2": True, "layer_3": True, "layer_4": True}, + timeline_level={"layer_1": 0.0, "layer_2": 1.0, "layer_3": 1.0, "layer_4": 1.0}, override_slots={"layer_1"}, ) surface = pygame.Surface((1280, 720), pygame.SRCALPHA) @@ -570,26 +593,48 @@ def test_draw_dual_eye_state_does_not_crash() -> None: assert overlay.panel_rect is not None -def test_visibility_segments_default_only() -> None: - lane = TimelineLane(baseline=False, cues=[]) - segments = visibility_segments(lane, 60.0, inherit=True) - assert segments == [(0.0, 60.0, False)] +def test_clip_breakpoints_interpolates_at_edges() -> None: + bps = [(0.0, 0.0), (10.0, 1.0), (20.0, 0.5)] + clipped = clip_breakpoints(bps, 5.0, 15.0) + assert clipped[0][0] == 5.0 + assert clipped[0][1] == pytest.approx(0.5) + assert clipped[-1][0] == 15.0 + assert clipped[-1][1] == pytest.approx(0.75) + assert any(abs(t - 10.0) < 1e-9 for t, _ in clipped) + + +def test_partial_level_bar_shorter_than_full() -> None: + """Partial level yields a shorter filled height than level 1.0.""" + import pygame as _pygame + from cleave.viz.timeline_overlay import bar_level_y -def test_visibility_segments_from_cues() -> None: + bar_rect = _pygame.Rect(0, 0, 100, 20) + assert bar_level_y(bar_rect, 0.25) > bar_level_y(bar_rect, 1.0) + assert bar_level_y(bar_rect, 0.0) == bar_rect.bottom + assert bar_level_y(bar_rect, 1.0) == bar_rect.y + + +def test_lane_level_segments_default_only() -> None: + lane = TimelineLane(baseline=0.0, cues=[]) + segments = lane_level_segments(lane, 60.0, inherit=1.0) + assert segments == [(0.0, 60.0, 0.0)] + + +def test_lane_level_segments_from_cues() -> None: lane = _lane(True, (10.0, False), (30.0, True)) - segments = visibility_segments(lane, 60.0, inherit=True) + segments = lane_level_segments(lane, 60.0, inherit=1.0) assert segments == [ - (0.0, 10.0, True), - (10.0, 30.0, False), - (30.0, 60.0, True), + (0.0, 10.0, 1.0), + (10.0, 30.0, 0.0), + (30.0, 60.0, 1.0), ] -def test_visibility_segments_other_stem_unchanged_across_unrelated_cue() -> None: +def test_lane_level_segments_other_stem_unchanged_across_unrelated_cue() -> None: lane = _lane(True) - segments = visibility_segments(lane, 20.0, inherit=True) - assert segments == [(0.0, 20.0, True)] + segments = lane_level_segments(lane, 20.0, inherit=1.0) + assert segments == [(0.0, 20.0, 1.0)] def test_cue_times_for_stem_lists_lane_transitions() -> None: @@ -790,18 +835,24 @@ def test_upscale_expands_bar_width_not_row_height() -> None: assert upscaled_panel[2] > baseline_panel[2] +def _bar_level_at( + state: TimelineViewState, + slot: str, + t: float, +) -> float: + """Return the strip level for a slot at time t from breakpoints.""" + from cleave.viz.timeline_overlay import level_at_breakpoints + + bps = bar_level_breakpoints_for_row(state, slot) + return level_at_breakpoints(bps, t) + + def _bar_visible_at( state: TimelineViewState, slot: str, t: float, ) -> bool: - """Return the bar's visibility value for a slot at time t.""" - segs = bar_segments_for_row(state, slot) - visible = False - for seg_start, seg_end, seg_visible in segs: - if seg_start <= t < seg_end: - visible = seg_visible - return visible + return _bar_level_at(state, slot, t) > LEVEL_EPS def test_bar_shows_fill_for_backward_skipped_range() -> None: @@ -815,7 +866,7 @@ def test_bar_shows_fill_for_backward_skipped_range() -> None: recording=True, record_start_sec=20.0, record_baseline={"layer_1": True}, - record_buffer={"layer_1": [SlotCue(t=20.0, visible=False)]}, + record_buffer={"layer_1": [SlotCue(t=20.0, level=0.0)]}, record_high_water_mark=30.0, ) assert _bar_visible_at(state, slot, 25.0) is False @@ -834,7 +885,7 @@ def test_bar_shows_fill_for_backward_seek_with_expanded_punch_start() -> None: recording=True, record_start_sec=10.0, record_baseline={"layer_1": False}, - record_buffer={"layer_1": [SlotCue(t=10.0, visible=True)]}, + record_buffer={"layer_1": [SlotCue(t=10.0, level=1.0)]}, record_high_water_mark=20.0, ) assert _bar_visible_at(state, slot, 15.0) is True @@ -853,7 +904,7 @@ def test_bar_without_high_water_mark_behaves_as_before() -> None: recording=True, record_start_sec=20.0, record_baseline={"layer_1": True}, - record_buffer={"layer_1": [SlotCue(t=20.0, visible=False)]}, + record_buffer={"layer_1": [SlotCue(t=20.0, level=0.0)]}, record_high_water_mark=None, ) assert _bar_visible_at(state, slot, 22.0) is False diff --git a/tests/cleave/viz/test_timeline_panel_cache.py b/tests/cleave/viz/test_timeline_panel_cache.py index c6c28ba..a75efaf 100644 --- a/tests/cleave/viz/test_timeline_panel_cache.py +++ b/tests/cleave/viz/test_timeline_panel_cache.py @@ -30,12 +30,18 @@ def test_static_signature_stable_for_same_state() -> None: assert _static_sig(state) == _static_sig(state) -def _lane( - baseline: bool | None, - *transitions: tuple[float, bool], -) -> TimelineLane: - cues = [SlotCue(t=t, visible=v) for t, v in transitions] - return TimelineLane(baseline=baseline, cues=canonicalize(baseline, cues)) +def _as_level(value): + if value is None: + return None + if isinstance(value, bool): + return 1.0 if value else 0.0 + return float(value) + + +def _lane(baseline, *transitions) -> TimelineLane: + base = _as_level(baseline) + cues = [SlotCue(t=t, level=float(_as_level(level))) for t, level in transitions] + return TimelineLane(baseline=base, cues=canonicalize(base, cues)) def test_static_signature_changes_on_cue_edit() -> None: @@ -48,7 +54,7 @@ def test_static_signature_changes_on_layer_add() -> None: four = _view_state() five = _view_state( layer_z_order=["layer_1", "layer_2", "layer_3", "layer_4", "layer_5"], - defaults={f"layer_{i}": True for i in range(1, 6)}, + defaults={f"layer_{i}": 1.0 for i in range(1, 6)}, ) assert _static_sig(four) != _static_sig(five, panel_h=200) @@ -72,14 +78,14 @@ def test_static_signature_changes_on_playhead_while_recording() -> None: recording=True, armed_slots={"layer_1"}, record_start_sec=10.0, - record_baseline={"layer_1": True}, + record_baseline={"layer_1": 1.0}, ) advanced = _view_state( position_sec=15.0, recording=True, armed_slots={"layer_1"}, record_start_sec=10.0, - record_baseline={"layer_1": True}, + record_baseline={"layer_1": 1.0}, ) assert _static_sig(at_start) != _static_sig(advanced) @@ -154,7 +160,7 @@ def test_compose_rebuilds_static_panel_when_recording_playhead_moves() -> None: recording=True, armed_slots={"layer_1"}, record_start_sec=10.0, - record_baseline={"layer_1": True}, + record_baseline={"layer_1": 1.0}, ) first = overlay.compose_panel(first_state, **kwargs) assert first is not None @@ -166,7 +172,7 @@ def test_compose_rebuilds_static_panel_when_recording_playhead_moves() -> None: recording=True, armed_slots={"layer_1"}, record_start_sec=10.0, - record_baseline={"layer_1": True}, + record_baseline={"layer_1": 1.0}, ) second = overlay.compose_panel(second_state, **kwargs) assert second is not None diff --git a/tests/cleave/viz/test_view_state_structure.py b/tests/cleave/viz/test_view_state_structure.py index 85fe581..48b38d6 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -526,7 +526,7 @@ def test_structure_signature_invalidates_on_beat_bar_grid_expanded() -> None: assert sig_before != sig_after -def test_structure_signature_invalidates_on_timeline_fades_expanded() -> None: +def test_structure_signature_invalidates_on_timeline_levels_expanded() -> None: controls = _make_controls(("layer_1",)) session = controls.session config_save = controls._config_save From 237d4a74db3fab5f2feccd6f97d269acb4df8ebd Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 26 Jul 2026 02:18:16 +0100 Subject: [PATCH 02/12] Complete phase 1b for rich cues --- .cursor/rules/live-tuning-ui.mdc | 2 +- .cursor/rules/preset-scan-rotation-set.mdc | 2 +- cleave/config_schema.py | 47 +- cleave/cue_roles.py | 20 + cleave/preset_curation.py | 5 +- cleave/preset_scan_targets.py | 19 +- cleave/timeline.py | 128 ++++- cleave/viz/app.py | 1 + cleave/viz/controls.py | 10 +- cleave/viz/editor_mode_controls.py | 1 - cleave/viz/help_content.py | 9 + cleave/viz/layer.py | 4 +- cleave/viz/layer_pipeline.py | 4 + cleave/viz/layer_visibility.py | 18 +- cleave/viz/live_layer_bindings.py | 2 - cleave/viz/preset_seed_controls.py | 4 + cleave/viz/preset_switching.py | 95 +++- cleave/viz/session.py | 2 + cleave/viz/timeline_controls.py | 123 +++++ cleave/viz/timeline_overlay.py | 444 +++++++++++++++++- cleave/viz/timeline_panel_cache.py | 16 +- cleave/viz/wiring.py | 11 +- docs/completed/presets-scan-plan.md | 5 +- docs/improved-timeline-presets.md | 27 +- tests/cleave/test_config.py | 98 ++++ tests/cleave/test_config_snapshot.py | 79 ++++ tests/cleave/test_preset_curation.py | 1 + tests/cleave/test_preset_scan_targets.py | 40 ++ tests/cleave/test_timeline.py | 130 +++++ tests/cleave/viz/test_controls.py | 20 + tests/cleave/viz/test_layer.py | 74 +++ tests/cleave/viz/test_preset_seed_controls.py | 8 +- tests/cleave/viz/test_preset_switching.py | 167 ++++++- tests/cleave/viz/test_timeline_controls.py | 219 ++++++++- tests/cleave/viz/test_timeline_overlay.py | 424 +++++++++++++++++ tests/cleave/viz/test_timeline_panel_cache.py | 72 +++ tests/cleave/viz/test_wiring.py | 2 +- tests/support/viz.py | 1 - 38 files changed, 2228 insertions(+), 106 deletions(-) create mode 100644 cleave/cue_roles.py diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index 0e8ce91..e5950db 100644 --- a/.cursor/rules/live-tuning-ui.mdc +++ b/.cursor/rules/live-tuning-ui.mdc @@ -56,7 +56,7 @@ Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always ## Timeline panel -Separate bottom overlay ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py), [cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)). Open with **t** (opens strip and enters submenu on row 0) or **Right** on **Render: TIMELINE** (strip only; **Down** enters submenu), including when `timeline.enabled` is false. **Left** on that header or **t** when the strip is open closes it and returns focus to **Render: TIMELINE**. Strip open does not hide the main overlay or route keys until `submenu_focused` (**t** from the main tree sets this immediately; **Down** from the header when the strip is already open). When the strip is open, **Up**/**Down** use one focus ring ([cleave/viz/focus_nav.py](cleave/viz/focus_nav.py)): main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, with uniform modulo wrap. **Down** from the last timeline row wraps to **Settings**; **Up** from **Settings** wraps to the last timeline row. **Up** at timeline row 0 returns focus to **Render: TIMELINE** without closing the strip. **Up**/**Down** always route through the main tuning controls; other timeline keys route to the timeline strip when `submenu_focused`. **Esc** or **t** while in the submenu closes the strip and returns focus to **Render: TIMELINE**. Rows follow `layer_z_order`; labels use stem abbreviations (D/B/V/O). Each track owns a lane (`TimelineLane`: explicit `baseline` plus ordered single-slot `SlotCue(t, level)` transitions in [cleave/timeline.py](cleave/timeline.py)); edits (`punch_lane`, snap, presets) are lane-local. Each row shows a monitor eye beside the label, the cue bar, and a committed-timeline eye at the far right: **left** = monitor/output (`effective_layer_enabled`; gold `OVERRIDE_BG` when stem is in `TimelineRuntime.override_slots` or when recording and the row is armed). Armed rows during record use `record_baseline` + per-slot `record_buffer` toggles only, not committed lane cues. Record start baseline is not drawn on the bar; only transition cues in that slot's `record_buffer` show ticks. **right** = committed lane level at playhead (`timeline_committed_level`; eye alpha follows the level; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **a** arms a row (`ARMED_BG` red fill, distinct from override eye). **Space** pause snapshots current output into `monitor` and sets `preview_active` (resume clears both); while recording and playing, **Space** stops the take and pauses instead. Num keys **1**-**8** (main row and numpad) toggle layer visibility while paused (preview via `monitor` or override via `override_visible`; adds stem to override when needed), write record buffer when recording (armed only), toggle `override_visible` while playing for stems in `override_slots` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_slots` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip uses **Shift+Left** / **Shift+Right** for 2s transport seek (not solo). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed lanes in range (playback keeps running). **Ctrl+Space** starts record the same way; while recording it stops the take and pauses (no preview). `preview_active` / `monitor` / `override_slots` / `override_visible` are session-only on `TimelineRuntime`. Per-lane state persists under root `timeline.lanes` in YAML (written last in snapshots). The strip draws each lane as a variable-height level bar from `lane_level_breakpoints` (bottom-filled polygons between breakpoints; remainder dark so a level-0 lane still reads as a full-height bed; colour lerps by mean segment level). Fade groups shape the breakpoint slopes; cue ticks and the bar grid are unchanged. +Separate bottom overlay ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py), [cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)). Open with **t** (opens strip and enters submenu on row 0) or **Right** on **Render: TIMELINE** (strip only; **Down** enters submenu), including when `timeline.enabled` is false. **Left** on that header or **t** when the strip is open closes it and returns focus to **Render: TIMELINE**. Strip open does not hide the main overlay or route keys until `submenu_focused` (**t** from the main tree sets this immediately; **Down** from the header when the strip is already open). When the strip is open, **Up**/**Down** use one focus ring ([cleave/viz/focus_nav.py](cleave/viz/focus_nav.py)): main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, with uniform modulo wrap. **Down** from the last timeline row wraps to **Settings**; **Up** from **Settings** wraps to the last timeline row. **Up** at timeline row 0 returns focus to **Render: TIMELINE** without closing the strip. **Up**/**Down** always route through the main tuning controls; other timeline keys route to the timeline strip when `submenu_focused`. **Esc** or **t** while in the submenu closes the strip and returns focus to **Render: TIMELINE**. Rows follow `layer_z_order`; labels use stem abbreviations (D/B/V/O). Each track owns a lane (`TimelineLane`: explicit `baseline` plus ordered `SlotCue(t, level, blend?, role?)` transitions in [cleave/timeline.py](cleave/timeline.py)); `level` is required; optional `blend` is held state (inherits the layer static blend when `None`); optional `role` (`bed` / `pulse` / `lead` / `accent`) is an on-transition cast into `preset_root/roles//`. Edits (`punch_lane`, snap, presets, cue authoring) are lane-local. Each row shows a monitor eye beside the label, the cue bar, and a committed-timeline eye at the far right: **left** = monitor/output (`effective_layer_enabled`; gold `OVERRIDE_BG` when stem is in `TimelineRuntime.override_slots` or when recording and the row is armed). Armed rows during record use `record_baseline` + per-slot `record_buffer` toggles only, not committed lane cues. Record start baseline is not drawn on the bar; only transition cues in that slot's `record_buffer` show ticks. **right** = committed lane level at playhead (`timeline_committed_level`; eye alpha follows the level; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **a** arms a row (`ARMED_BG` red fill, distinct from override eye). **Space** pause snapshots current output into `monitor` and sets `preview_active` (resume clears both); while recording and playing, **Space** stops the take and pauses instead. Num keys **1**-**8** (main row and numpad) toggle layer visibility while paused (preview via `monitor` or override via `override_visible`; adds stem to override when needed), write record buffer when recording (armed only), toggle `override_visible` while playing for stems in `override_slots` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_slots` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip uses **Shift+Left** / **Shift+Right** for 2s transport seek (not solo). **`,`** / **`.`** select the previous / next on cue (`level > LEVEL_EPS`, including mid-on level changes) on the focused lane; pure off cues are skipped. First press (or when selection is missing / on an off) picks the navigable cue nearest the playhead. **b** / **Shift+B** cycle the selected on cue's blend through inherit (`None`) then `BLEND_MODES`; **c** / **Shift+C** cycle its role (cast) through `None` then `CUE_ROLES`; both no-op on offs. Cast and blend are authored for the next on / visible period (like preset switching at on-transition); `canonicalize` strips `blend` and `role` from off cues. Cue selection and blend/role edits are refused while the timeline is locked or recording. Selection is session-only on `TimelineRuntime.selected_cue_t` (keyed by slot; per-track memory). The selected tick, flash, and badge readout draw only for the focused timeline row; other slots keep their remembered `selected_cue_t` and restore the settled yellow highlight when focus returns (flash restarts only when `,` / `.` changes the cue on that track). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed lanes in range (playback keeps running). **Ctrl+Space** starts record the same way; while recording it stops the take and pauses (no preview). `preview_active` / `monitor` / `override_slots` / `override_visible` / `selected_cue_t` are session-only on `TimelineRuntime`. Per-lane state persists under root `timeline.lanes` in YAML (written last in snapshots). The strip draws each lane as a variable-height level bar from `lane_level_breakpoints` (bottom-filled polygons between breakpoints; remainder dark so a level-0 lane still reads as a full-height bed; colour lerps by mean segment level). Fade groups shape the breakpoint slopes; cue ticks and the bar grid are unchanged. The selected cue's tick draws full row height in `HIGHLIGHT` at `SELECTED_CUE_TICK_WIDTH` (thinner than the flash width, thicker than normal ticks) and live-patches a 3s thick HIGHLIGHT/`ARMED_BG` (yellow/red) blink at `SELECTED_CUE_FLASH_TICK_WIDTH` via `selected_cue_flash_start_ms` (same cadence as arm flash); after the flash expires it settles on the thinner yellow tick; on cues with a role draw a bold single-letter glyph (`B` / `P` / `L` / `A`) at the bottom of the level bar, offset left of off edges and right of on edges (inside the enabled segment) via per-pixel RGB XOR (pygame 2.6 has no `BLEND_XOR`), painted last after bars, ticks, markers, and the playhead (never on off cues); the badge strip shows a selected-cue readout (`[mm:ss] lvl 1.00 blend add cast lead`, with `-` for unset blend/role). ## Header rows section diff --git a/.cursor/rules/preset-scan-rotation-set.mdc b/.cursor/rules/preset-scan-rotation-set.mdc index b98bc24..7c22f67 100644 --- a/.cursor/rules/preset-scan-rotation-set.mdc +++ b/.cursor/rules/preset-scan-rotation-set.mdc @@ -20,7 +20,7 @@ Plan: [docs/legacy-plans/presets-scan-plan.md](docs/legacy-plans/presets-scan-pl - Rotation set (`preset_switching_rotation_set`): `directory` | `user_defined` (used when mode is `projectm` or `timeline`). - **directory** live rotation: immediate `*.milk` siblings in `playlist.current_dir`, **non-recursive** (`recurse=False`), then `sort()`. - **user_defined** live rotation: `preset_switching_presets` via `add_presets(..., allow_duplicates=True)` (no sort). -- Project scan includes the anchor directory for **every** layer (even `preset_switching: none` or `enabled: false`), plus `preset_switching_presets` when `preset_switching_rotation_set` is `user_defined`. +- Project scan includes the anchor directory for **every** layer (even `preset_switching: none` or `enabled: false`), plus `preset_switching_presets` when `preset_switching_rotation_set` is `user_defined`, plus non-recursive `*.milk` in each `preset_root/roles//` pool (`bed`, `pulse`, `lead`, `accent`). Role pools are part of the live rotation surface: timeline cues with a `role` cast from those directories via `role_rotations` in [cleave/viz/preset_switching.py](cleave/viz/preset_switching.py), so scan must enumerate the same files. - Bulk mode uses `--presets-dir` with optional `--recursive`; project mode does not recurse into subfolders. ## When rotation set expands diff --git a/cleave/config_schema.py b/cleave/config_schema.py index 32a6787..a4857b5 100644 --- a/cleave/config_schema.py +++ b/cleave/config_schema.py @@ -10,6 +10,7 @@ from typing import Any, Callable, Literal, TypeVar from cleave.blend_modes import BLEND_MODES, BlendMode +from cleave.cue_roles import CUE_ROLES, CueRole from cleave.effects.constants import clamp_effect_pct from cleave.effects.registry import validate_effect_entry from cleave.extract import STEM_SOURCES, StemSource @@ -1613,14 +1614,25 @@ def persist_layer_z_order(ctx: PersistCtx) -> list[str]: return cfg_order +def validate_blend_mode(raw: Any, *, path: str) -> BlendMode: + if raw not in BLEND_MODES: + allowed = ", ".join(f"'{mode}'" for mode in BLEND_MODES) + raise ValueError(f"{path} must be one of: {allowed}") + return raw + + +def validate_cue_role(raw: Any, *, path: str) -> CueRole: + if raw not in CUE_ROLES: + allowed = ", ".join(f"'{role}'" for role in CUE_ROLES) + raise ValueError(f"{path} must be one of: {allowed}") + return raw + + def parse_blend_mode(slot: str, stem: StemSource, layer_raw: dict[str, Any]) -> BlendMode: raw = layer_raw.get("blend_mode") if raw is None: return DEFAULT_BLEND_MODE[stem] - if raw not in BLEND_MODES: - allowed = ", ".join(f"'{mode}'" for mode in BLEND_MODES) - raise ValueError(f"layers.{slot}.blend_mode must be one of: {allowed}") - return raw + return validate_blend_mode(raw, path=f"layers.{slot}.blend_mode") def _parse_stem(slot: str, layer_raw: dict[str, Any]) -> StemSource: @@ -2172,10 +2184,24 @@ def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None: f"timeline.lanes.{slot}.cues[{index}].t", ) ) + blend: BlendMode | None = None + if "blend" in cue_map and cue_map["blend"] is not None: + blend = validate_blend_mode( + cue_map["blend"], + path=f"timeline.lanes.{slot}.cues[{index}].blend", + ) + role: CueRole | None = None + if "role" in cue_map and cue_map["role"] is not None: + role = validate_cue_role( + cue_map["role"], + path=f"timeline.lanes.{slot}.cues[{index}].role", + ) cues.append( SlotCue( t=t, level=clamp_level(float(cue_map["level"])), + blend=blend, + role=role, ) ) lanes[str(slot)] = TimelineLane( @@ -2225,10 +2251,15 @@ def persist_timeline(ctx: PersistCtx) -> dict[str, Any]: if lane.baseline is not None: entry["baseline"] = lane.baseline if lane.cues: - entry["cues"] = [ - {"t": cue.t, "level": cue.level} - for cue in lane.cues - ] + cues_out: list[dict[str, Any]] = [] + for cue in lane.cues: + cue_out: dict[str, Any] = {"t": cue.t, "level": cue.level} + if cue.blend is not None: + cue_out["blend"] = cue.blend + if cue.role is not None: + cue_out["role"] = cue.role + cues_out.append(cue_out) + entry["cues"] = cues_out lanes_out[slot] = entry if lanes_out: out["lanes"] = lanes_out diff --git a/cleave/cue_roles.py b/cleave/cue_roles.py new file mode 100644 index 0000000..3bec726 --- /dev/null +++ b/cleave/cue_roles.py @@ -0,0 +1,20 @@ +"""Cue role labels and role-pool preset paths under ``preset_root/roles/``.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +CueRole = Literal["bed", "pulse", "lead", "accent"] + +CUE_ROLES: tuple[CueRole, ...] = ("bed", "pulse", "lead", "accent") + +CUE_ROLE_DIR = "roles" + + +def role_pool_paths(preset_root: Path, role: CueRole) -> tuple[Path, ...]: + """Sorted non-recursive ``*.milk`` under ``preset_root/roles//``. + + Same rule as ``milk_files_in_dir`` in ``cleave.preset_playlist``. + """ + return tuple(sorted((preset_root / CUE_ROLE_DIR / role).glob("*.milk"))) diff --git a/cleave/preset_curation.py b/cleave/preset_curation.py index f5d48ff..d6dcc11 100644 --- a/cleave/preset_curation.py +++ b/cleave/preset_curation.py @@ -7,6 +7,7 @@ from dataclasses import dataclass from pathlib import Path +from cleave.cue_roles import CUE_ROLE_DIR from cleave.viz.user_presets import copy_with_dedup, resolve_user_preset_dest FAVOURITES_DIR = "favourites" @@ -111,11 +112,11 @@ def list_destination_subdirs(base: Path) -> tuple[str, ...]: def list_restore_destination_subdirs(preset_root: Path) -> tuple[str, ...]: - """Top-level dirs under ``preset_root`` excluding favourites/ and blacklist/.""" + """Top-level dirs under ``preset_root`` excluding curation and role pools.""" return tuple( name for name in list_destination_subdirs(preset_root) - if name not in (FAVOURITES_DIR, BLACKLIST_DIR) + if name not in (FAVOURITES_DIR, BLACKLIST_DIR, CUE_ROLE_DIR) ) diff --git a/cleave/preset_scan_targets.py b/cleave/preset_scan_targets.py index d4b5e31..e036c42 100644 --- a/cleave/preset_scan_targets.py +++ b/cleave/preset_scan_targets.py @@ -6,6 +6,7 @@ from pathlib import Path from cleave.config import CleaveConfig +from cleave.cue_roles import CUE_ROLES, role_pool_paths from cleave.preset_playlist import milk_files_in_dir, scan_preset_playlist @@ -32,6 +33,7 @@ def build_project_targets(cfg: CleaveConfig) -> ScanTargets: """Collect presets from on-disk config, matching live rotation set rules.""" by_path: dict[Path, list[str]] = {} layer_sources: dict[str, tuple[Path, ...]] = {} + preset_root = cfg.paths.preset_root for slot in cfg.layer_z_order: layer = cfg.layers.get(slot) @@ -52,10 +54,25 @@ def build_project_targets(cfg: CleaveConfig) -> ScanTargets: layer_sources[slot] = tuple(sources) + # Role pools under preset_root/roles// are part of timeline casting. + role_slots = tuple(layer_sources) + for role in CUE_ROLES: + pool = role_pool_paths(preset_root, role) + if not pool: + continue + role_dir = pool[0].parent.resolve() + for slot in role_slots: + sources = list(layer_sources[slot]) + if role_dir not in sources: + sources.append(role_dir) + layer_sources[slot] = tuple(sources) + for preset_path in pool: + _register_preset(by_path, preset_path, slot) + presets = _finalize_presets(by_path) return ScanTargets( presets=presets, - preset_root=cfg.paths.preset_root.resolve(), + preset_root=preset_root.resolve(), texture_paths=tuple(p.resolve() for p in cfg.paths.texture_paths), layer_sources=layer_sources, ) diff --git a/cleave/timeline.py b/cleave/timeline.py index 73d47aa..52c4df3 100644 --- a/cleave/timeline.py +++ b/cleave/timeline.py @@ -3,11 +3,13 @@ from __future__ import annotations from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import Literal import numpy as np +from cleave.blend_modes import BlendMode +from cleave.cue_roles import CueRole from cleave.easing import smoothstep from cleave.extract import STEM_SOURCES, StemSource @@ -29,6 +31,8 @@ class SlotCue: t: float level: float + blend: BlendMode | None = None + role: CueRole | None = None @dataclass @@ -66,14 +70,31 @@ def levels_equal(a: float, b: float) -> bool: return abs(float(a) - float(b)) <= LEVEL_EPS +def cue_editable_for_blend_role(cue: SlotCue) -> bool: + """True when ``cue`` may carry blend/role and is in the strip nav set. + + Off cues (``level <= LEVEL_EPS``) are level transitions only; cast and blend + are authored on the next on / visible period. + """ + return float(cue.level) > LEVEL_EPS + + +def navigable_cue_times(lane: TimelineLane) -> list[float]: + """Cue times eligible for ``,`` / ``.`` selection (level above off).""" + return [cue.t for cue in lane.cues if cue_editable_for_blend_role(cue)] + + def canonicalize( baseline: float | None, cues: Sequence[SlotCue], ) -> list[SlotCue]: """Drop redundant/no-op transitions; last-wins at equal ``t``. - Returns strictly increasing ``t`` cues where each changes level from - the previous state (``baseline``, or the prior cue when baseline is None). + Returns strictly increasing ``t`` cues where each changes level and/or + blend from the previous state (``baseline`` with blend inherit ``None``, + or the prior cue when baseline is None). Role is not part of the + comparison. Off cues (``level <= LEVEL_EPS``) always have ``blend`` and + ``role`` cleared before compare and emit. """ if not cues: return [] @@ -85,12 +106,20 @@ def canonicalize( else: collapsed.append(cue) result: list[SlotCue] = [] - current = baseline + current_level = baseline + current_blend: BlendMode | None = None for cue in collapsed: - if current is not None and levels_equal(cue.level, current): + if not cue_editable_for_blend_role(cue): + cue = replace(cue, blend=None, role=None) + if ( + current_level is not None + and levels_equal(cue.level, current_level) + and cue.blend == current_blend + ): continue result.append(cue) - current = cue.level + current_level = cue.level + current_blend = cue.blend return result @@ -109,6 +138,20 @@ def lane_level_at( return level +def lane_blend_at(lane: TimelineLane, t_sec: float) -> BlendMode | None: + """Held blend at ``t_sec``: last cue's ``blend`` at or before ``t``. + + ``None`` means inherit the layer's static ``blend_mode``. A later cue may + set ``blend`` back to ``None`` to revert. + """ + blend: BlendMode | None = None + for cue in lane.cues: + if cue.t > t_sec: + break + blend = cue.blend + return blend + + def lane_level_segments( lane: TimelineLane, duration_sec: float, @@ -285,14 +328,14 @@ def lane_tick_times(lane: TimelineLane, duration_sec: float) -> list[float]: ) -def lane_on_transition_trigger_times( +def lane_on_transition_cues( lane: TimelineLane, *, song_marker_times: Sequence[float] = (), song_marker_fades: TimelineFadeGroup, standard_fades: TimelineFadeGroup, -) -> list[float]: - """Preset-switch trigger times for each rise from zero. +) -> list[tuple[float, SlotCue]]: + """Preset-switch ``(trigger_t, cue)`` pairs for each rise from zero. Fires when ``previous <= LEVEL_EPS < cue.level``, with ``previous`` from ``baseline`` or ``0.0`` when baseline is None. Trigger is @@ -300,7 +343,7 @@ def lane_on_transition_trigger_times( group for that edge. When the matching group is disabled or ``fade_in`` is 0, the trigger is ``cue.t``. """ - triggers: list[float] = [] + results: list[tuple[float, SlotCue]] = [] previous = 0.0 if lane.baseline is None else float(lane.baseline) for cue in lane.cues: if previous <= LEVEL_EPS < cue.level: @@ -314,9 +357,28 @@ def lane_on_transition_trigger_times( ramp = 0.0 else: ramp = max(0.0, float(group.fade_in)) * float(cue.level) - triggers.append(cue.t - ramp) + results.append((cue.t - ramp, cue)) previous = float(cue.level) - return triggers + return results + + +def lane_on_transition_trigger_times( + lane: TimelineLane, + *, + song_marker_times: Sequence[float] = (), + song_marker_fades: TimelineFadeGroup, + standard_fades: TimelineFadeGroup, +) -> list[float]: + """Preset-switch trigger times for each rise from zero.""" + return [ + trigger + for trigger, _cue in lane_on_transition_cues( + lane, + song_marker_times=song_marker_times, + song_marker_fades=song_marker_fades, + standard_fades=standard_fades, + ) + ] def lane_on_transition_count( @@ -376,11 +438,37 @@ def set_lane_cue( t: float, level: float, ) -> TimelineLane: - """Set or replace the transition at ``t``; canonicalize.""" + """Set or replace the transition at ``t``; canonicalize. + + Replacing an existing cue keeps its ``blend`` and ``role``. + """ others = [cue for cue in lane.cues if cue.t != t] + existing = next((cue for cue in lane.cues if cue.t == t), None) + if existing is not None: + new_cue = replace(existing, level=level) + else: + new_cue = SlotCue(t=t, level=level) + return TimelineLane( + baseline=lane.baseline, + cues=canonicalize(lane.baseline, others + [new_cue]), + ) + + +def update_lane_cue( + lane: TimelineLane, + cue_t: float, + *, + blend: BlendMode | None, + role: CueRole | None, +) -> TimelineLane: + """Update ``blend`` / ``role`` on the cue at ``cue_t`` without changing level.""" + updated = [ + replace(cue, blend=blend, role=role) if cue.t == cue_t else cue + for cue in lane.cues + ] return TimelineLane( baseline=lane.baseline, - cues=canonicalize(lane.baseline, others + [SlotCue(t=t, level=level)]), + cues=canonicalize(lane.baseline, updated), ) @@ -465,11 +553,11 @@ def shift_lane_cues_by_beats( beats = np.asarray(beat_times, dtype=np.float64) last = len(beats) - 1 shifted = [ - SlotCue( + replace( + cue, t=float( beats[max(0, min(last, _nearest_beat_index(cue.t, beats) + delta))] ), - level=cue.level, ) for cue in lane.cues ] @@ -490,7 +578,7 @@ def snap_lane_to_beats( beats = np.asarray(beat_times, dtype=np.float64) if beats.size == 1: sole = float(beats[0]) - snapped = [SlotCue(t=sole, level=cue.level) for cue in lane.cues] + snapped = [replace(cue, t=sole) for cue in lane.cues] return TimelineLane( baseline=lane.baseline, cues=canonicalize(lane.baseline, snapped), @@ -516,7 +604,7 @@ def snap_t(t: float) -> float: (first + lo * interval, first + (lo + 1) * interval), ) - snapped = [SlotCue(t=snap_t(cue.t), level=cue.level) for cue in lane.cues] + snapped = [replace(cue, t=snap_t(cue.t)) for cue in lane.cues] return TimelineLane( baseline=lane.baseline, cues=canonicalize(lane.baseline, snapped), @@ -581,7 +669,7 @@ def snap_lanes_to_song_markers( if best_i is None: continue old = cues[best_i] - cues[best_i] = SlotCue(t=marker, level=old.level) + cues[best_i] = replace(old, t=marker) claimed.add(best_i) moved += 1 else: @@ -604,7 +692,7 @@ def snap_lanes_to_song_markers( continue _dist, _t, _z, slot, cue_i = best old = working[slot][cue_i] - working[slot][cue_i] = SlotCue(t=marker, level=old.level) + working[slot][cue_i] = replace(old, t=marker) claimed_pairs.add((slot, cue_i)) moved += 1 diff --git a/cleave/viz/app.py b/cleave/viz/app.py index fb4737e..c4bdf78 100644 --- a/cleave/viz/app.py +++ b/cleave/viz/app.py @@ -241,6 +241,7 @@ def report(message: str) -> None: layers=layers, signals=seed.signals, effect_runtime=seed.effect_runtime, + preset_root=seed.preset_root, mix_player=mix_player, on_notification=controls.show_notification, tuning_controls=controls, diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index 3539de5..412fd83 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -176,6 +176,7 @@ def __init__( session, self._modal_host, layers_by_slot, + preset_root=preset_root, on_preset_switching_change=on_switching, ) self._timeline_phase = TimelinePhaseController( @@ -747,7 +748,12 @@ def _apply_focus_cursor(self, cursor: FocusCursor) -> None: self._editor_mode.sync_selection_to_mode() self._focus_cursor = cursor if isinstance(cursor, TimelineFocus): - self.session.timeline.focus_row = cursor.row + tl = self.session.timeline + if cursor.row != tl.focus_row: + # Drop in-progress cue flash so returning to a remembered + # selection shows the settled tick, not a restart blink. + tl.selected_cue_flash_start_ms = None + tl.focus_row = cursor.row return if isinstance(cursor, MainFocus): desc = cursor.descriptor @@ -1168,8 +1174,6 @@ def _cycle_blend(self, slot: str, *, forward: bool) -> None: layer.blend_mode = BLEND_MODES[(index + 1) % len(BLEND_MODES)] else: layer.blend_mode = BLEND_MODES[(index - 1) % len(BLEND_MODES)] - if self._layer_bindings is not None: - self._layer_bindings.on_blend_change(slot, layer.blend_mode) def _cycle_stem(self, slot: str, *, forward: bool) -> None: layer = self.session.layers[slot] diff --git a/cleave/viz/editor_mode_controls.py b/cleave/viz/editor_mode_controls.py index 47b6de7..38bfb00 100644 --- a/cleave/viz/editor_mode_controls.py +++ b/cleave/viz/editor_mode_controls.py @@ -265,7 +265,6 @@ def _sync_live_layers(self, playlists: dict[str, PresetPlaylist]) -> None: layer.playlist = playlist bindings.on_preset_change(slot, playlist) bindings.on_stem_change(slot, layer.stem) - bindings.on_blend_change(slot, layer.blend_mode) bindings.on_opacity_change(slot, layer.opacity_pct) bindings.on_layer_enabled_change(slot, layer.enabled) bindings.on_beat_change(slot, layer.beat_sensitivity) diff --git a/cleave/viz/help_content.py b/cleave/viz/help_content.py index 983aa37..9230f49 100644 --- a/cleave/viz/help_content.py +++ b/cleave/viz/help_content.py @@ -221,6 +221,15 @@ def timeline_strip_section( ) ) + if not recording: + entries.extend( + ( + (", / .", "select previous / next on cue"), + ("B / Shift+B", "cycle on-cue blend"), + ("C / Shift+C", "cycle on-cue cast"), + ) + ) + entries.append(("Esc", "close timeline")) return HelpSection("Timeline", tuple(entries)) diff --git a/cleave/viz/layer.py b/cleave/viz/layer.py index d2df15d..6664cc8 100644 --- a/cleave/viz/layer.py +++ b/cleave/viz/layer.py @@ -2,9 +2,10 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path +from cleave.cue_roles import CueRole from cleave.gl_compositor import LayerFbo from cleave.preset_playlist import PresetPlaylist from cleave.preset_rotation import PresetRotation @@ -22,5 +23,6 @@ class StemLayer: auto_preset_path: Path | None = None timeline_level: float = 1.0 preset_rotation: PresetRotation | None = None + role_rotations: dict[CueRole, PresetRotation] = field(default_factory=dict) timeline_switch_count: int = 0 rotation_anchor: int = 0 diff --git a/cleave/viz/layer_pipeline.py b/cleave/viz/layer_pipeline.py index 5171682..fbc3cc1 100644 --- a/cleave/viz/layer_pipeline.py +++ b/cleave/viz/layer_pipeline.py @@ -197,6 +197,7 @@ def build_single( *, width: int, height: int, + preset_root: Path, ) -> StemLayer: w, h = width, height @@ -238,6 +239,7 @@ def build_single( hard_cut_sensitivity=layer_cfg.hard_cut_sensitivity, shuffle=layer_cfg.preset_switching_shuffle, shuffle_salt=layer_cfg.preset_switching_shuffle_salt, + preset_root=preset_root, ) return layer @@ -286,6 +288,7 @@ def layer_size(slot: str) -> tuple[int, int]: z_index = z_order.index(slot) return render_layer_size(cfg, z_index, viz_quality=viz_quality) + preset_root = cfg.paths.preset_root for slot, layer_cfg in cfg.layers_in_z_order(): width, height = layer_size(slot) runtimes.append( @@ -299,6 +302,7 @@ def layer_size(slot: str) -> tuple[int, int]: _beat_sensitivity(cfg, slot), width=width, height=height, + preset_root=preset_root, ) ) diff --git a/cleave/viz/layer_visibility.py b/cleave/viz/layer_visibility.py index 7edfb95..412b211 100644 --- a/cleave/viz/layer_visibility.py +++ b/cleave/viz/layer_visibility.py @@ -13,6 +13,7 @@ canonicalize, copy_lane, empty_lane, + lane_blend_at, lane_level_at, lane_level_breakpoints, lane_level_envelope, @@ -29,7 +30,11 @@ cursor_timeline_submenu_focused, ) from cleave.viz.row_semantics import RowKind -from cleave.viz.timeline_overlay import TimelineViewState, prune_expired_arm_flashes +from cleave.viz.timeline_overlay import ( + TimelineViewState, + prune_expired_arm_flashes, + prune_expired_selected_cue_flash, +) if TYPE_CHECKING: from cleave.viz.layer import StemLayer @@ -275,9 +280,15 @@ def apply_layer_visibility( level = timeline_level_multiplier(session, slot, t_sec) layer.timeline_level = level layer.fbo.enabled = level > LEVEL_EPS + cue_blend = lane_blend_at(_lane_for_slot(session, slot), t_sec) + if cue_blend is not None: + layer.fbo.blend_mode = cue_blend + else: + layer.fbo.blend_mode = session.layers[slot].blend_mode else: layer.timeline_level = 1.0 layer.fbo.enabled = effective_layer_enabled(session, slot, t_sec) + layer.fbo.blend_mode = session.layers[slot].blend_mode def build_timeline_view_state( @@ -291,6 +302,9 @@ def build_timeline_view_state( ) -> TimelineViewState: tl = session.timeline prune_expired_arm_flashes(tl.arm_flash_start_ms) + tl.selected_cue_flash_start_ms = prune_expired_selected_cue_flash( + tl.selected_cue_flash_start_ms + ) submenu_focused = ( focus_cursor is not None and cursor_timeline_submenu_focused(focus_cursor) ) @@ -341,4 +355,6 @@ def build_timeline_view_state( selected_song_marker_index=focused_song_marker_index(focus_cursor), song_marker_fades=_as_fade_group(tl.song_marker_fades), standard_cue_fades=_as_fade_group(tl.standard_cue_fades), + selected_cue_t=dict(tl.selected_cue_t), + selected_cue_flash_start_ms=tl.selected_cue_flash_start_ms, ) diff --git a/cleave/viz/live_layer_bindings.py b/cleave/viz/live_layer_bindings.py index aea7d15..020f3ec 100644 --- a/cleave/viz/live_layer_bindings.py +++ b/cleave/viz/live_layer_bindings.py @@ -5,7 +5,6 @@ from collections.abc import Callable from dataclasses import dataclass -from cleave.blend_modes import BlendMode from cleave.extract import StemSource from cleave.preset_playlist import PresetPlaylist @@ -16,7 +15,6 @@ class LiveLayerBindings: on_preset_switching_change: Callable[[str], None] lock_preset_for_modal: Callable[[str], None] unlock_preset_after_modal: Callable[[str], None] - on_blend_change: Callable[[str, BlendMode], None] on_stem_change: Callable[[str, StemSource], None] on_opacity_change: Callable[[str, int], None] on_layer_enabled_change: Callable[[str, bool], None] diff --git a/cleave/viz/preset_seed_controls.py b/cleave/viz/preset_seed_controls.py index 18e051a..7621fcb 100644 --- a/cleave/viz/preset_seed_controls.py +++ b/cleave/viz/preset_seed_controls.py @@ -4,6 +4,7 @@ import random from collections.abc import Callable, Mapping +from pathlib import Path from cleave.viz.layer import StemLayer from cleave.viz.modal import ModalHost @@ -29,11 +30,13 @@ def __init__( modal_host: ModalHost, layers_by_slot: Mapping[str, StemLayer], *, + preset_root: Path, on_preset_switching_change: Callable[[str], None] | None = None, ) -> None: self.session = session self._modal = modal_host self._layers_by_slot = layers_by_slot + self._preset_root = preset_root self._on_preset_switching_change = on_preset_switching_change def prompt(self, slot: str) -> None: @@ -71,6 +74,7 @@ def _rebuild(self, slot: str, runtime: LayerRuntime) -> None: shuffle=runtime.preset_switching_shuffle, shuffle_salt=runtime.preset_switching_shuffle_salt, preset_start_clean=runtime.preset_start_clean, + preset_root=self._preset_root, ) return if self._on_preset_switching_change is not None: diff --git a/cleave/viz/preset_switching.py b/cleave/viz/preset_switching.py index c5b2076..a212f76 100644 --- a/cleave/viz/preset_switching.py +++ b/cleave/viz/preset_switching.py @@ -18,6 +18,7 @@ PresetSwitchingMode, PresetSwitchingRotationSet, ) +from cleave.cue_roles import CUE_ROLES, CueRole, role_pool_paths from cleave.preset_playlist import milk_files_in_dir from cleave.preset_rotation import ( PresetRotation, @@ -30,6 +31,7 @@ TimelineFadeGroup, empty_lane, lane_on_transition_count, + lane_on_transition_cues, ) from cleave.viz.layer import StemLayer @@ -59,14 +61,80 @@ def _apply_projectm_timing( def _clear_timeline_rotation(layer: StemLayer) -> None: layer.preset_rotation = None + layer.role_rotations = {} layer.timeline_switch_count = 0 layer.rotation_anchor = 0 +def _build_role_rotations( + layer: StemLayer, + preset_root: Path, + *, + shuffle: bool, + shuffle_salt: int, +) -> dict[CueRole, PresetRotation]: + role_rotations: dict[CueRole, PresetRotation] = {} + for role in CUE_ROLES: + pool = role_pool_paths(preset_root, role) + if not pool: + continue + role_rotations[role] = PresetRotation( + paths=pool, + shuffle=shuffle, + seed=layer_rotation_seed( + pool, slot=layer.slot, shuffle_salt=shuffle_salt + ), + anchor=0, + ) + return role_rotations + + +def _timeline_path_for_count( + layer: StemLayer, + *, + count: int, + lane, + song_marker_times: Sequence[float], + song_marker_fades: TimelineFadeGroup, + standard_fades: TimelineFadeGroup, +) -> Path | None: + """Seek-stable path for transition ``count`` (main or role pool).""" + rotation = layer.preset_rotation + if rotation is None or count < 1: + return None if rotation is None else rotation.path_for(count) + + cues = lane_on_transition_cues( + lane, + song_marker_times=song_marker_times, + song_marker_fades=song_marker_fades, + standard_fades=standard_fades, + ) + if count > len(cues): + return rotation.path_for(count) + + crossed = cues[count - 1][1] + role = crossed.role + if role is None: + return rotation.path_for(count) + + role_rotation = layer.role_rotations.get(role) + if role_rotation is None: + return rotation.path_for(count) + + earlier_same = sum( + 1 for _trigger, cue in cues[: count - 1] if cue.role == role + ) + path = role_rotation.path_for(earlier_same) + if path is None: + return rotation.path_for(count) + return path + + def reapply_projectm_preset_switching( session, layers_by_slot: dict[str, StemLayer], *, + preset_root: Path, delta_sec: float = 0.0, ) -> None: """Re-attach projectM playlist switching after seek without reloading browse preset.""" @@ -93,6 +161,7 @@ def reapply_projectm_preset_switching( hard_cut_enabled=runtime.hard_cut_enabled, hard_cut_duration=runtime.hard_cut_duration, hard_cut_sensitivity=runtime.hard_cut_sensitivity, + preset_root=preset_root, ) continue _reapply_on_seek( @@ -113,6 +182,7 @@ def apply_preset_switching( *, mode: PresetSwitchingMode, rotation_set: PresetSwitchingRotationSet, + preset_root: Path, user_presets: list[str] | None = None, shuffle: bool = DEFAULT_PRESET_SWITCHING_SHUFFLE, shuffle_salt: int = DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT, @@ -151,6 +221,7 @@ def apply_preset_switching( shuffle=shuffle, shuffle_salt=shuffle_salt, preset_start_clean=preset_start_clean, + preset_root=preset_root, ) return _apply_timeline_preset_switching( @@ -160,6 +231,7 @@ def apply_preset_switching( shuffle=shuffle, shuffle_salt=shuffle_salt, preset_start_clean=preset_start_clean, + preset_root=preset_root, on_empty=on_empty, ) return @@ -239,6 +311,7 @@ def _apply_timeline_preset_switching( shuffle: bool, shuffle_salt: int, preset_start_clean: bool, + preset_root: Path, on_empty: Callable[[], None] | None, ) -> None: pm = layer.pm @@ -267,6 +340,12 @@ def _apply_timeline_preset_switching( ), anchor=anchor, ) + layer.role_rotations = _build_role_rotations( + layer, + preset_root, + shuffle=shuffle, + shuffle_salt=shuffle_salt, + ) path = layer.preset_rotation.path_for(0) if path is None: return @@ -277,6 +356,7 @@ def rebuild_timeline_preset_rotation_preserving_count( layer: StemLayer, *, rotation_set: PresetSwitchingRotationSet, + preset_root: Path, user_presets: list[str] | None = None, shuffle: bool = DEFAULT_PRESET_SWITCHING_SHUFFLE, shuffle_salt: int = DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT, @@ -305,6 +385,12 @@ def rebuild_timeline_preset_rotation_preserving_count( ), anchor=anchor, ) + layer.role_rotations = _build_role_rotations( + layer, + preset_root, + shuffle=shuffle, + shuffle_salt=shuffle_salt, + ) path = layer.preset_rotation.path_for(count) if path is None: return @@ -379,7 +465,14 @@ def advance_timeline_preset_switching( ) if count == layer.timeline_switch_count: continue - path = rotation.path_for(count) + path = _timeline_path_for_count( + layer, + count=count, + lane=lane, + song_marker_times=song_marker_times, + song_marker_fades=song_marker_fades, + standard_fades=standard_fades, + ) layer.timeline_switch_count = count if path is None: continue diff --git a/cleave/viz/session.py b/cleave/viz/session.py index d671641..ffaf89f 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -190,6 +190,8 @@ class TimelineRuntime: override_slots: set[str] = field(default_factory=set) override_visible: dict[str, bool] = field(default_factory=dict) arm_flash_start_ms: dict[str, int] = field(default_factory=dict) + selected_cue_t: dict[str, float] = field(default_factory=dict) + selected_cue_flash_start_ms: int | None = None bar_phase_offset: int = 0 show_bar_grid: bool = False beat_bar_grid_expanded: bool = False diff --git a/cleave/viz/timeline_controls.py b/cleave/viz/timeline_controls.py index 28f326e..27622d4 100644 --- a/cleave/viz/timeline_controls.py +++ b/cleave/viz/timeline_controls.py @@ -6,12 +6,18 @@ import pygame +from cleave.blend_modes import BLEND_MODES, BlendMode +from cleave.cue_roles import CUE_ROLES, CueRole from cleave.timeline import ( LEVEL_EPS, SlotCue, canonicalize, + cue_editable_for_blend_role, + empty_lane, + navigable_cue_times, should_accept_toggle, snap_placement_time, + update_lane_cue, ) from cleave.viz.controls import SEEK_LONG, SEEK_SHORT, SEEK_TINY from cleave.viz.session import TuningSession @@ -171,6 +177,22 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: self._toggle_arm() return True + if event.key == pygame.K_COMMA: + self._step_selected_cue(forward=False) + return True + + if event.key == pygame.K_PERIOD: + self._step_selected_cue(forward=True) + return True + + if event.key == pygame.K_b: + self._cycle_selected_cue_blend(forward=not mod_shift(event.mod)) + return True + + if event.key == pygame.K_c: + self._cycle_selected_cue_role(forward=not mod_shift(event.mod)) + return True + return True def handle_keyup(self, event: pygame.event.Event) -> None: @@ -197,6 +219,107 @@ def _slot_for_layer_index(self, index: int) -> str | None: def _focused_slot(self) -> str: return self.session.layer_z_order[self.session.timeline.focus_row] + def _cue_edits_allowed(self) -> bool: + tl = self.session.timeline + return not tl.locked and not tl.recording + + def _step_selected_cue(self, *, forward: bool) -> None: + if not self._cue_edits_allowed(): + return + tl = self.session.timeline + slot = self._focused_slot() + lane = tl.lanes.get(slot) or empty_lane() + times = navigable_cue_times(lane) + if not times: + return + selected = tl.selected_cue_t.get(slot) + if selected is None or selected not in times: + playhead = current_sec(self.playback, self.duration_sec) + nearest = min(times, key=lambda t: (abs(t - playhead), t)) + self._set_selected_cue(slot, nearest) + return + index = times.index(selected) + if forward: + index = min(index + 1, len(times) - 1) + else: + index = max(index - 1, 0) + self._set_selected_cue(slot, times[index]) + + def _set_selected_cue(self, slot: str, cue_t: float) -> None: + tl = self.session.timeline + if tl.selected_cue_t.get(slot) == cue_t: + return + tl.selected_cue_t[slot] = cue_t + tl.selected_cue_flash_start_ms = pygame.time.get_ticks() + + def _selected_cue(self) -> tuple[str, SlotCue] | None: + tl = self.session.timeline + slot = self._focused_slot() + selected = tl.selected_cue_t.get(slot) + if selected is None: + return None + lane = tl.lanes.get(slot) or empty_lane() + for cue in lane.cues: + if cue.t == selected: + return slot, cue + return None + + def _cycle_selected_cue_blend(self, *, forward: bool) -> None: + if not self._cue_edits_allowed(): + return + selected = self._selected_cue() + if selected is None: + return + slot, cue = selected + if not cue_editable_for_blend_role(cue): + return + options: tuple[BlendMode | None, ...] = (None, *BLEND_MODES) + try: + index = options.index(cue.blend) + except ValueError: + index = 0 + if forward: + index = (index + 1) % len(options) + else: + index = (index - 1) % len(options) + self._apply_selected_cue_update(slot, cue.t, blend=options[index], role=cue.role) + + def _cycle_selected_cue_role(self, *, forward: bool) -> None: + if not self._cue_edits_allowed(): + return + selected = self._selected_cue() + if selected is None: + return + slot, cue = selected + if not cue_editable_for_blend_role(cue): + return + options: tuple[CueRole | None, ...] = (None, *CUE_ROLES) + try: + index = options.index(cue.role) + except ValueError: + index = 0 + if forward: + index = (index + 1) % len(options) + else: + index = (index - 1) % len(options) + self._apply_selected_cue_update(slot, cue.t, blend=cue.blend, role=options[index]) + + def _apply_selected_cue_update( + self, + slot: str, + cue_t: float, + *, + blend: BlendMode | None, + role: CueRole | None, + ) -> None: + tl = self.session.timeline + lane = tl.lanes.get(slot) or empty_lane() + updated = update_lane_cue(lane, cue_t, blend=blend, role=role) + tl.lanes[slot] = updated + if not any(cue.t == cue_t for cue in updated.cues): + tl.selected_cue_t.pop(slot, None) + tl.selected_cue_flash_start_ms = None + def _toggle_arm(self) -> None: slot = self._focused_slot() tl = self.session.timeline diff --git a/cleave/viz/timeline_overlay.py b/cleave/viz/timeline_overlay.py index be569dc..e0dcf85 100644 --- a/cleave/viz/timeline_overlay.py +++ b/cleave/viz/timeline_overlay.py @@ -3,9 +3,11 @@ from __future__ import annotations from dataclasses import dataclass, field +from typing import Literal import pygame +from cleave.cue_roles import CueRole from cleave.extract import StemSource from cleave.timeline import ( LEVEL_EPS, @@ -13,6 +15,7 @@ TimelineFadeGroup, TimelineLane, empty_lane, + lane_level_at, lane_level_breakpoints, lane_tick_times, levels_equal, @@ -63,6 +66,12 @@ int(round(OFF_SEGMENT_COLOR[i] + (TIMELINE_BAR_ON[i] - OFF_SEGMENT_COLOR[i]) * 0.4)) for i in range(3) ) +_ROLE_GLYPH: dict[CueRole, str] = { + "bed": "B", + "pulse": "P", + "lead": "L", + "accent": "A", +} def timeline_viewport_reserve_px(row_count: int, *, margin: int | None = None) -> int: @@ -76,6 +85,11 @@ def timeline_viewport_reserve_px(row_count: int, *, margin: int | None = None) - BAR_VERTICAL_INSET: int = _timeline_ui.bar_vertical_inset ARMED_BG_ALPHA: int = 220 CUE_TICK_ALPHA: int = 120 +CUE_TICK_WIDTH: int = 1 +# Settled selected tick (thinner than flash; still thicker than a normal cue tick). +SELECTED_CUE_TICK_WIDTH: int = 3 +# Thick width while the selection flash is active. +SELECTED_CUE_FLASH_TICK_WIDTH: int = 7 PLAYHEAD_WIDTH: int = _timeline_ui.playhead_width REC_BADGE_GAP: int = _timeline_ui.rec_badge_gap REC_BADGE_PAD_X: int = _timeline_ui.rec_badge_pad_x @@ -85,6 +99,16 @@ def timeline_viewport_reserve_px(row_count: int, *, margin: int | None = None) - PLAYHEAD_FLASH_MS: int = 400 ARM_FLASH_HALF_MS: int = 150 ARM_FLASH_DURATION_MS: int = ARM_FLASH_HALF_MS * 4 +# Selected-cue flash: same blink cadence as arm flash, held for 3s. +SELECTED_CUE_FLASH_HALF_MS: int = ARM_FLASH_HALF_MS +SELECTED_CUE_FLASH_DURATION_MS: int = 3000 +# Settled selected tick (HIGHLIGHT yellow); flash pulses yellow/red at flash width. +SELECTED_CUE_COLOR: tuple[int, int, int] = HIGHLIGHT +SELECTED_CUE_FLASH_YELLOW: tuple[int, int, int] = HIGHLIGHT +SELECTED_CUE_FLASH_RED: tuple[int, int, int] = ARMED_BG +# Clear half the flash tick plus 1px so glyphs sit beside the thick marker. +ROLE_GLYPH_TICK_GAP: int = SELECTED_CUE_FLASH_TICK_WIDTH // 2 + 1 +ROLE_GLYPH_BOTTOM_PAD: int = 1 @dataclass @@ -115,6 +139,8 @@ class TimelineViewState: selected_song_marker_index: int | None = None song_marker_fades: TimelineFadeGroup = field(default_factory=TimelineFadeGroup) standard_cue_fades: TimelineFadeGroup = field(default_factory=TimelineFadeGroup) + selected_cue_t: dict[str, float] = field(default_factory=dict) + selected_cue_flash_start_ms: int | None = None def cue_times_for_stem( @@ -283,29 +309,65 @@ def bar_level_breakpoints_for_row( return result -def bar_tick_times_for_row(state: TimelineViewState, slot: str) -> list[float]: - """Cue tick times for one timeline row.""" +def bar_cues_for_row(state: TimelineViewState, slot: str) -> list[SlotCue]: + """Cues whose ticks are drawn for one timeline row.""" duration = state.duration_sec lane = _lane_for_view(state, slot) if not (state.recording and slot in state.record_baseline): - return cue_times_for_stem(lane, duration) + return [cue for cue in lane.cues if 0.0 <= cue.t <= duration] record_start = state.record_slot_start_sec.get(slot, state.record_start_sec) if record_start is None: record_start = state.position_sec playhead = state.position_sec effective_end = max(playhead, state.record_high_water_mark or 0.0) - committed_ticks = [ - t - for t in cue_times_for_stem(lane, duration) - if t < record_start or t > effective_end + committed = [ + cue + for cue in lane.cues + if 0.0 <= cue.t <= duration + and (cue.t < record_start or cue.t > effective_end) ] - live_ticks = [ - t - for t in cue_times_for_stem(_recording_view_lane(state, slot), duration) - if record_start <= t <= effective_end + live = [ + cue + for cue in _recording_view_lane(state, slot).cues + if record_start <= cue.t <= effective_end and cue.t <= duration ] - return sorted(set(committed_ticks) | set(live_ticks)) + by_t = {cue.t: cue for cue in committed} + for cue in live: + by_t[cue.t] = cue + return [by_t[t] for t in sorted(by_t)] + + +def bar_tick_times_for_row(state: TimelineViewState, slot: str) -> list[float]: + """Cue tick times for one timeline row.""" + return [cue.t for cue in bar_cues_for_row(state, slot)] + + +def selected_cue_readout_text(cue: SlotCue) -> str: + """Badge-strip text for a selected cue's time, level, blend, and cast.""" + blend = cue.blend if cue.blend is not None else "-" + cast = cue.role if cue.role is not None else "-" + return f"[{format_mmss(cue.t)}] lvl {cue.level:.2f} blend {blend} cast {cast}" + + +def _selected_cue_for_focus(state: TimelineViewState) -> SlotCue | None: + if not state.layer_z_order: + return None + focus = state.focus_row + if focus < 0 or focus >= len(state.layer_z_order): + return None + slot = state.layer_z_order[focus] + selected_t = state.selected_cue_t.get(slot) + if selected_t is None: + return None + for cue in bar_cues_for_row(state, slot): + if cue.t == selected_t: + return cue + lane = _lane_for_view(state, slot) + for cue in lane.cues: + if cue.t == selected_t: + return cue + return None def _lerp_rgb( @@ -409,6 +471,19 @@ def prune_expired_arm_flashes( flash_starts.pop(slot, None) +def prune_expired_selected_cue_flash( + flash_start_ms: int | None, + ticks_ms: int | None = None, +) -> int | None: + if flash_start_ms is None: + return None + if ticks_ms is None: + ticks_ms = pygame.time.get_ticks() + if ticks_ms - flash_start_ms >= SELECTED_CUE_FLASH_DURATION_MS: + return None + return flash_start_ms + + def arm_abbrev_flash_active( flash_starts: dict[str, int], slot: str, @@ -436,6 +511,42 @@ def arm_abbrev_flash_visible( return (elapsed // ARM_FLASH_HALF_MS) % 2 == 0 +def selected_cue_flash_active( + flash_start_ms: int | None, + ticks_ms: int | None = None, +) -> bool: + if flash_start_ms is None: + return False + if ticks_ms is None: + ticks_ms = pygame.time.get_ticks() + return ticks_ms - flash_start_ms < SELECTED_CUE_FLASH_DURATION_MS + + +def selected_cue_flash_bright( + flash_start_ms: int | None, + ticks_ms: int | None = None, +) -> bool: + """True on the yellow (HIGHLIGHT) half of the selected-cue blink cycle.""" + if not selected_cue_flash_active(flash_start_ms, ticks_ms=ticks_ms): + return False + if ticks_ms is None: + ticks_ms = pygame.time.get_ticks() + assert flash_start_ms is not None + elapsed = ticks_ms - flash_start_ms + return (elapsed // SELECTED_CUE_FLASH_HALF_MS) % 2 == 0 + + +def selected_cue_tick_color( + flash_start_ms: int | None, + ticks_ms: int | None = None, +) -> tuple[int, int, int]: + if selected_cue_flash_bright(flash_start_ms, ticks_ms=ticks_ms): + return SELECTED_CUE_FLASH_YELLOW + if selected_cue_flash_active(flash_start_ms, ticks_ms=ticks_ms): + return SELECTED_CUE_FLASH_RED + return SELECTED_CUE_COLOR + + def armed_abbrev_bg_visible( *, armed: bool, @@ -449,6 +560,118 @@ def armed_abbrev_bg_visible( return armed and (not recording or rec_flash_visible(ticks_ms)) +def blit_role_glyph_xor( + panel: pygame.Surface, + glyph: str, + *, + x: int, + y: int, + font: pygame.font.Font, +) -> tuple[int, int, int, int] | None: + """Bold role letter via per-pixel RGB XOR (pygame 2.6 has no ``BLEND_XOR``). + + Opaque glyph samples invert the destination RGB so the letter stays readable + on both bright level bars and dark beds. Returns the touched panel rect, or + ``None`` when the glyph is fully clipped. + """ + src = font.render(glyph, True, (255, 255, 255)) + gw, gh = src.get_width(), src.get_height() + if gw <= 0 or gh <= 0: + return None + dest_rect = pygame.Rect(x, y, gw, gh).clip(panel.get_rect()) + if dest_rect.w <= 0 or dest_rect.h <= 0: + return None + src_x = dest_rect.x - x + src_y = dest_rect.y - y + src_view = src.subsurface((src_x, src_y, dest_rect.w, dest_rect.h)) + # surfarray axes are (x, y); threshold soft edges to a binary XOR mask. + # Manual XOR: pygame 2.6 exposes no BLEND_XOR blit flag. + dest_rgb = pygame.surfarray.pixels3d(panel) + alpha = pygame.surfarray.array_alpha(src_view) + x0, y0 = dest_rect.x, dest_rect.y + region = dest_rgb[x0 : x0 + dest_rect.w, y0 : y0 + dest_rect.h] + mask = alpha > 127 + region[mask] ^= 255 + del dest_rgb + return (dest_rect.x, dest_rect.y, dest_rect.w, dest_rect.h) + + +def role_glyph_side(*, previous_level: float, cue_level: float) -> Literal["left", "right"]: + """Which side of the cue tick hosts the role glyph (enabled segment). + + On cues (off -> on) place right; off cues (on -> off) place left. When both + sides are enabled, prefer right (toward the new state). When both are off, + prefer right as a stable default. + """ + prev_on = float(previous_level) > LEVEL_EPS + next_on = float(cue_level) > LEVEL_EPS + if next_on and not prev_on: + return "right" + if prev_on and not next_on: + return "left" + if next_on: + return "right" + if prev_on: + return "left" + return "right" + + +def role_glyph_previous_level( + lane: TimelineLane, + cue_t: float, + *, + inherit: float, +) -> float: + """Stepped lane level immediately before ``cue_t``.""" + if cue_t <= 0.0: + return inherit if lane.baseline is None else float(lane.baseline) + return float(lane_level_at(lane, cue_t - 1e-9, inherit=inherit)) + + +def role_glyph_anchor( + *, + tick_x: int, + bar_rect: pygame.Rect, + glyph_w: int, + glyph_h: int, + side: Literal["left", "right"], +) -> tuple[int, int]: + """Top-left of a role glyph at the bar bottom, beside its cue tick.""" + if side == "right": + glyph_x = tick_x + ROLE_GLYPH_TICK_GAP + else: + glyph_x = tick_x - ROLE_GLYPH_TICK_GAP - glyph_w + glyph_y = bar_rect.bottom - glyph_h - ROLE_GLYPH_BOTTOM_PAD + max_x = bar_rect.right - glyph_w + if max_x >= bar_rect.left: + glyph_x = max(bar_rect.left, min(glyph_x, max_x)) + else: + glyph_x = bar_rect.left + max_y = bar_rect.bottom - glyph_h + if max_y >= bar_rect.top: + glyph_y = max(bar_rect.top, min(glyph_y, max_y)) + else: + glyph_y = bar_rect.top + return glyph_x, glyph_y + + +def _glyph_lane_for_cue( + state: TimelineViewState, + slot: str, + cue_t: float, +) -> tuple[TimelineLane, float]: + """Lane and inherit used for stepped previous-level at a drawn cue.""" + inherit = _inherit_for_view(state, slot) + if state.recording and slot in state.record_baseline: + record_start = state.record_slot_start_sec.get(slot, state.record_start_sec) + if record_start is None: + record_start = state.position_sec + effective_end = max(state.position_sec, state.record_high_water_mark or 0.0) + if record_start <= cue_t <= effective_end: + return _recording_view_lane(state, slot), 1.0 + return _lane_for_view(state, slot), inherit + + def time_to_x(t_sec: float, bar_left: int, bar_width: int, duration_sec: float) -> int: if duration_sec <= 0: return bar_left @@ -572,6 +795,7 @@ def __init__( self._padding = padding self._row_gap = row_gap self._font: pygame.font.Font | None = None + self._bold_font: pygame.font.Font | None = None self._panel_rect: tuple[int, int, int, int] | None = None self._header_badge_rect: tuple[int, int, int, int] | None = None self._layer_num_width: int = 0 @@ -588,6 +812,13 @@ def _font_get(self) -> pygame.font.Font: self._font = pygame.font.SysFont("monospace", self._font_size) return self._font + def _bold_font_get(self) -> pygame.font.Font: + if self._bold_font is None: + self._bold_font = pygame.font.SysFont( + "monospace", self._font_size, bold=True + ) + return self._bold_font + @property def gpu_state(self): return self._cache.gpu @@ -767,15 +998,33 @@ def _build_static_panel( bar_rect=bar_rect, ) - for cue_t in bar_tick_times_for_row(state, slot): - tick_x = time_to_x(cue_t, bar_left, bar_width, state.duration_sec) - pygame.draw.line( - panel, - (*LABEL, CUE_TICK_ALPHA), - (tick_x, bar_rect.y), - (tick_x, bar_rect.bottom - 1), - 1, - ) + row_cues = bar_cues_for_row(state, slot) + # Per-slot selection memory stays in selected_cue_t; only the focused + # row draws the selected marker (settled yellow / flash / badge). + selected_t = ( + state.selected_cue_t.get(slot) + if row_index == state.focus_row + else None + ) + for cue in row_cues: + tick_x = time_to_x(cue.t, bar_left, bar_width, state.duration_sec) + selected = selected_t is not None and cue.t == selected_t + if selected: + pygame.draw.line( + panel, + SELECTED_CUE_COLOR, + (tick_x, row_y), + (tick_x, row_y + row_h - 1), + SELECTED_CUE_TICK_WIDTH, + ) + else: + pygame.draw.line( + panel, + (*LABEL, CUE_TICK_ALPHA), + (tick_x, bar_rect.y), + (tick_x, bar_rect.bottom - 1), + CUE_TICK_WIDTH, + ) if focused and BAR_VERTICAL_INSET > 0: blit_tint( @@ -814,6 +1063,59 @@ def _build_static_panel( ) return panel + def _draw_role_glyphs( + self, + surface: pygame.Surface, + state: TimelineViewState, + layout: _TimelineLayout, + *, + y_offset: int = 0, + ) -> list[tuple[int, int, int, int]]: + """XOR role letters after bars/ticks/markers/playhead so they stay on top.""" + bold_font = self._bold_font_get() + dirty: list[tuple[int, int, int, int]] = [] + row_h = layout.row_h + for display_i, slot in enumerate(state.layer_z_order): + row_y = self._row_y(display_i, row_h) + bar_rect = pygame.Rect( + layout.bar_left, + row_y + BAR_VERTICAL_INSET, + layout.bar_width, + max(1, row_h - BAR_VERTICAL_INSET * 2), + ) + for cue in bar_cues_for_row(state, slot): + if cue.role is None or cue.level <= LEVEL_EPS: + continue + letter = _ROLE_GLYPH.get(cue.role) + if letter is None: + continue + lane, inherit = _glyph_lane_for_cue(state, slot, cue.t) + previous = role_glyph_previous_level(lane, cue.t, inherit=inherit) + side = role_glyph_side( + previous_level=previous, cue_level=cue.level + ) + tick_x = time_to_x( + cue.t, layout.bar_left, layout.bar_width, state.duration_sec + ) + glyph_w, glyph_h = bold_font.size(letter) + glyph_x, glyph_y = role_glyph_anchor( + tick_x=tick_x, + bar_rect=bar_rect, + glyph_w=glyph_w, + glyph_h=glyph_h, + side=side, + ) + touched = blit_role_glyph_xor( + surface, + letter, + x=glyph_x, + y=glyph_y + y_offset, + font=bold_font, + ) + if touched is not None: + dirty.append(touched) + return dirty + def _row_y(self, display_i: int, row_h: int) -> int: return self._padding + display_i * (row_h + self._row_gap) @@ -901,10 +1203,50 @@ def _draw_row_live_flash( ) upload.blit(monitor_icon, (monitor_eye_x, upload_y)) dirty.append(monitor_rect) + + selected_t = ( + state.selected_cue_t.get(slot) if row_index == state.focus_row else None + ) + if selected_t is not None and selected_cue_flash_active( + state.selected_cue_flash_start_ms + ): + tick_x = time_to_x( + selected_t, layout.bar_left, layout.bar_width, state.duration_sec + ) + half_w = SELECTED_CUE_FLASH_TICK_WIDTH // 2 + 1 + tick_rect = ( + max(layout.bar_left, tick_x - half_w), + upload_y, + min(layout.bar_width, half_w * 2 + 1), + row_h, + ) + # Clamp width if tick sits near the bar edge. + tick_rect = ( + tick_rect[0], + tick_rect[1], + min(tick_rect[2], layout.bar_left + layout.bar_width - tick_rect[0]), + tick_rect[3], + ) + if tick_rect[2] > 0: + self._restore_upload_rect_from_static( + upload, + static_panel, + tick_rect, + panel_y_offset=panel_y_offset, + ) + pygame.draw.line( + upload, + selected_cue_tick_color(state.selected_cue_flash_start_ms), + (tick_x, upload_y), + (tick_x, upload_y + row_h - 1), + SELECTED_CUE_FLASH_TICK_WIDTH, + ) + dirty.append(tick_rect) return dirty def _live_flash_row_indices(self, state: TimelineViewState) -> tuple[int, ...]: indices: list[int] = [] + cue_flash = selected_cue_flash_active(state.selected_cue_flash_start_ms) for row_index, slot in enumerate(state.layer_z_order): armed = slot in state.armed_slots if arm_abbrev_flash_active(state.arm_flash_start_ms, slot): @@ -913,10 +1255,21 @@ def _live_flash_row_indices(self, state: TimelineViewState) -> tuple[int, ...]: indices.append(row_index) elif armed and not state.recording: indices.append(row_index) + elif ( + cue_flash + and row_index == state.focus_row + and slot in state.selected_cue_t + ): + indices.append(row_index) return tuple(indices) def _live_flash_signature(self, state: TimelineViewState) -> tuple: parts: list[tuple] = [] + cue_flash_bright = ( + selected_cue_flash_bright(state.selected_cue_flash_start_ms) + if selected_cue_flash_active(state.selected_cue_flash_start_ms) + else None + ) for row_index in self._live_flash_row_indices(state): slot = state.layer_z_order[row_index] armed = slot in state.armed_slots @@ -933,6 +1286,17 @@ def _live_flash_signature(self, state: TimelineViewState) -> tuple: (slot in state.override_slots and not (state.recording and armed)) or (state.recording and armed and rec_flash_visible()) ), + ( + state.selected_cue_t.get(slot) + if cue_flash_bright is not None + and row_index == state.focus_row + else None + ), + ( + cue_flash_bright + if row_index == state.focus_row and slot in state.selected_cue_t + else None + ), ) ) return tuple(parts) @@ -986,6 +1350,7 @@ def _draw_header_badges_on_surface( recording: bool, *, y_offset: int = 0, + cue_readout: str | None = None, ) -> tuple[int, int, int, int]: time_surf = font.render(transport_time_text(position_sec), True, VALUE) time_w = time_surf.get_width() + REC_BADGE_PAD_X * 2 @@ -999,10 +1364,22 @@ def _draw_header_badges_on_surface( badge_h = time_h gap = REC_TIME_GAP if recording else 0 - total_w = time_w + gap + rec_w + right_w = time_w + gap + rec_w time_x = panel_w - time_w badge_y = y_offset + readout_w = 0 + if cue_readout: + readout_surf = font.render(cue_readout, True, VALUE) + readout_w = readout_surf.get_width() + REC_BADGE_PAD_X * 2 + pygame.draw.rect( + surface, BACKGROUND, (0, badge_y, readout_w, badge_h) + ) + surface.blit( + readout_surf, + (REC_BADGE_PAD_X, badge_y + REC_BADGE_PAD_Y), + ) + pygame.draw.rect(surface, BACKGROUND, (time_x, badge_y, time_w, badge_h)) surface.blit( time_surf, @@ -1020,7 +1397,9 @@ def _draw_header_badges_on_surface( (rec_x + REC_BADGE_PAD_X, badge_y + REC_BADGE_PAD_Y), ) - return (header_x, badge_y, total_w, badge_h) + if readout_w > 0: + return (0, badge_y, panel_w, badge_h) + return (header_x, badge_y, right_w, badge_h) def _ensure_upload_scratch( self, @@ -1073,6 +1452,10 @@ def _patch_live_overlay( bx, by, bw, bh = cache.last_badge_rect upload.fill((0, 0, 0, 0), (bx, by, bw, bh)) + selected_cue = _selected_cue_for_focus(state) + cue_readout = ( + selected_cue_readout_text(selected_cue) if selected_cue is not None else None + ) badge_rect = self._draw_header_badges_on_surface( upload, font, @@ -1080,6 +1463,7 @@ def _patch_live_overlay( state.position_sec, state.recording, y_offset=badge_top, + cue_readout=cue_readout, ) cache.last_badge_rect = badge_rect @@ -1096,6 +1480,16 @@ def _patch_live_overlay( ) ) cache.last_flash_rects = tuple(flash_dirty) + + # Role glyphs last (after playhead / flash) so XOR letters sit on top. + glyph_dirty = self._draw_role_glyphs( + upload, + state, + layout, + y_offset=panel_y_offset, + ) + cache.last_glyph_rects = tuple(glyph_dirty) + flash_dirty.extend(glyph_dirty) return flash_dirty def compose_panel( @@ -1147,6 +1541,7 @@ def compose_panel( cache.last_playhead_rect = None cache.last_badge_rect = None cache.last_flash_rects = () + cache.last_glyph_rects = () cache.last_live_signature = None incremental = False @@ -1165,6 +1560,7 @@ def compose_panel( prev_playhead = cache.last_playhead_rect prev_badge = cache.last_badge_rect prev_flash = cache.last_flash_rects + prev_glyphs = cache.last_glyph_rects flash_dirty = self._patch_live_overlay( upload, static_panel, @@ -1228,6 +1624,8 @@ def compose_panel( for rect in ( *prev_flash, *flash_dirty, + *prev_glyphs, + *cache.last_glyph_rects, prev_playhead, cache.last_playhead_rect, prev_badge, diff --git a/cleave/viz/timeline_panel_cache.py b/cleave/viz/timeline_panel_cache.py index b6d6629..6fb3d56 100644 --- a/cleave/viz/timeline_panel_cache.py +++ b/cleave/viz/timeline_panel_cache.py @@ -59,6 +59,7 @@ class TimelineStaticSignature: standard_cue_fades_enabled: bool standard_cue_fade_in: float standard_cue_fade_out: float + selected_cue_t: tuple[tuple[str, float], ...] @dataclass @@ -71,6 +72,7 @@ class TimelinePanelCache: last_playhead_rect: tuple[int, int, int, int] | None = None last_badge_rect: tuple[int, int, int, int] | None = None last_flash_rects: tuple[tuple[int, int, int, int], ...] = () + last_glyph_rects: tuple[tuple[int, int, int, int], ...] = () def visibility_bucket(visibility: float) -> int: @@ -81,19 +83,22 @@ def visibility_bucket(visibility: float) -> int: def _slot_cues_fingerprint( cues: list[SlotCue], -) -> tuple[tuple[float, float], ...]: - return tuple((cue.t, cue.level) for cue in cues) +) -> tuple[tuple[float, float, str | None, str | None], ...]: + return tuple((cue.t, cue.level, cue.blend, cue.role) for cue in cues) def _lane_fingerprint( lane: TimelineLane, -) -> tuple[float | None, tuple[tuple[float, float], ...]]: +) -> tuple[float | None, tuple[tuple[float, float, str | None, str | None], ...]]: return (lane.baseline, _slot_cues_fingerprint(lane.cues)) def _lanes_fingerprint( lanes: dict[str, TimelineLane], -) -> tuple[tuple[str, float | None, tuple[tuple[float, float], ...]], ...]: +) -> tuple[ + tuple[str, float | None, tuple[tuple[float, float, str | None, str | None], ...]], + ..., +]: return tuple( (slot, *_lane_fingerprint(lane)) for slot, lane in sorted(lanes.items()) @@ -102,7 +107,7 @@ def _lanes_fingerprint( def _record_buffer_fingerprint( record_buffer: dict[str, list[SlotCue]], -) -> tuple[tuple[str, tuple[tuple[float, float], ...]], ...]: +) -> tuple[tuple[str, tuple[tuple[float, float, str | None, str | None], ...]], ...]: return tuple( (slot, _slot_cues_fingerprint(cues)) for slot, cues in sorted(record_buffer.items()) @@ -148,6 +153,7 @@ def timeline_static_signature( standard_cue_fades_enabled=state.standard_cue_fades.enabled, standard_cue_fade_in=state.standard_cue_fades.fade_in, standard_cue_fade_out=state.standard_cue_fades.fade_out, + selected_cue_t=tuple(sorted(state.selected_cue_t.items())), ) diff --git a/cleave/viz/wiring.py b/cleave/viz/wiring.py index 7372910..bdb729b 100644 --- a/cleave/viz/wiring.py +++ b/cleave/viz/wiring.py @@ -62,6 +62,7 @@ def _discard_timeline_slot(session: TuningSession, slot: str) -> None: timeline.monitor.pop(slot, None) timeline.override_visible.pop(slot, None) timeline.arm_flash_start_ms.pop(slot, None) + timeline.selected_cue_t.pop(slot, None) timeline.record_buffer.pop(slot, None) @@ -118,6 +119,7 @@ def add_layer(self) -> str: beat_sensitivity=self.cfg.editor.beat_sensitivity, width=width, height=height, + preset_root=self.preset_root, ) self.layers.append(stem_layer) self.layers_by_slot[slot] = stem_layer @@ -223,6 +225,7 @@ def on_preset_change(slot: str, playlist: PresetPlaylist) -> None: hard_cut_sensitivity=runtime.hard_cut_sensitivity, shuffle=runtime.preset_switching_shuffle, shuffle_salt=runtime.preset_switching_shuffle_salt, + preset_root=preset_root, on_empty=_empty_rotation_notify(slot), ) return @@ -265,6 +268,7 @@ def on_preset_switching_change(slot: str) -> None: hard_cut_sensitivity=runtime.hard_cut_sensitivity, shuffle=runtime.preset_switching_shuffle, shuffle_salt=runtime.preset_switching_shuffle_salt, + preset_root=preset_root, on_empty=_empty_rotation_notify(slot), ) @@ -280,9 +284,6 @@ def unlock_preset_after_modal(slot: str) -> None: notification_sink: dict[str, Callable[[str], None] | None] = {"fn": None} - def on_blend_change(slot: str, blend_mode) -> None: - layers_by_slot[slot].fbo.blend_mode = blend_mode - def on_stem_change(slot: str, stem) -> None: LayerFramePipeline.flush_pcm(layers) if mix_player is not None: @@ -357,6 +358,7 @@ def on_seek(delta_sec: float) -> None: reapply_projectm_preset_switching( session, layers_by_slot, + preset_root=preset_root, delta_sec=delta_sec, ) resync_timeline_preset_switching( @@ -436,7 +438,6 @@ def on_chroma_boost_apply_mode_change(old_mode: str, new_mode: str) -> None: on_preset_switching_change=on_preset_switching_change, lock_preset_for_modal=lock_preset_for_modal, unlock_preset_after_modal=unlock_preset_after_modal, - on_blend_change=on_blend_change, on_stem_change=on_stem_change, on_opacity_change=on_opacity_change, on_layer_enabled_change=on_layer_enabled_change, @@ -513,6 +514,7 @@ def make_timeline_controls( layers: list[StemLayer], signals: Signals | None, effect_runtime: EffectRuntime, + preset_root: Path, mix_player: MixPlayer | None = None, on_notification: Callable[[str], None] | None = None, tuning_controls: TuningControls | None = None, @@ -546,6 +548,7 @@ def on_seek(delta_sec: float) -> None: reapply_projectm_preset_switching( session, layers_by_slot, + preset_root=preset_root, delta_sec=delta_sec, ) resync_timeline_preset_switching( diff --git a/docs/completed/presets-scan-plan.md b/docs/completed/presets-scan-plan.md index 2fcf991..8c2250d 100644 --- a/docs/completed/presets-scan-plan.md +++ b/docs/completed/presets-scan-plan.md @@ -75,9 +75,10 @@ Build the preset list from parsed [CleaveConfig](../../cleave/config.py) **on di 2. **Include anchor directory even when `preset_switching` is `none`** — user may enable projectM later; scanning only the locked file would miss siblings they will rotate into. 3. **projectM directory rotation** — when `preset_switching` is `projectm` and `preset_switching_rotation_set` is `directory`, step 1 already matches live rotation. 4. **User-defined rotation** — when `preset_switching_rotation_set` is `user_defined`, add every path in `layers..preset_switching_presets` (resolved relative to project dir per [config_schema](../../cleave/config_schema.py)). -5. **Deduplicate** by resolved absolute `.milk` path. Keep metadata: which layer slot(s) referenced each file. +5. **Role pools** — include non-recursive `*.milk` under each `paths.preset_root/roles//` (`bed`, `pulse`, `lead`, `accent`), matching timeline role casting in [preset_switching](../../cleave/viz/preset_switching.py). Attribute those presets to every layer slot already in the scan set. +6. **Deduplicate** by resolved absolute `.milk` path. Keep metadata: which layer slot(s) referenced each file. -Do **not** scan `browse_floor`, full `preset_root`, or subfolders under the anchor directory unless bulk mode with `--recursive`. +Do **not** scan `browse_floor`, full `preset_root`, or subfolders under the anchor directory unless bulk mode with `--recursive`. Role pool dirs are an explicit exception (step 5). ### Texture paths diff --git a/docs/improved-timeline-presets.md b/docs/improved-timeline-presets.md index 90f2529..cfb96c2 100644 --- a/docs/improved-timeline-presets.md +++ b/docs/improved-timeline-presets.md @@ -21,44 +21,47 @@ What works today: Gaps the ideas below target: -1. Cues carry a level but not yet blend mode or preset role, so milk personality and per-cue casting remain out of reach of the arranger. +1. The arranger still emits level-only cues; it does not yet assign blend or role automatically. 2. The arranger is musically phrased but not stem-content-aware: it never looks at what the drums or vocals are doing. 3. Song form still needs manual markers for the best results. -4. Busy collisions often come from milk personality, which level-only cues cannot fix alone. +4. Busy collisions often come from milk personality; role casting helps when pools are curated, but automatic pool fill needs Idea 3 fingerprints. 5. Nothing recurs. Chorus 2 gets a fresh roll of the dice, so the output reads as plausible-but-random rather than composed. --- -## Idea 1: Arrangement as a mix, not a gate (shipped: levels) +## Idea 1: Arrangement as a mix, not a gate (shipped: levels, blend, role) -**Shipped:** cues are level keyframes (`SlotCue(t, level)`), fade groups become constant-slope ramps on a piecewise-linear envelope, the strip draws variable-height bars, and crescendo ramps entrants in at `0.5` before a full stack. Layer opacity stays the static fader; the lane multiplies into `fbo.opacity` as before. +**Shipped:** cues are level keyframes with optional blend and role (`SlotCue(t, level, blend?, role?)`), fade groups become constant-slope ramps on a piecewise-linear envelope, the strip draws variable-height bars, and crescendo ramps entrants in at `0.5` before a full stack. Layer opacity stays the static fader; the lane multiplies into `fbo.opacity` as before. Per-cue blend writes `LayerFbo.blend_mode` each frame ([cleave/blend_modes.py](../cleave/blend_modes.py)); per-cue role casts from `preset_root/roles//` on rises from zero. Blend and role are authored on on / visible cues (the next period), not on disable cues; off cues are stripped of both in `canonicalize`. **Deferred** (still part of the mix vision, not implemented): -- Per-cue blend mode writing `LayerFbo.blend_mode` ([cleave/blend_modes.py](../cleave/blend_modes.py)). -- Per-cue preset role for content-aware casting (Idea 3). - Manual level authoring in the strip (record toggles still write `0.0` / `1.0`). +- Automatic blend/role assignment from the generative arranger (Idea 2 / Idea 3). ### Intent - Let the arranger duck a layer instead of muting it. A chorus becomes one lead at full weight and a bed at half, rather than "four layers on". - Turn the anti-busy rule into a mix rule (total visual weight budget) instead of a layer count. - Reuse compositing capability the renderer already has but the timeline could not address with booleans alone. +- Let a cue pick a milk personality via role without replacing the whole layer rotation. ### What landed -1. Cue model and persist: `level` required in YAML; baseline is `float | None` ([cleave/config_schema.py](../cleave/config_schema.py)). +1. Cue model and persist: `level` required in YAML; optional `blend` / `role`; baseline is `float | None` ([cleave/config_schema.py](../cleave/config_schema.py)). 2. Envelope: `lane_level_breakpoints` / `lane_level_envelope` replace edge-only fade alpha; rise completes at cue time, fall starts at cue time; slopes scale with level delta. 3. Runtime: `timeline_level` / `timeline_level_multiplier`; levels apply even when both fade groups are disabled (piecewise-constant envelope). 4. Preset-switch trigger: rise from zero only; `cue.t - fade_in * cue.level`. 5. Generator: `cues_from_states` emits level mappings; crescendo uses `0.5` entrants. 6. Strip: polygon fill from breakpoints; committed eye alpha follows level. +7. Blend: held like level via `lane_blend_at`; applied per frame in `apply_layer_visibility` with layer static fallback. +8. Role: event property on on-transitions; seek-stable per-role pools in [cleave/viz/preset_switching.py](../cleave/viz/preset_switching.py); empty pool falls back to the main rotation. +9. Strip authoring: `,` / `.` select on cues (`level > 0`, including mid-on changes; offs skipped); `b` / `c` cycle blend and cast on those only; selected tick highlight, role glyphs on on cues, and badge readout. -Overlaps [roadmap.md](roadmap.md) richer cue types; blend and role are the remaining slice. +Overlaps [roadmap.md](roadmap.md) richer cue types; automatic assignment from stem content and fingerprints remains Idea 2 / Idea 3. ### User effort -None for 0/1 lanes. Partial levels come from generative Apply (crescendo) until manual authoring lands. +None for 0/1 lanes. Partial levels come from generative Apply (crescendo). Blend and role are authorable on selected on cues in the strip; place milk files under `preset_root/roles//` for casting. --- @@ -130,7 +133,7 @@ The probe harness is most of the way there already. [cleave/preset_scan.py](../c | Lead | high busyness or brightness | at most one hot at a time | | Accent | short bright bursts, high delta | chorus hits, marker edges | -5. Cast on each on-transition in [cleave/viz/preset_switching.py](../cleave/viz/preset_switching.py), or explicitly through a role carried on a rich cue (deferred Idea 1 work). Keep play and scan in agreement on the rotation set (see [.cursor/rules/preset-scan-rotation-set.mdc](../.cursor/rules/preset-scan-rotation-set.mdc)). +5. Cast on each on-transition in [cleave/viz/preset_switching.py](../cleave/viz/preset_switching.py) through the cue `role` field (Idea 1b): when set, index `role_rotations[role]` by per-role occurrence; when unset or the pool is empty, use the main rotation. Idea 3 fills those role pools from fingerprints instead of hand curation. Keep play and scan in agreement on the rotation set (see [.cursor/rules/preset-scan-rotation-set.mdc](../.cursor/rules/preset-scan-rotation-set.mdc)). ### Fits existing code @@ -214,7 +217,7 @@ Breathing / Dialogue / Arc / Pulse remain character profiles that bias switch ra | Order | Item | Why here | Payoff | Effort | | --- | --- | --- | --- | --- | | 1 | Rich cues: levels (Idea 1, shipped) | Enabler; everything else wants levels, not booleans | Better output immediately, even with today's arranger | Done | -| 1b | Rich cues: blend and role (deferred) | Completes the mix vision; casting needs role | Medium | Medium (cue fields, strip, persist, record) | +| 1b | Rich cues: blend and role (Idea 1, shipped) | Completes the mix vision; casting uses the cue role field | Medium | Done | | 2 | Stem conductor (Idea 2) | Uses data already in `signals.json`; first thing that makes lanes song-tied | High | Medium (arranger plus signals) | | 3 | Closed-loop limiter (companion) | Cheap once levels exist; buys headroom before casting lands | Medium | Low | | 4 | Reactivity fingerprints and casting (Idea 3) | Probe harness exists; fixes busy-on-busy at the milk level | High | High (probe set, metrics, pools, casting) | @@ -234,4 +237,4 @@ Sequencing notes: - MIDI out, web or Butterchurn port, live sliding-window Demucs. - Six-stem Demucs as a prerequisite; named guitar and piano voices would strengthen the conductor but are not required by any step above. - Replacing manual timeline edit, arm and record, or snap tools. Generative Apply remains a starting point the user can polish. -- Per-cue arbitrary parameter automation (effect depths, post-FX). Idea 1's remaining slice is blend and preset role only. +- Per-cue arbitrary parameter automation (effect depths, post-FX). Idea 1 blend and preset role are shipped; further cue fields stay out of scope here. diff --git a/tests/cleave/test_config.py b/tests/cleave/test_config.py index 3ff7461..0d124a7 100644 --- a/tests/cleave/test_config.py +++ b/tests/cleave/test_config.py @@ -1347,6 +1347,104 @@ def test_persist_timeline_cue_levels_round_trip() -> None: assert round_trip.lanes["layer_1"] == session.timeline.lanes["layer_1"] +def test_persist_timeline_cue_blend_and_role_round_trip() -> None: + from cleave.viz.session import TimelineRuntime + + session = TuningSession( + layer_z_order=list(DEFAULT_LAYER_SLOTS), + timeline=TimelineRuntime( + enabled=True, + lanes={ + "layer_1": TimelineLane( + baseline=0.0, + cues=[ + SlotCue(t=1.0, level=1.0, blend="add", role="lead"), + SlotCue(t=2.0, level=0.0), + ], + ), + }, + ), + ) + cfg = CleaveConfig( + paths=PathsConfig(preset_root=Path("/tmp"), texture_paths=()), + layers={}, + editor=EditorConfig(), + config_path=Path("/tmp/cleave-viz.yaml"), + user_config_path=Path("/tmp/user.yaml"), + layer_z_order=list(DEFAULT_LAYER_SLOTS), + ) + payload = persist_timeline(PersistCtx(cfg=cfg, session=session, cfg_dir=None)) + assert payload["lanes"]["layer_1"]["cues"] == [ + {"t": 1.0, "level": 1.0, "blend": "add", "role": "lead"}, + {"t": 2.0, "level": 0.0}, + ] + round_trip = parse_timeline_section( + {"timeline": payload}, + _timeline_parse_ctx(), + ) + assert round_trip is not None + assert round_trip.lanes["layer_1"] == session.timeline.lanes["layer_1"] + + +def test_parse_timeline_reads_cue_blend_and_role() -> None: + timeline = parse_timeline_section( + { + "timeline": { + "lanes": { + "layer_1": { + "baseline": 0.0, + "cues": [ + { + "t": 1.0, + "level": 1.0, + "blend": "screen", + "role": "accent", + }, + ], + }, + }, + } + }, + _timeline_parse_ctx(), + ) + assert timeline is not None + assert timeline.lanes["layer_1"].cues == [ + SlotCue(t=1.0, level=1.0, blend="screen", role="accent"), + ] + + +def test_parse_timeline_rejects_invalid_cue_blend() -> None: + with pytest.raises(ValueError, match="blend must be one of"): + parse_timeline_section( + { + "timeline": { + "lanes": { + "layer_1": { + "cues": [{"t": 1.0, "level": 1.0, "blend": "glow"}], + } + } + } + }, + _timeline_parse_ctx(), + ) + + +def test_parse_timeline_rejects_invalid_cue_role() -> None: + with pytest.raises(ValueError, match="role must be one of"): + parse_timeline_section( + { + "timeline": { + "lanes": { + "layer_1": { + "cues": [{"t": 1.0, "level": 1.0, "role": "solo"}], + } + } + } + }, + _timeline_parse_ctx(), + ) + + def test_parse_timeline_rejects_cue_missing_level() -> None: with pytest.raises(ValueError, match="missing level"): parse_timeline_section( diff --git a/tests/cleave/test_config_snapshot.py b/tests/cleave/test_config_snapshot.py index cff60be..4f20dcf 100644 --- a/tests/cleave/test_config_snapshot.py +++ b/tests/cleave/test_config_snapshot.py @@ -1032,6 +1032,85 @@ def test_write_session_snapshot_round_trips_lane_baseline_and_cues(tmp_path: Pat assert session2.timeline.lanes["layer_1"] == session.timeline.lanes["layer_1"] +def test_write_session_snapshot_round_trips_cue_blend_and_role(tmp_path: Path) -> None: + cfg, session, out_path = _snapshot_fixture(tmp_path) + session.timeline.enabled = True + session.timeline.lanes = { + "layer_1": TimelineLane( + baseline=0.0, + cues=[ + SlotCue(t=2.5, level=1.0, blend="add", role="pulse"), + SlotCue(t=5.0, level=0.0), + ], + ), + } + write_session_snapshot(out_path, cfg=cfg, session=session) + + data = yaml.safe_load(out_path.read_text(encoding="utf-8")) + assert data["timeline"]["lanes"]["layer_1"]["cues"] == [ + {"t": 2.5, "level": 1.0, "blend": "add", "role": "pulse"}, + {"t": 5.0, "level": 0.0}, + ] + + timeline = parse_timeline_section( + data, + ParseCtx(layer_slots=tuple(cfg.layer_z_order)), + ) + assert timeline is not None + playlists = _round_trip_playlists(cfg.paths.preset_root) + cfg_with_timeline = CleaveConfig( + paths=cfg.paths, + layers=cfg.layers, + editor=cfg.editor, + config_path=out_path, + user_config_path=cfg.user_config_path, + render=cfg.render, + timeline=timeline, + ) + session2 = session_from_cfg(cfg_with_timeline, playlists) + assert session2.timeline.lanes["layer_1"] == session.timeline.lanes["layer_1"] + + +def test_persisted_session_signature_moves_when_cue_blend_changes( + tmp_path: Path, +) -> None: + cfg, session, _out_path = _snapshot_fixture(tmp_path) + session.timeline.enabled = True + session.timeline.lanes = { + "layer_1": TimelineLane( + baseline=0.0, + cues=[SlotCue(t=1.0, level=1.0)], + ), + } + before = persisted_session_signature(cfg, session) + session.timeline.lanes = { + "layer_1": TimelineLane( + baseline=0.0, + cues=[SlotCue(t=1.0, level=1.0, blend="add")], + ), + } + assert persisted_session_signature(cfg, session) != before + + +def test_write_session_snapshot_level_only_cues_omit_blend_role_keys( + tmp_path: Path, +) -> None: + cfg, session, out_path = _snapshot_fixture(tmp_path) + session.timeline.enabled = True + session.timeline.lanes = { + "layer_1": TimelineLane( + baseline=0.0, + cues=[SlotCue(t=2.5, level=1.0)], + ), + } + write_session_snapshot(out_path, cfg=cfg, session=session) + data = yaml.safe_load(out_path.read_text(encoding="utf-8")) + cues = data["timeline"]["lanes"]["layer_1"]["cues"] + assert cues == [{"t": 2.5, "level": 1.0}] + assert "blend" not in cues[0] + assert "role" not in cues[0] + + def test_write_session_snapshot_persists_timeline_disabled_without_cues( tmp_path: Path, ) -> None: diff --git a/tests/cleave/test_preset_curation.py b/tests/cleave/test_preset_curation.py index aa2dc1f..edb0432 100644 --- a/tests/cleave/test_preset_curation.py +++ b/tests/cleave/test_preset_curation.py @@ -117,6 +117,7 @@ def test_list_restore_destination_subdirs_excludes_curation_dirs() -> None: (root / "other").mkdir() (root / FAVOURITES_DIR).mkdir() (root / BLACKLIST_DIR).mkdir() + (root / "roles").mkdir() (root / ".hidden").mkdir() assert list_restore_destination_subdirs(root) == ("other", "pack") diff --git a/tests/cleave/test_preset_scan_targets.py b/tests/cleave/test_preset_scan_targets.py index 6d8c1e5..37e7929 100644 --- a/tests/cleave/test_preset_scan_targets.py +++ b/tests/cleave/test_preset_scan_targets.py @@ -268,6 +268,46 @@ def test_bulk_targets_recursive() -> None: ] +def test_project_targets_include_role_pool_presets() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + preset_dir = root / "presets" / "pack" + preset_dir.mkdir(parents=True) + anchor = preset_dir / "anchor.milk" + _write_milk(anchor) + + pulse_dir = root / "presets" / "roles" / "pulse" + pulse_dir.mkdir(parents=True) + pulse_a = pulse_dir / "a.milk" + pulse_b = pulse_dir / "b.milk" + nested = pulse_dir / "nested" + nested.mkdir() + _write_milk(pulse_a) + _write_milk(pulse_b) + _write_milk(nested / "deep.milk") + + bed_dir = root / "presets" / "roles" / "bed" + bed_dir.mkdir(parents=True) + bed = bed_dir / "bed.milk" + _write_milk(bed) + + cfg = _project_cfg( + root, + layers={ + "layer_1": _layer_config(anchor, preset_switching="timeline"), + }, + ) + targets = build_project_targets(cfg) + + paths = {target.path for target in targets.presets} + assert pulse_a.resolve() in paths + assert pulse_b.resolve() in paths + assert bed.resolve() in paths + assert (nested / "deep.milk").resolve() not in paths + assert pulse_dir.resolve() in targets.layer_sources["layer_1"] + assert bed_dir.resolve() in targets.layer_sources["layer_1"] + + def test_build_project_targets_uses_user_config_preset_root(tmp_path: Path) -> None: import yaml diff --git a/tests/cleave/test_timeline.py b/tests/cleave/test_timeline.py index 51ca5de..614b2fa 100644 --- a/tests/cleave/test_timeline.py +++ b/tests/cleave/test_timeline.py @@ -14,14 +14,18 @@ TimelineLane, canonicalize, empty_lane, + lane_blend_at, lane_level_at, lane_level_breakpoints, lane_level_envelope, lane_on_transition_count, + lane_on_transition_cues, lane_on_transition_trigger_times, punch_lane, set_lane_cue, + shift_lane_cues_by_beats, should_accept_toggle, + snap_lane_to_beats, stem_abbreviation, strip_lane_range, ) @@ -152,6 +156,132 @@ def test_canonicalize_drops_redundant_transitions() -> None: assert cues == [SlotCue(t=2.0, level=1.0)] +def test_canonicalize_keeps_blend_only_change() -> None: + cues = canonicalize( + 0.0, + [ + SlotCue(t=1.0, level=1.0), + SlotCue(t=2.0, level=1.0, blend="add"), + ], + ) + assert cues == [ + SlotCue(t=1.0, level=1.0), + SlotCue(t=2.0, level=1.0, blend="add"), + ] + + +def test_canonicalize_drops_full_noop_including_blend() -> None: + cues = canonicalize( + 0.0, + [ + SlotCue(t=1.0, level=1.0, blend="add"), + SlotCue(t=2.0, level=1.0, blend="add", role="pulse"), + ], + ) + assert cues == [SlotCue(t=1.0, level=1.0, blend="add")] + + +def test_canonicalize_strips_blend_and_role_on_off_cues() -> None: + cues = canonicalize( + 0.0, + [ + SlotCue(t=1.0, level=1.0, blend="add", role="lead"), + SlotCue(t=2.0, level=0.0, blend="screen", role="accent"), + SlotCue(t=3.0, level=0.5, blend="add", role="pulse"), + ], + ) + assert cues == [ + SlotCue(t=1.0, level=1.0, blend="add", role="lead"), + SlotCue(t=2.0, level=0.0), + SlotCue(t=3.0, level=0.5, blend="add", role="pulse"), + ] + + +def test_canonicalize_off_with_dead_metadata_still_level_only() -> None: + """Stripping off metadata must not invent blend-only keep/drop bugs.""" + cues = canonicalize( + 1.0, + [SlotCue(t=1.0, level=0.0, blend="add", role="pulse")], + ) + assert cues == [SlotCue(t=1.0, level=0.0)] + + +def test_cue_editable_and_navigable_times() -> None: + from cleave.timeline import cue_editable_for_blend_role, navigable_cue_times + + on = SlotCue(t=1.0, level=0.5) + mid = SlotCue(t=2.0, level=1.0) + off = SlotCue(t=3.0, level=0.0, blend="add", role="lead") + assert cue_editable_for_blend_role(on) + assert cue_editable_for_blend_role(mid) + assert not cue_editable_for_blend_role(off) + lane = TimelineLane(baseline=0.0, cues=[on, mid, off]) + assert navigable_cue_times(lane) == [1.0, 2.0] + + +def test_lane_blend_at_holds_and_reverts() -> None: + lane = TimelineLane( + baseline=0.0, + cues=[ + SlotCue(t=1.0, level=1.0, blend="add"), + SlotCue(t=2.0, level=1.0, blend=None), + SlotCue(t=3.0, level=0.0), + SlotCue(t=4.0, level=1.0, blend="screen"), + ], + ) + assert lane_blend_at(lane, 0.5) is None + assert lane_blend_at(lane, 1.0) == "add" + assert lane_blend_at(lane, 1.5) == "add" + assert lane_blend_at(lane, 2.0) is None + assert lane_blend_at(lane, 3.0) is None + assert lane_blend_at(lane, 4.0) == "screen" + + +def test_snap_and_shift_preserve_blend_and_role_on_ons_strip_offs() -> None: + lane = TimelineLane( + baseline=0.0, + cues=[ + SlotCue(t=0.4, level=1.0, blend="add", role="lead"), + SlotCue(t=1.6, level=0.0, blend="screen", role="accent"), + ], + ) + snapped = snap_lane_to_beats(lane, (0.0, 1.0, 2.0)) + assert snapped.cues == [ + SlotCue(t=0.0, level=1.0, blend="add", role="lead"), + SlotCue(t=2.0, level=0.0), + ] + shifted = shift_lane_cues_by_beats(lane, (0.0, 1.0, 2.0, 3.0), 1) + assert shifted.cues == [ + SlotCue(t=1.0, level=1.0, blend="add", role="lead"), + SlotCue(t=3.0, level=0.0), + ] + + +def test_lane_on_transition_cues_returns_cue_per_trigger() -> None: + lane = TimelineLane( + baseline=0.0, + cues=[ + SlotCue(t=10.0, level=1.0, role="bed"), + SlotCue(t=20.0, level=0.0), + SlotCue(t=30.0, level=1.0, blend="add", role="pulse"), + ], + ) + pairs = lane_on_transition_cues( + lane, + song_marker_fades=_OFF, + standard_fades=_std(fade_in=2.0, fade_out=2.0), + ) + assert [(t, cue.t, cue.role, cue.blend) for t, cue in pairs] == [ + (8.0, 10.0, "bed", None), + (28.0, 30.0, "pulse", "add"), + ] + assert lane_on_transition_trigger_times( + lane, + song_marker_fades=_OFF, + standard_fades=_std(fade_in=2.0, fade_out=2.0), + ) == [8.0, 28.0] + + def test_punch_lane_replaces_cues_in_range() -> None: lane = _lane(1.0, (1.0, 0.0), (5.0, 1.0), (8.0, 0.0), (12.0, 1.0)) result = punch_lane( diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 72ef370..0c36552 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -2960,6 +2960,26 @@ def test_ctrl_quick_nav_does_not_affect_normal_up_down() -> None: assert controls.focus_descriptor == _desc(view, stem_row) +def test_timeline_focus_change_clears_selected_cue_flash() -> None: + controls = _make_controls(("layer_1", "layer_2"), timeline_enabled=True) + controls.session.timeline.panel_open = True + controls.focus_cursor = TimelineFocus(0) + controls.session.timeline.selected_cue_t = {"layer_1": 10.0, "layer_2": 20.0} + controls.session.timeline.selected_cue_flash_start_ms = 1234 + + controls.focus_cursor = TimelineFocus(1) + assert controls.session.timeline.focus_row == 1 + assert controls.session.timeline.selected_cue_t == { + "layer_1": 10.0, + "layer_2": 20.0, + } + assert controls.session.timeline.selected_cue_flash_start_ms is None + + controls.session.timeline.selected_cue_flash_start_ms = 5678 + controls.focus_cursor = TimelineFocus(1) + assert controls.session.timeline.selected_cue_flash_start_ms == 5678 + + def test_ctrl_quick_nav_from_timeline_submenu_jumps_sections() -> None: controls = _make_controls(("layer_1", "layer_2"), timeline_enabled=True) controls.session.timeline.panel_open = True diff --git a/tests/cleave/viz/test_layer.py b/tests/cleave/viz/test_layer.py index a2cb45f..da80847 100644 --- a/tests/cleave/viz/test_layer.py +++ b/tests/cleave/viz/test_layer.py @@ -272,6 +272,66 @@ def test_apply_layer_visibility_sets_fbo_enabled_from_timeline() -> None: assert layers_by_slot["layer_1"].fbo.enabled is True +def test_apply_layer_visibility_writes_cue_blend_with_layer_fallback() -> None: + session = _session( + layer_enabled={"layer_1": True, "layer_2": True, "layer_3": True, "layer_4": True}, + timeline_enabled=True, + lanes={ + "layer_1": TimelineLane( + baseline=0.0, + cues=[ + SlotCue(t=1.0, level=1.0, blend="add"), + SlotCue(t=2.0, level=1.0, blend=None), + ], + ), + }, + ) + session.layers["layer_1"].blend_mode = "screen" + layers_by_slot = {slot: _stem_layer(slot) for slot in DEFAULT_LAYER_SLOTS} + layers_by_slot["layer_1"].fbo.blend_mode = "black-key" + + apply_layer_visibility(session, layers_by_slot, 0.5) + assert layers_by_slot["layer_1"].fbo.blend_mode == "screen" + + apply_layer_visibility(session, layers_by_slot, 1.0) + assert layers_by_slot["layer_1"].fbo.blend_mode == "add" + + apply_layer_visibility(session, layers_by_slot, 2.0) + assert layers_by_slot["layer_1"].fbo.blend_mode == "screen" + + +def test_apply_layer_visibility_cue_blend_then_curation_forces_black_key() -> None: + session = _session( + layer_enabled={"layer_1": True, "layer_2": True, "layer_3": True, "layer_4": True}, + timeline_enabled=True, + lanes={ + "layer_1": TimelineLane( + baseline=0.0, + cues=[SlotCue(t=1.0, level=1.0, blend="add")], + ), + }, + ) + session.layers["layer_1"].blend_mode = "screen" + layers_by_slot = {slot: _stem_layer(slot) for slot in DEFAULT_LAYER_SLOTS} + layers_by_slot["layer_1"].fbo.enabled = True + + apply_layer_visibility(session, layers_by_slot, 1.0) + assert layers_by_slot["layer_1"].fbo.blend_mode == "add" + + session.settings.editor_mode = "preset_curation" + apply_layer_visibility(session, layers_by_slot, 1.0) + assert layers_by_slot["layer_1"].fbo.blend_mode == "screen" + apply_effect_modifiers( + session, + {"layer_1": layers_by_slot["layer_1"]}, + MagicMock(), + None, + 1.0, + update=False, + ) + assert layers_by_slot["layer_1"].fbo.blend_mode == "black-key" + + def test_apply_layer_visibility_fades_enable_before_on_cue() -> None: session = _session( layer_enabled={"layer_1": True, "layer_2": True, "layer_3": True, "layer_4": True}, @@ -887,3 +947,17 @@ def test_build_timeline_view_state_default_song_markers_empty() -> None: state = build_timeline_view_state(session, position_sec=0.0, duration_sec=60.0) assert state.song_marker_times == () assert state.selected_song_marker_index is None + + +def test_build_timeline_view_state_includes_selected_cue_t() -> None: + session = _session( + layer_enabled={"layer_1": True, "layer_2": True, "layer_3": True, "layer_4": True}, + timeline_enabled=True, + ) + session.timeline.selected_cue_t = {"layer_1": 12.5} + session.timeline.selected_cue_flash_start_ms = 1234 + state = build_timeline_view_state(session, position_sec=0.0, duration_sec=60.0) + assert state.selected_cue_t == {"layer_1": 12.5} + assert state.selected_cue_flash_start_ms == 1234 + state.selected_cue_t["layer_1"] = 99.0 + assert session.timeline.selected_cue_t == {"layer_1": 12.5} diff --git a/tests/cleave/viz/test_preset_seed_controls.py b/tests/cleave/viz/test_preset_seed_controls.py index 6e12d17..f10bb0d 100644 --- a/tests/cleave/viz/test_preset_seed_controls.py +++ b/tests/cleave/viz/test_preset_seed_controls.py @@ -44,7 +44,7 @@ def _session(*runtimes: tuple[str, LayerRuntime]) -> TuningSession: def test_prompt_opens_yes_cancel_when_shuffle_on() -> None: session = _session(("layer_1", _runtime(mode="projectm", shuffle=True, salt=1))) - controller = PresetSeedController(session, ModalHost(), {}) + controller = PresetSeedController(session, ModalHost(), {}, preset_root=Path("/tmp/presets")) controller.prompt("layer_1") assert controller._modal.active is True view = controller._modal.view_state() @@ -55,14 +55,14 @@ def test_prompt_opens_yes_cancel_when_shuffle_on() -> None: def test_prompt_noop_when_shuffle_off() -> None: session = _session(("layer_1", _runtime(mode="projectm", shuffle=False))) - controller = PresetSeedController(session, ModalHost(), {}) + controller = PresetSeedController(session, ModalHost(), {}, preset_root=Path("/tmp/presets")) controller.prompt("layer_1") assert controller._modal.active is False def test_prompt_noop_when_switching_none() -> None: session = _session(("layer_1", _runtime(mode="none", shuffle=True))) - controller = PresetSeedController(session, ModalHost(), {}) + controller = PresetSeedController(session, ModalHost(), {}, preset_root=Path("/tmp/presets")) controller.prompt("layer_1") assert controller._modal.active is False @@ -76,6 +76,7 @@ def test_confirm_changes_salt_and_rebuilds_projectm() -> None: session, ModalHost(), {}, + preset_root=Path("/tmp/presets"), on_preset_switching_change=switched.append, ) controller._confirm("layer_1") @@ -107,6 +108,7 @@ def test_confirm_timeline_preserves_switch_count( session, ModalHost(), {"layer_1": layer}, + preset_root=Path("/tmp/presets"), ) controller._confirm("layer_1") assert runtime.preset_switching_shuffle_salt != 5 diff --git a/tests/cleave/viz/test_preset_switching.py b/tests/cleave/viz/test_preset_switching.py index 27d24e4..8db7c8f 100644 --- a/tests/cleave/viz/test_preset_switching.py +++ b/tests/cleave/viz/test_preset_switching.py @@ -38,6 +38,8 @@ Path("/tmp/presets/drums/c.milk"), ) +_PRESET_ROOT = Path("/tmp/presets") + def _stem_layer(*, paths: tuple[Path, ...], index: int = 0) -> StemLayer: current_dir = Path("/tmp/presets/drums") @@ -96,7 +98,7 @@ def test_apply_none_locks_and_disables_hard_cuts() -> None: layer.projectm_playlist = mock_pl layer.auto_preset_path = _MILK[2] - apply_preset_switching(layer, mode="none", rotation_set="directory") + apply_preset_switching(layer, mode="none", rotation_set="directory", preset_root=_PRESET_ROOT) mock_pl.destroy.assert_called_once() assert layer.projectm_playlist is None @@ -119,7 +121,8 @@ def test_apply_projectm_connects_playlist( on_empty = MagicMock() apply_preset_switching( - layer, mode="projectm", rotation_set="directory", on_empty=on_empty + layer, mode="projectm", rotation_set="directory", on_empty=on_empty, + preset_root=_PRESET_ROOT, ) layer.pm.lock_preset.assert_called_with(False) @@ -169,6 +172,7 @@ def test_apply_projectm_shuffle_enabled( rotation_set="directory", shuffle=True, shuffle_salt=salt, + preset_root=_PRESET_ROOT, ) playlist.add_path.assert_not_called() @@ -194,6 +198,7 @@ def test_apply_projectm_respects_hard_cut_disabled( mode="projectm", rotation_set="directory", hard_cut_enabled=False, + preset_root=_PRESET_ROOT, ) layer.pm.set_hard_cut_enabled.assert_called_with(False) @@ -238,7 +243,8 @@ def test_apply_projectm_empty_dir_falls_back(mock_playlist_cls: MagicMock) -> No on_empty = MagicMock() apply_preset_switching( - layer, mode="projectm", rotation_set="directory", on_empty=on_empty + layer, mode="projectm", rotation_set="directory", on_empty=on_empty, + preset_root=_PRESET_ROOT, ) mock_playlist_cls.create.assert_not_called() @@ -271,6 +277,7 @@ def test_apply_user_defined_rotation_set_adds_presets( rotation_set="user_defined", user_presets=[str(_MILK[0]), str(_MILK[1])], on_empty=on_empty, + preset_root=_PRESET_ROOT, ) layer.pm.lock_preset.assert_called_with(False) @@ -296,6 +303,7 @@ def test_apply_user_defined_rotation_set_empty_falls_back( rotation_set="user_defined", user_presets=[], on_empty=on_empty, + preset_root=_PRESET_ROOT, ) mock_playlist_cls.create.assert_not_called() @@ -333,6 +341,7 @@ def test_reapply_projectm_preset_switching_only_projectm_layers() -> None: reapply_projectm_preset_switching( session, {"layer_1": layer_projectm, "layer_2": layer_none}, + preset_root=_PRESET_ROOT, ) mock_apply.assert_not_called() @@ -364,7 +373,7 @@ def test_reapply_without_playlist_falls_back_to_apply() -> None: ) with patch("cleave.viz.preset_switching.apply_preset_switching") as mock_apply: - reapply_projectm_preset_switching(session, {"layer_1": layer}) + reapply_projectm_preset_switching(session, {"layer_1": layer}, preset_root=_PRESET_ROOT) mock_apply.assert_called_once() assert mock_apply.call_args.kwargs.get("on_empty") is None @@ -389,12 +398,13 @@ def test_reapply_without_playlist_does_not_notify_empty( ) apply_preset_switching( - layer, mode="projectm", rotation_set="directory", on_empty=on_empty + layer, mode="projectm", rotation_set="directory", on_empty=on_empty, + preset_root=_PRESET_ROOT, ) on_empty.assert_called_once() on_empty.reset_mock() - reapply_projectm_preset_switching(session, {"layer_1": layer}) + reapply_projectm_preset_switching(session, {"layer_1": layer}, preset_root=_PRESET_ROOT) on_empty.assert_not_called() mock_playlist_cls.create.assert_not_called() @@ -417,7 +427,7 @@ def test_reapply_on_forward_seek_reconnects_without_reload() -> None: }, ) - reapply_projectm_preset_switching(session, {"layer_1": layer}, delta_sec=5.0) + reapply_projectm_preset_switching(session, {"layer_1": layer}, delta_sec=5.0, preset_root=_PRESET_ROOT) playlist.connect.assert_called_once() layer.pm.lock_preset.assert_has_calls([call(False), call(True), call(False)]) @@ -442,7 +452,7 @@ def test_reapply_on_backward_seek_restarts_tracked_preset() -> None: }, ) - reapply_projectm_preset_switching(session, {"layer_1": layer}, delta_sec=-5.0) + reapply_projectm_preset_switching(session, {"layer_1": layer}, delta_sec=-5.0, preset_root=_PRESET_ROOT) playlist.connect.assert_called_once() layer.pm.load_preset.assert_called_once_with(_MILK[2].resolve(), smooth=False) @@ -476,7 +486,7 @@ def _timeline_session(layer: StemLayer, *, enabled: bool = True) -> TuningSessio @patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) def test_apply_timeline_locks_and_loads_anchor(_mock_milk: MagicMock) -> None: layer = _stem_layer(paths=_MILK, index=1) - apply_preset_switching(layer, mode="timeline", rotation_set="directory") + apply_preset_switching(layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT) layer.pm.lock_preset.assert_called_with(True) layer.pm.set_hard_cut_enabled.assert_called_with(False) assert layer.preset_rotation is not None @@ -489,7 +499,7 @@ def test_apply_timeline_locks_and_loads_anchor(_mock_milk: MagicMock) -> None: def test_advance_timeline_loads_next_on_rising_edge(_mock_milk: MagicMock) -> None: layer = _stem_layer(paths=_MILK, index=0) session = _timeline_session(layer) - apply_preset_switching(layer, mode="timeline", rotation_set="directory") + apply_preset_switching(layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT) layer.pm.load_preset.reset_mock() advance_timeline_preset_switching(session, {"layer_1": layer}, 4.9) @@ -522,7 +532,7 @@ def test_advance_timeline_does_not_mark_config_dirty(_mock_milk: MagicMock) -> N save = ConfigSaveController(session, cfg, ModalHost()) assert not save.config_dirty - apply_preset_switching(layer, mode="timeline", rotation_set="directory") + apply_preset_switching(layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT) save.clear_config_dirty() advance_timeline_preset_switching(session, {"layer_1": layer}, 5.0) @@ -537,7 +547,7 @@ def test_advance_timeline_does_not_mark_config_dirty(_mock_milk: MagicMock) -> N def test_advance_timeline_noop_when_timeline_disabled(_mock_milk: MagicMock) -> None: layer = _stem_layer(paths=_MILK, index=0) session = _timeline_session(layer, enabled=False) - apply_preset_switching(layer, mode="timeline", rotation_set="directory") + apply_preset_switching(layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT) layer.pm.load_preset.reset_mock() advance_timeline_preset_switching(session, {"layer_1": layer}, 15.0) @@ -554,6 +564,7 @@ def test_rebuild_timeline_preserves_switch_count(_mock_milk: MagicMock) -> None: rotation_set="directory", shuffle=True, shuffle_salt=1, + preset_root=_PRESET_ROOT, ) layer.timeline_switch_count = 2 layer.rotation_anchor = 1 @@ -565,6 +576,7 @@ def test_rebuild_timeline_preserves_switch_count(_mock_milk: MagicMock) -> None: rotation_set="directory", shuffle=True, shuffle_salt=2, + preset_root=_PRESET_ROOT, ) assert layer.timeline_switch_count == 2 @@ -587,6 +599,7 @@ def test_apply_timeline_shuffle_toggle_preserves_switch_count( rotation_set="directory", shuffle=False, shuffle_salt=7, + preset_root=_PRESET_ROOT, ) layer.timeline_switch_count = 2 layer.rotation_anchor = 0 @@ -599,6 +612,7 @@ def test_apply_timeline_shuffle_toggle_preserves_switch_count( rotation_set="directory", shuffle=True, shuffle_salt=7, + preset_root=_PRESET_ROOT, ) assert layer.timeline_switch_count == 2 @@ -626,6 +640,7 @@ def test_apply_timeline_first_enable_still_resets_count( rotation_set="directory", shuffle=True, shuffle_salt=3, + preset_root=_PRESET_ROOT, ) assert layer.timeline_switch_count == 0 @@ -683,3 +698,131 @@ def test_tick_frame_core_skips_advance_when_paused() -> None: blank_visualizers=False, ) mock_advance.assert_not_called() + + +_PULSE_POOL = ( + Path("/tmp/presets/roles/pulse/p0.milk"), + Path("/tmp/presets/roles/pulse/p1.milk"), +) + + +def _role_timeline_session(layer: StemLayer) -> TuningSession: + session = TuningSession( + layer_z_order=["layer_1"], + layers={ + "layer_1": LayerRuntime( + playlist=layer.playlist, + browse_floor=layer.playlist.current_dir, + stem="drums", + preset_switching="timeline", + ), + }, + ) + session.timeline.enabled = True + session.timeline.lanes["layer_1"] = TimelineLane( + baseline=0.0, + cues=[ + SlotCue(t=5.0, level=1.0, role="pulse"), + SlotCue(t=10.0, level=0.0), + SlotCue(t=15.0, level=1.0, role="pulse"), + SlotCue(t=20.0, level=0.0), + SlotCue(t=25.0, level=1.0), + ], + ) + return session + + +@patch("cleave.viz.preset_switching.role_pool_paths") +@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) +def test_advance_timeline_role_cue_loads_from_role_pool( + _mock_milk: MagicMock, + mock_role_pool: MagicMock, +) -> None: + def _pools(preset_root: Path, role: str): + del preset_root + return _PULSE_POOL if role == "pulse" else () + + mock_role_pool.side_effect = _pools + layer = _stem_layer(paths=_MILK, index=0) + session = _role_timeline_session(layer) + apply_preset_switching( + layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT + ) + assert "pulse" in layer.role_rotations + layer.pm.load_preset.reset_mock() + + advance_timeline_preset_switching(session, {"layer_1": layer}, 5.0) + layer.pm.load_preset.assert_called_once_with(_PULSE_POOL[0], smooth=False) + assert layer.timeline_switch_count == 1 + + +@patch("cleave.viz.preset_switching.role_pool_paths") +@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) +def test_advance_timeline_role_index_is_seek_stable( + _mock_milk: MagicMock, + mock_role_pool: MagicMock, +) -> None: + def _pools(preset_root: Path, role: str): + del preset_root + return _PULSE_POOL if role == "pulse" else () + + mock_role_pool.side_effect = _pools + layer = _stem_layer(paths=_MILK, index=0) + session = _role_timeline_session(layer) + apply_preset_switching( + layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT + ) + layer.pm.load_preset.reset_mock() + + advance_timeline_preset_switching(session, {"layer_1": layer}, 15.0) + assert layer.timeline_switch_count == 2 + assert layer.pm.load_preset.call_args_list[-1] == call(_PULSE_POOL[1], smooth=False) + + layer.timeline_switch_count = 0 + layer.pm.load_preset.reset_mock() + advance_timeline_preset_switching(session, {"layer_1": layer}, 15.0) + layer.pm.load_preset.assert_called_once_with(_PULSE_POOL[1], smooth=False) + assert layer.timeline_switch_count == 2 + + +@patch("cleave.viz.preset_switching.role_pool_paths", return_value=()) +@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) +def test_advance_timeline_empty_role_pool_falls_back_to_main( + _mock_milk: MagicMock, + _mock_role_pool: MagicMock, +) -> None: + layer = _stem_layer(paths=_MILK, index=0) + session = _role_timeline_session(layer) + apply_preset_switching( + layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT + ) + assert layer.role_rotations == {} + layer.pm.load_preset.reset_mock() + + advance_timeline_preset_switching(session, {"layer_1": layer}, 5.0) + layer.pm.load_preset.assert_called_once_with(_MILK[1], smooth=False) + + +@patch("cleave.viz.preset_switching.role_pool_paths") +@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) +def test_advance_timeline_absent_role_uses_main_rotation( + _mock_milk: MagicMock, + mock_role_pool: MagicMock, +) -> None: + def _pools(preset_root: Path, role: str): + del preset_root + return _PULSE_POOL if role == "pulse" else () + + mock_role_pool.side_effect = _pools + layer = _stem_layer(paths=_MILK, index=0) + session = _role_timeline_session(layer) + apply_preset_switching( + layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT + ) + layer.pm.load_preset.reset_mock() + + # Third on-transition (t=25) has no role -> main rotation path_for(3). + advance_timeline_preset_switching(session, {"layer_1": layer}, 25.0) + expected = layer.preset_rotation.path_for(3) + assert layer.pm.load_preset.call_args_list[-1] == call(expected, smooth=False) + assert layer.timeline_switch_count == 3 diff --git a/tests/cleave/viz/test_timeline_controls.py b/tests/cleave/viz/test_timeline_controls.py index 3fe1d28..7ac84ab 100644 --- a/tests/cleave/viz/test_timeline_controls.py +++ b/tests/cleave/viz/test_timeline_controls.py @@ -1125,4 +1125,221 @@ def segments() -> list[tuple[float, float]]: session.layers["layer_1"].enabled = True session.timeline.enabled = True assert segments() == expected - assert expected[0][0] == 0.0 \ No newline at end of file + assert expected[0][0] == 0.0 + + +def test_comma_period_select_nearest_then_step_cues() -> None: + # Off at 10 is skipped; navigable on cues are 4 and 16. + lanes = { + "layer_1": _lane(False, (4.0, True), (10.0, False), (16.0, True)), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=9.0, + ) + assert session.timeline.selected_cue_t == {} + assert session.timeline.selected_cue_flash_start_ms is None + + before = pygame.time.get_ticks() + controls.handle_keydown(keydown(pygame.K_PERIOD)) + # Nearest on cue to playhead 9 is 4 (not the off at 10). + assert session.timeline.selected_cue_t["layer_1"] == 4.0 + first_flash = session.timeline.selected_cue_flash_start_ms + assert first_flash is not None and first_flash >= before + + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t["layer_1"] == 16.0 + second_flash = session.timeline.selected_cue_flash_start_ms + assert second_flash is not None and second_flash >= first_flash + + controls.handle_keydown(keydown(pygame.K_COMMA)) + assert session.timeline.selected_cue_t["layer_1"] == 4.0 + at_start_flash = session.timeline.selected_cue_flash_start_ms + + controls.handle_keydown(keydown(pygame.K_COMMA)) + assert session.timeline.selected_cue_t["layer_1"] == 4.0 + # No new target: flash start is unchanged. + assert session.timeline.selected_cue_flash_start_ms == at_start_flash + + +def test_comma_period_keeps_mid_on_level_changes() -> None: + lanes = { + "layer_1": TimelineLane( + baseline=0.0, + cues=canonicalize( + 0.0, + [ + SlotCue(t=4.0, level=0.5), + SlotCue(t=10.0, level=1.0), + SlotCue(t=16.0, level=0.0), + ], + ), + ), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=5.0, + ) + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t["layer_1"] == 4.0 + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t["layer_1"] == 10.0 + + +def test_comma_period_from_off_selection_jumps_to_nearest_on() -> None: + lanes = { + "layer_1": _lane(False, (4.0, True), (10.0, False), (16.0, True)), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=9.0, + ) + session.timeline.selected_cue_t["layer_1"] = 10.0 + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t["layer_1"] == 4.0 + + +def test_cue_selection_memory_is_per_track() -> None: + lanes = { + "layer_1": _lane(False, (4.0, True), (10.0, False), (16.0, True)), + "layer_2": _lane(False, (8.0, True), (16.0, False)), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + focus_row=0, + position_sec=5.0, + ) + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t["layer_1"] == 4.0 + + session.timeline.focus_row = 1 + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t["layer_2"] == 8.0 + assert session.timeline.selected_cue_t["layer_1"] == 4.0 + + session.timeline.focus_row = 0 + assert session.timeline.selected_cue_t["layer_1"] == 4.0 + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t["layer_1"] == 16.0 + assert session.timeline.selected_cue_t["layer_2"] == 8.0 + + +def test_b_cycles_selected_cue_blend_including_none() -> None: + from cleave.blend_modes import BLEND_MODES + + lanes = { + "layer_1": TimelineLane( + baseline=0.0, + cues=canonicalize( + 0.0, + [SlotCue(t=5.0, level=1.0)], + ), + ), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=5.0, + ) + controls.handle_keydown(keydown(pygame.K_PERIOD)) + cue = session.timeline.lanes["layer_1"].cues[0] + assert cue.blend is None + + controls.handle_keydown(keydown(pygame.K_b)) + cue = session.timeline.lanes["layer_1"].cues[0] + assert cue.blend == BLEND_MODES[0] + + for _ in range(len(BLEND_MODES)): + controls.handle_keydown(keydown(pygame.K_b)) + cue = session.timeline.lanes["layer_1"].cues[0] + assert cue.blend is None + + +def test_c_cycles_selected_cue_role_including_none() -> None: + from cleave.cue_roles import CUE_ROLES + + lanes = { + "layer_1": TimelineLane( + baseline=0.0, + cues=canonicalize( + 0.0, + [SlotCue(t=5.0, level=1.0)], + ), + ), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=5.0, + ) + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.lanes["layer_1"].cues[0].role is None + + controls.handle_keydown(keydown(pygame.K_c)) + assert session.timeline.lanes["layer_1"].cues[0].role == CUE_ROLES[0] + + for _ in range(len(CUE_ROLES)): + controls.handle_keydown(keydown(pygame.K_c)) + assert session.timeline.lanes["layer_1"].cues[0].role is None + + +def test_b_and_c_noop_on_off_cue() -> None: + from cleave.blend_modes import BLEND_MODES + from cleave.cue_roles import CUE_ROLES + + lanes = { + "layer_1": _lane(False, (5.0, True), (10.0, False)), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=10.0, + ) + session.timeline.selected_cue_t["layer_1"] = 10.0 + off_before = session.timeline.lanes["layer_1"].cues[1] + assert off_before.level == 0.0 + controls.handle_keydown(keydown(pygame.K_b)) + controls.handle_keydown(keydown(pygame.K_c)) + off_after = session.timeline.lanes["layer_1"].cues[1] + assert off_after == off_before + assert off_after.blend is None + assert off_after.role is None + # On cue at 5 still editable when selected. + session.timeline.selected_cue_t["layer_1"] = 5.0 + controls.handle_keydown(keydown(pygame.K_b)) + controls.handle_keydown(keydown(pygame.K_c)) + on_cue = session.timeline.lanes["layer_1"].cues[0] + assert on_cue.blend == BLEND_MODES[0] + assert on_cue.role == CUE_ROLES[0] + + +def test_cue_edit_keys_refused_when_locked() -> None: + lanes = { + "layer_1": _lane(False, (5.0, True)), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=5.0, + ) + session.timeline.locked = True + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t == {} + controls.handle_keydown(keydown(pygame.K_b)) + controls.handle_keydown(keydown(pygame.K_c)) + assert session.timeline.lanes["layer_1"].cues[0].blend is None + assert session.timeline.lanes["layer_1"].cues[0].role is None + + +def test_cue_edit_keys_refused_when_recording() -> None: + lanes = { + "layer_1": _lane(False, (5.0, True)), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=5.0, + armed_slots={"layer_1"}, + recording=True, + ) + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t == {} + controls.handle_keydown(keydown(pygame.K_b)) + controls.handle_keydown(keydown(pygame.K_c)) + assert session.timeline.lanes["layer_1"].cues[0].blend is None + assert session.timeline.lanes["layer_1"].cues[0].role is None \ No newline at end of file diff --git a/tests/cleave/viz/test_timeline_overlay.py b/tests/cleave/viz/test_timeline_overlay.py index fd20efc..c85618f 100644 --- a/tests/cleave/viz/test_timeline_overlay.py +++ b/tests/cleave/viz/test_timeline_overlay.py @@ -37,10 +37,21 @@ from cleave.viz.timeline_overlay import ( ARM_FLASH_DURATION_MS, ARM_FLASH_HALF_MS, + BAR_VERTICAL_INSET, PLAYHEAD_FLASH_MS, + ROLE_GLYPH_BOTTOM_PAD, + ROLE_GLYPH_TICK_GAP, + SELECTED_CUE_COLOR, + SELECTED_CUE_FLASH_DURATION_MS, + SELECTED_CUE_FLASH_HALF_MS, + SELECTED_CUE_FLASH_RED, + SELECTED_CUE_FLASH_TICK_WIDTH, + SELECTED_CUE_FLASH_YELLOW, + SELECTED_CUE_TICK_WIDTH, TimelineOverlay, TimelineViewState, arm_abbrev_flash_active, + blit_role_glyph_xor, arm_abbrev_flash_visible, armed_abbrev_bg_visible, bar_level_breakpoints_for_row, @@ -51,8 +62,16 @@ playhead_flash_bright, playhead_x, prune_expired_arm_flashes, + prune_expired_selected_cue_flash, rec_flash_visible, + role_glyph_anchor, + role_glyph_previous_level, + role_glyph_side, row_prefix_width, + selected_cue_flash_active, + selected_cue_flash_bright, + selected_cue_readout_text, + selected_cue_tick_color, stem_abbrev_label, stem_label_text, transport_time_text, @@ -108,6 +127,8 @@ def _view_state( standard_cue_fades_enabled: bool = False, standard_cue_fade_in: float = 2.0, standard_cue_fade_out: float = 2.0, + selected_cue_t: dict[str, float] | None = None, + selected_cue_flash_start_ms: int | None = None, ) -> TimelineViewState: order = list(layer_z_order or list(DEFAULT_LAYER_SLOTS)) lane_map = dict(lanes or {}) @@ -173,6 +194,8 @@ def _view_state( fade_in=standard_cue_fade_in, fade_out=standard_cue_fade_out, ), + selected_cue_t=dict(selected_cue_t or ()), + selected_cue_flash_start_ms=selected_cue_flash_start_ms, ) @@ -911,6 +934,407 @@ def test_bar_without_high_water_mark_behaves_as_before() -> None: assert _bar_visible_at(state, slot, 30.0) is True +def test_selected_cue_readout_text_format() -> None: + assert selected_cue_readout_text( + SlotCue(t=65.0, level=1.0, blend="add", role="lead") + ) == "[01:05] lvl 1.00 blend add cast lead" + assert selected_cue_readout_text( + SlotCue(t=0.0, level=0.25, blend=None, role=None) + ) == "[00:00] lvl 0.25 blend - cast -" + + +def test_draw_with_selected_cue_and_role_does_not_crash() -> None: + pygame.init() + overlay = TimelineOverlay() + state = _view_state( + lanes={ + "layer_1": TimelineLane( + baseline=0.0, + cues=[ + SlotCue(t=10.0, level=1.0, blend="add", role="lead"), + SlotCue(t=20.0, level=0.0), + ], + ) + }, + position_sec=12.0, + focus_row=0, + submenu_focused=True, + selected_cue_t={"layer_1": 10.0}, + ) + surface = pygame.Surface((1280, 720), pygame.SRCALPHA) + _draw(overlay, surface, state) + assert overlay.panel_rect is not None + assert overlay.header_badge_rect is not None + + +def test_role_glyph_not_drawn_on_off_cue() -> None: + pygame.init() + overlay = TimelineOverlay() + # Bypass canonicalize so an off cue can still carry a stale role. + state = _view_state( + layer_z_order=["layer_1"], + defaults={"layer_1": 1.0}, + lanes={ + "layer_1": TimelineLane( + baseline=1.0, + cues=[SlotCue(t=25.0, level=0.0, role="pulse")], + ) + }, + duration_sec=100.0, + focus_row=0, + position_sec=0.0, + ) + composed = overlay.compose_panel( + state, + viewport_width=1280, + viewport_height=720, + ) + assert composed is not None + assert not overlay._cache.last_glyph_rects + + +def test_selected_cue_highlight_drawn_on_static_panel() -> None: + pygame.init() + overlay = TimelineOverlay() + state = _view_state( + layer_z_order=["layer_1"], + defaults={"layer_1": 1.0}, + lanes={ + "layer_1": TimelineLane( + baseline=0.0, + cues=[SlotCue(t=25.0, level=1.0)], + ) + }, + duration_sec=100.0, + focus_row=0, + selected_cue_t={"layer_1": 25.0}, + ) + composed = overlay.compose_panel( + state, + viewport_width=1280, + viewport_height=720, + ) + assert composed is not None + static = overlay._cache.panel + assert static is not None + assert overlay.bar_layout is not None + bar_left, bar_width, _ = overlay.bar_layout + tick_x = time_to_x(25.0, bar_left, bar_width, 100.0) + row_h = overlay.row_layout[0][4] + padding = overlay._padding + # Settled selected tick: full-height HIGHLIGHT, thinner than flash width. + assert SELECTED_CUE_COLOR == HIGHLIGHT + assert SELECTED_CUE_TICK_WIDTH < SELECTED_CUE_FLASH_TICK_WIDTH + assert static.get_at((tick_x, padding + 1))[:3] == SELECTED_CUE_COLOR + assert static.get_at((tick_x, padding + row_h // 2))[:3] == SELECTED_CUE_COLOR + # Thicker than a 1px tick: neighbours within SELECTED_CUE_TICK_WIDTH stay selected. + half = SELECTED_CUE_TICK_WIDTH // 2 + assert static.get_at((tick_x - half, padding + row_h // 2))[:3] == SELECTED_CUE_COLOR + assert static.get_at((tick_x + half, padding + row_h // 2))[:3] == SELECTED_CUE_COLOR + # Settled width does not reach flash thickness. + flash_half = SELECTED_CUE_FLASH_TICK_WIDTH // 2 + assert static.get_at((tick_x - flash_half, padding + row_h // 2))[:3] != SELECTED_CUE_COLOR + assert static.get_at((tick_x + flash_half, padding + row_h // 2))[:3] != SELECTED_CUE_COLOR + + +def test_selected_cue_marker_only_on_focused_track() -> None: + """Remembered selections stay in selected_cue_t; only focus_row draws the marker.""" + pygame.init() + overlay = TimelineOverlay() + lanes = { + "layer_1": TimelineLane( + baseline=0.0, + cues=[SlotCue(t=25.0, level=1.0)], + ), + "layer_2": TimelineLane( + baseline=0.0, + cues=[SlotCue(t=50.0, level=1.0)], + ), + } + selected = {"layer_1": 25.0, "layer_2": 50.0} + base_kwargs = dict( + layer_z_order=["layer_1", "layer_2"], + defaults={"layer_1": 1.0, "layer_2": 1.0}, + lanes=lanes, + duration_sec=100.0, + selected_cue_t=selected, + ) + + def _compose(focus_row: int): + return overlay.compose_panel( + _view_state(focus_row=focus_row, **base_kwargs), + viewport_width=1280, + viewport_height=720, + ) + + def _tick_rgb(cue_t: float, row_index: int) -> tuple[int, int, int]: + static = overlay._cache.panel + assert static is not None + assert overlay.bar_layout is not None + bar_left, bar_width, _ = overlay.bar_layout + tick_x = time_to_x(cue_t, bar_left, bar_width, 100.0) + row_h = overlay.row_layout[row_index][4] + y = overlay._padding + row_index * (row_h + overlay._row_gap) + row_h // 2 + return static.get_at((tick_x, y))[:3] + + assert _compose(0) is not None + assert _tick_rgb(25.0, 0) == SELECTED_CUE_COLOR + assert _tick_rgb(50.0, 1) != SELECTED_CUE_COLOR + + assert _compose(1) is not None + assert _tick_rgb(25.0, 0) != SELECTED_CUE_COLOR + assert _tick_rgb(50.0, 1) == SELECTED_CUE_COLOR + + assert _compose(0) is not None + assert _tick_rgb(25.0, 0) == SELECTED_CUE_COLOR + assert _tick_rgb(50.0, 1) != SELECTED_CUE_COLOR + + +def test_prune_expired_selected_cue_flash() -> None: + assert prune_expired_selected_cue_flash(None, ticks_ms=0) is None + assert prune_expired_selected_cue_flash(1000, ticks_ms=1000) == 1000 + assert ( + prune_expired_selected_cue_flash( + 1000, ticks_ms=1000 + SELECTED_CUE_FLASH_DURATION_MS - 1 + ) + == 1000 + ) + assert ( + prune_expired_selected_cue_flash( + 1000, ticks_ms=1000 + SELECTED_CUE_FLASH_DURATION_MS + ) + is None + ) + + +def test_selected_cue_flash_blink_colors() -> None: + start = 1000 + assert selected_cue_flash_active(start, ticks_ms=start) + assert selected_cue_flash_bright(start, ticks_ms=start) + assert selected_cue_tick_color(start, ticks_ms=start) == SELECTED_CUE_FLASH_YELLOW + assert SELECTED_CUE_FLASH_YELLOW == HIGHLIGHT + assert SELECTED_CUE_COLOR == HIGHLIGHT + assert SELECTED_CUE_FLASH_RED == ARMED_BG + alt_t = start + SELECTED_CUE_FLASH_HALF_MS + assert selected_cue_flash_active(start, ticks_ms=alt_t) + assert not selected_cue_flash_bright(start, ticks_ms=alt_t) + assert selected_cue_tick_color(start, ticks_ms=alt_t) == SELECTED_CUE_FLASH_RED + expired = start + SELECTED_CUE_FLASH_DURATION_MS + assert not selected_cue_flash_active(start, ticks_ms=expired) + assert not selected_cue_flash_bright(start, ticks_ms=expired) + assert selected_cue_tick_color(start, ticks_ms=expired) == SELECTED_CUE_COLOR + + +def test_selected_cue_flash_live_patches_yellow(monkeypatch) -> None: + pygame.init() + overlay = TimelineOverlay() + start = 5000 + state = _view_state( + layer_z_order=["layer_1"], + defaults={"layer_1": 1.0}, + lanes={ + "layer_1": TimelineLane( + baseline=0.0, + cues=[SlotCue(t=25.0, level=1.0)], + ) + }, + duration_sec=100.0, + focus_row=0, + selected_cue_t={"layer_1": 25.0}, + selected_cue_flash_start_ms=start, + ) + # Yellow half of the blink: thick live-patch over the thinner settled static tick. + monkeypatch.setattr(pygame.time, "get_ticks", lambda: start) + composed = overlay.compose_panel( + state, + viewport_width=1280, + viewport_height=720, + ) + assert composed is not None + upload = composed.upload_surface + assert overlay.bar_layout is not None + bar_left, bar_width, _ = overlay.bar_layout + tick_x = time_to_x(25.0, bar_left, bar_width, 100.0) + from cleave.viz.timeline_panel_cache import timeline_badge_reserve_px + + y = timeline_badge_reserve_px() + overlay._padding + overlay.row_layout[0][4] // 2 + assert upload.get_at((tick_x, y))[:3] == SELECTED_CUE_FLASH_YELLOW + flash_half = SELECTED_CUE_FLASH_TICK_WIDTH // 2 + assert upload.get_at((tick_x - flash_half, y))[:3] == SELECTED_CUE_FLASH_YELLOW + assert upload.get_at((tick_x + flash_half, y))[:3] == SELECTED_CUE_FLASH_YELLOW + static = overlay._cache.panel + assert static is not None + static_y = overlay._padding + overlay.row_layout[0][4] // 2 + assert static.get_at((tick_x, static_y))[:3] == SELECTED_CUE_COLOR + assert static.get_at((tick_x - flash_half, static_y))[:3] != SELECTED_CUE_COLOR + + +def test_blit_role_glyph_xor_inverts_destination_rgb() -> None: + pygame.init() + panel = pygame.Surface((48, 48), pygame.SRCALPHA) + bg = (40, 80, 120) + panel.fill((*bg, 255)) + font = pygame.font.SysFont("monospace", 16, bold=True) + touched = blit_role_glyph_xor(panel, "L", x=8, y=8, font=font) + assert touched is not None + expected = (bg[0] ^ 255, bg[1] ^ 255, bg[2] ^ 255) + found = False + x0, y0, w, h = touched + for y in range(y0, y0 + h): + for x in range(x0, x0 + w): + if panel.get_at((x, y))[:3] == expected: + found = True + break + if found: + break + assert found + + +def test_role_glyph_side_on_right_off_left() -> None: + assert role_glyph_side(previous_level=0.0, cue_level=1.0) == "right" + assert role_glyph_side(previous_level=LEVEL_EPS, cue_level=0.5) == "right" + assert role_glyph_side(previous_level=1.0, cue_level=0.0) == "left" + assert role_glyph_side(previous_level=0.25, cue_level=LEVEL_EPS) == "left" + + +def test_role_glyph_side_both_enabled_prefers_right() -> None: + assert role_glyph_side(previous_level=0.25, cue_level=1.0) == "right" + assert role_glyph_side(previous_level=1.0, cue_level=0.5) == "right" + + +def test_role_glyph_previous_level_before_cue() -> None: + lane = TimelineLane( + baseline=0.0, + cues=[ + SlotCue(t=10.0, level=1.0, role="lead"), + SlotCue(t=20.0, level=0.0), + ], + ) + assert role_glyph_previous_level(lane, 10.0, inherit=1.0) == pytest.approx(0.0) + assert role_glyph_previous_level(lane, 20.0, inherit=1.0) == pytest.approx(1.0) + assert role_glyph_previous_level(lane, 0.0, inherit=1.0) == pytest.approx(0.0) + + +def test_role_glyph_anchor_bottom_and_side_offset() -> None: + pygame.init() + bar_rect = pygame.Rect(40, 10, 200, 20) + tick_x = 100 + glyph_w, glyph_h = 8, 12 + right_x, right_y = role_glyph_anchor( + tick_x=tick_x, + bar_rect=bar_rect, + glyph_w=glyph_w, + glyph_h=glyph_h, + side="right", + ) + left_x, left_y = role_glyph_anchor( + tick_x=tick_x, + bar_rect=bar_rect, + glyph_w=glyph_w, + glyph_h=glyph_h, + side="left", + ) + assert right_x == tick_x + ROLE_GLYPH_TICK_GAP + assert left_x == tick_x - ROLE_GLYPH_TICK_GAP - glyph_w + assert right_y == left_y == bar_rect.bottom - glyph_h - ROLE_GLYPH_BOTTOM_PAD + assert right_y + glyph_h <= bar_rect.bottom + assert right_y >= bar_rect.top + assert ROLE_GLYPH_TICK_GAP >= SELECTED_CUE_FLASH_TICK_WIDTH // 2 + + +def test_role_glyph_anchor_clamps_inside_bar() -> None: + pygame.init() + bar_rect = pygame.Rect(40, 10, 30, 16) + glyph_w, glyph_h = 20, 20 + left_x, left_y = role_glyph_anchor( + tick_x=bar_rect.left + 2, + bar_rect=bar_rect, + glyph_w=glyph_w, + glyph_h=glyph_h, + side="left", + ) + right_x, right_y = role_glyph_anchor( + tick_x=bar_rect.right - 2, + bar_rect=bar_rect, + glyph_w=glyph_w, + glyph_h=glyph_h, + side="right", + ) + assert left_x == bar_rect.left + assert right_x == bar_rect.right - glyph_w + assert left_y == bar_rect.top + assert right_y == bar_rect.top + + +def test_role_glyph_xor_drawn_late_on_upload_not_static() -> None: + pygame.init() + overlay = TimelineOverlay() + state = _view_state( + layer_z_order=["layer_1"], + defaults={"layer_1": 1.0}, + lanes={ + "layer_1": TimelineLane( + baseline=0.0, + cues=[SlotCue(t=25.0, level=1.0, role="lead")], + ) + }, + duration_sec=100.0, + focus_row=0, + position_sec=0.0, + ) + composed = overlay.compose_panel( + state, + viewport_width=1280, + viewport_height=720, + ) + assert composed is not None + static = overlay._cache.panel + assert static is not None + upload = composed.upload_surface + assert overlay.bar_layout is not None + bar_left, bar_width, _ = overlay.bar_layout + tick_x = time_to_x(25.0, bar_left, bar_width, 100.0) + row_h = overlay.row_layout[0][4] + row_y = overlay._padding + bar_rect = pygame.Rect( + bar_left, + row_y + BAR_VERTICAL_INSET, + bar_width, + max(1, row_h - BAR_VERTICAL_INSET * 2), + ) + bold = pygame.font.SysFont( + "monospace", timeline_ui_metrics().font_size, bold=True + ) + glyph_w, glyph_h = bold.size("L") + # On cue (baseline 0 -> level 1): glyph to the right of the tick, bar bottom. + glyph_x, glyph_y = role_glyph_anchor( + tick_x=tick_x, + bar_rect=bar_rect, + glyph_w=glyph_w, + glyph_h=glyph_h, + side="right", + ) + assert role_glyph_side(previous_level=0.0, cue_level=1.0) == "right" + assert glyph_x > tick_x + assert glyph_y + glyph_h <= bar_rect.bottom + from cleave.viz.timeline_panel_cache import timeline_badge_reserve_px + + upload_y = timeline_badge_reserve_px() + glyph_y + # Static panel omits glyphs (drawn late on the upload surface after playhead). + assert overlay._cache.last_glyph_rects + diff_found = False + for dy in range(glyph_h): + for dx in range(glyph_w): + static_px = static.get_at((glyph_x + dx, glyph_y + dy))[:3] + upload_px = upload.get_at((glyph_x + dx, upload_y + dy))[:3] + if static_px != upload_px: + diff_found = True + break + if diff_found: + break + assert diff_found + + def test_row_height_constant_across_layer_counts() -> None: pygame.init() overlay = TimelineOverlay() diff --git a/tests/cleave/viz/test_timeline_panel_cache.py b/tests/cleave/viz/test_timeline_panel_cache.py index a75efaf..251b1bb 100644 --- a/tests/cleave/viz/test_timeline_panel_cache.py +++ b/tests/cleave/viz/test_timeline_panel_cache.py @@ -127,6 +127,56 @@ def test_static_signature_changes_on_selected_song_marker_index() -> None: assert _static_sig(first) != _static_sig(second) +def test_static_signature_changes_on_selected_cue_t() -> None: + none_selected = _view_state( + lanes={"layer_1": _lane(True, (10.0, False), (20.0, True))}, + ) + first = _view_state( + lanes={"layer_1": _lane(True, (10.0, False), (20.0, True))}, + selected_cue_t={"layer_1": 10.0}, + ) + second = _view_state( + lanes={"layer_1": _lane(True, (10.0, False), (20.0, True))}, + selected_cue_t={"layer_1": 20.0}, + ) + assert _static_sig(none_selected) != _static_sig(first) + assert _static_sig(first) != _static_sig(second) + + +def test_static_signature_changes_on_cue_blend_and_role() -> None: + level_only = _view_state( + lanes={ + "layer_1": TimelineLane( + baseline=0.0, + cues=canonicalize(0.0, [SlotCue(t=10.0, level=1.0)]), + ) + } + ) + with_blend = _view_state( + lanes={ + "layer_1": TimelineLane( + baseline=0.0, + cues=canonicalize( + 0.0, [SlotCue(t=10.0, level=1.0, blend="add")] + ), + ) + } + ) + with_role = _view_state( + lanes={ + "layer_1": TimelineLane( + baseline=0.0, + cues=canonicalize( + 0.0, [SlotCue(t=10.0, level=1.0, role="lead")] + ), + ) + } + ) + assert _static_sig(level_only) != _static_sig(with_blend) + assert _static_sig(level_only) != _static_sig(with_role) + assert _static_sig(with_blend) != _static_sig(with_role) + + def test_static_signature_changes_on_standard_cue_fades_enabled() -> None: disabled = _view_state(standard_cue_fades_enabled=False) enabled = _view_state(standard_cue_fades_enabled=True) @@ -198,6 +248,28 @@ def test_live_signature_changes_on_playhead_flash() -> None: assert bright != dim +def test_live_signature_changes_on_selected_cue_flash_phase(monkeypatch) -> None: + pygame.init() + from cleave.viz.timeline_overlay import SELECTED_CUE_FLASH_HALF_MS + + overlay = TimelineOverlay() + start = 10_000 + state = _view_state( + position_sec=10.0, + selected_cue_t={"layer_1": 10.0}, + selected_cue_flash_start_ms=start, + lanes={"layer_1": _lane(True, (10.0, False))}, + ) + monkeypatch.setattr(pygame.time, "get_ticks", lambda: start) + bright = overlay._live_flash_signature(state) + monkeypatch.setattr( + pygame.time, "get_ticks", lambda: start + SELECTED_CUE_FLASH_HALF_MS + ) + alt = overlay._live_flash_signature(state) + assert bright + assert bright != alt + + def test_upload_plan_skip_when_paused_and_unchanged() -> None: pygame.init() overlay = TimelineOverlay() diff --git a/tests/cleave/viz/test_wiring.py b/tests/cleave/viz/test_wiring.py index e38306b..e126630 100644 --- a/tests/cleave/viz/test_wiring.py +++ b/tests/cleave/viz/test_wiring.py @@ -128,7 +128,7 @@ def test_reapply_projectm_preset_switching_noop_in_curation_mode() -> None: with patch( "cleave.viz.preset_switching.apply_preset_switching" ) as mock_apply: - reapply_projectm_preset_switching(session, {"layer_1": layer}, delta_sec=5.0) + reapply_projectm_preset_switching(session, {"layer_1": layer}, preset_root=Path("/tmp/presets"), delta_sec=5.0) mock_apply.assert_not_called() diff --git a/tests/support/viz.py b/tests/support/viz.py index 63f9c95..0f67e07 100644 --- a/tests/support/viz.py +++ b/tests/support/viz.py @@ -95,7 +95,6 @@ def noop_layer_bindings(**overrides: Callable) -> LiveLayerBindings: on_preset_switching_change=lambda _slot: None, lock_preset_for_modal=lambda _slot: None, unlock_preset_after_modal=lambda _slot: None, - on_blend_change=lambda _slot, _blend: None, on_stem_change=lambda _slot, _stem: None, on_opacity_change=lambda _slot, _pct: None, on_layer_enabled_change=lambda _slot, _enabled: None, From a9c2f76f9f07bc530d6ef6795d1eac078a3ccd0a Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 26 Jul 2026 16:20:57 +0100 Subject: [PATCH 03/12] Complete phase 2 - stem conductor --- .cursor/rules/live-tuning-ui.mdc | 4 +- .cursor/rules/project-context.mdc | 4 +- README.md | 2 +- cleave/analyse.py | 7 +- cleave/config.py | 4 +- cleave/config_schema.py | 12 + cleave/extract.py | 18 +- cleave/signals.py | 40 +- cleave/timeline_presets/__init__.py | 20 +- cleave/timeline_presets/arrange.py | 47 +- cleave/timeline_presets/conductor.py | 299 +++++++ cleave/timeline_presets/crescendo.py | 89 ++- cleave/viz/controls.py | 3 + cleave/viz/row_fields.py | 32 + cleave/viz/row_sections.py | 2 + cleave/viz/row_semantics.py | 18 +- cleave/viz/session.py | 7 + cleave/viz/timeline_preset_controls.py | 28 +- cleave/viz/tuning_view_state.py | 4 + cleave/viz/wiring.py | 1 + docs/improved-timeline-presets.md | 87 ++- tests/cleave/test_analyse.py | 17 +- tests/cleave/test_cli.py | 6 +- tests/cleave/test_config.py | 14 + tests/cleave/test_config_snapshot.py | 4 + tests/cleave/test_separate.py | 16 +- tests/cleave/test_signals.py | 89 ++- tests/cleave/test_timeline_conductor.py | 735 ++++++++++++++++++ tests/cleave/test_timeline_presets.py | 38 +- tests/cleave/viz/test_controls.py | 89 ++- tests/cleave/viz/test_render.py | 6 +- tests/cleave/viz/test_row_fields.py | 2 +- tests/cleave/viz/test_row_semantics.py | 1 + tests/cleave/viz/test_text_fit.py | 9 +- tests/cleave/viz/test_view_state_structure.py | 10 +- tests/fixtures/minimal_signals.json | 5 +- 36 files changed, 1645 insertions(+), 124 deletions(-) create mode 100644 cleave/timeline_presets/conductor.py create mode 100644 tests/cleave/test_timeline_conductor.py diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index e5950db..293a8d0 100644 --- a/.cursor/rules/live-tuning-ui.mdc +++ b/.cursor/rules/live-tuning-ui.mdc @@ -26,7 +26,7 @@ Confirm, save-choice, and unsaved-quit prompts are drawn by [cleave/viz/modal_ov **Parameterized actions:** when an action row needs one or more parameters, keep the panel row as a plain green `ACTION` line (no expand arrow, no child parameter rows). On Enter, chain one `prompt_choice` (or yes/no) modal per parameter, then apply. Example: snap to song markers asks for proximity, then layer scope ([cleave/viz/timeline_snap_controls.py](cleave/viz/timeline_snap_controls.py)). -**Exception — timeline preset:** parameters live as child value rows under an expandable `timeline preset` section (character, crescendo, density); the green **apply timeline preset** action confirms with Yes/Cancel and lists the staged choices in the modal message ([cleave/viz/timeline_preset_controls.py](cleave/viz/timeline_preset_controls.py)). +**Exception — timeline preset:** parameters live as child value rows under an expandable `timeline preset` section (character, crescendo, density, conductor); the green **apply timeline preset** action confirms with Yes/Cancel and lists the staged choices in the modal message ([cleave/viz/timeline_preset_controls.py](cleave/viz/timeline_preset_controls.py)). ## Track rows section @@ -52,7 +52,7 @@ Below overlay, **Render: POST FX** (`RowKind.RENDER_POST_FX_HEADER`) is always p **Section lock** (overlay, post-FX, timeline, and layer tracks share one mechanism in [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py)): persisted `locked` on `RenderOverlayConfig` / `RenderPostFxConfig` / `TimelineConfig` and the matching runtimes. **l** on `RENDER_OVERLAY_HEADER`, `RENDER_POST_FX_HEADER`, `RENDER_TIMELINE_HEADER` (or `TRACK_HEADER`) toggles it and draws a red `LOCK_ICON` on the header. When locked: the header still expands/opens and expandable child headers stay navigable, but value and action children are drawn in `LOCKED` and skipped in navigation (`section_locked(state, desc)` with `row_navigable_when_section_locked` / `row_blocked_by_section_lock`); **Ctrl** enable/disable is refused while solo stays allowed. `section_locked` accepts either a `TuningViewState` (tracks / `render_timeline`) or a `TuningSession` (layers / `timeline`). In preset curation mode, `section_locked` always returns false (lock icon, `LOCKED` coloring, navigation skip, and mutation blocks are all ignored) so layer browse and **f** / **b** / **r** stay available. -Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always present. This row is a **panel anchor** (not an expandable section): the timeline strip is hosted separately ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)), not as `RowLayout` children. Eye semantics match post-FX (no solo in v1). **Ctrl+Right** / **Ctrl+Left** sets `session.timeline.enabled`; **Right** opens the timeline strip without entering the submenu; **Left** closes it. **t** toggles the strip: when closed, opens and enters the submenu on row 0; when open, closes and returns focus to this header. Expand arrow reflects `session.timeline.panel_open` (▼ when open). Disable closes the strip; **Right** / **t** can still open it while disabled (same expand-while-disabled semantics as layer and other render headers). State: `RenderTimelineBlock` / `TimelineRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). `enabled` and `locked` persist via config snapshot; staged timeline preset character / crescendo / density persist under `timeline.preset`; `panel_open` is UI-only. When the strip is open, main-panel children under **Render: TIMELINE** are: song markers (expandable; marker items and **snap to song markers** when expanded), beat / bar grid (expandable; placement snap, bar grid, bar phase, snap to grid), timeline fades (expandable; song markers and standard cues each with enabled/disabled and fade in/out duration when enabled), timeline preset (expandable; character, crescendo, density, and **apply timeline preset** when expanded), and reset timeline. **snap to song markers** is a green action row inside the song markers section (last row after marker items; Enter opens proximity then layer-scope choice modals). **snap to grid** is a green action row under beat / bar grid (Enter opens BEATS / BARS / CANCEL). When the timeline is locked, those children are blocked and skipped in navigation (expandable headers stay navigable), and the strip stays openable and seekable (Seek Left/Right); Space still toggles transport play/pause (session-only preview), but arm, record, override, number-key visibility, and Ctrl+Space record stay blocked ([cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)); preset/snap/phase controllers also refuse while locked. **l** on the timeline header is refused while `timeline.recording` is true. +Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always present. This row is a **panel anchor** (not an expandable section): the timeline strip is hosted separately ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)), not as `RowLayout` children. Eye semantics match post-FX (no solo in v1). **Ctrl+Right** / **Ctrl+Left** sets `session.timeline.enabled`; **Right** opens the timeline strip without entering the submenu; **Left** closes it. **t** toggles the strip: when closed, opens and enters the submenu on row 0; when open, closes and returns focus to this header. Expand arrow reflects `session.timeline.panel_open` (▼ when open). Disable closes the strip; **Right** / **t** can still open it while disabled (same expand-while-disabled semantics as layer and other render headers). State: `RenderTimelineBlock` / `TimelineRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). `enabled` and `locked` persist via config snapshot; staged timeline preset character / crescendo / density / conductor persist under `timeline.preset`; `panel_open` is UI-only. When the strip is open, main-panel children under **Render: TIMELINE** are: song markers (expandable; marker items and **snap to song markers** when expanded), beat / bar grid (expandable; placement snap, bar grid, bar phase, snap to grid), timeline fades (expandable; song markers and standard cues each with enabled/disabled and fade in/out duration when enabled), timeline preset (expandable; character, crescendo, density, conductor, and **apply timeline preset** when expanded), and reset timeline. **snap to song markers** is a green action row inside the song markers section (last row after marker items; Enter opens proximity then layer-scope choice modals). **snap to grid** is a green action row under beat / bar grid (Enter opens BEATS / BARS / CANCEL). When the timeline is locked, those children are blocked and skipped in navigation (expandable headers stay navigable), and the strip stays openable and seekable (Seek Left/Right); Space still toggles transport play/pause (session-only preview), but arm, record, override, number-key visibility, and Ctrl+Space record stay blocked ([cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)); preset/snap/phase controllers also refuse while locked. **l** on the timeline header is refused while `timeline.recording` is true. ## Timeline panel diff --git a/.cursor/rules/project-context.mdc b/.cursor/rules/project-context.mdc index 69b4043..7c2c5d9 100644 --- a/.cursor/rules/project-context.mdc +++ b/.cursor/rules/project-context.mdc @@ -5,10 +5,10 @@ alwaysApply: true # Cleave project context -- BAU iterative development. Editor: `python -m cleave play` via [cleave/cli.py](cleave/cli.py) calling `cleave.viz.launch()`; [cleave.py](cleave.py) is an alias; implementation in [cleave/viz/](cleave/viz/) (up to eight Milkdrop/libprojectM layers, default four; add/remove in live tuning, 1280x720 default resolution, live at display frame rate, offline render fps via `render.fps`, stem PCM). Black-key stack in [cleave/gl_compositor.py](cleave/gl_compositor.py). GPU post-FX (bloom, grit, highlight rolloff, chroma boost): [cleave/gl_post_process.py](cleave/gl_post_process.py). Live tuning: session in [cleave/viz/session.py](cleave/viz/session.py), input in [cleave/viz/controls.py](cleave/viz/controls.py), view state in [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py), panel draw in [cleave/viz/tuning_panel_draw.py](cleave/viz/tuning_panel_draw.py); layout in [cleave/viz/row_layout.py](cleave/viz/row_layout.py); section tree in [cleave/viz/row_sections.py](cleave/viz/row_sections.py); panel field manifest in [cleave/viz/row_fields.py](cleave/viz/row_fields.py); shared live/offline frame finish in [cleave/viz/frame_finish.py](cleave/viz/frame_finish.py). projectM user time is a monotonic render clock ([cleave/viz/projectm_frame_clock.py](cleave/viz/projectm_frame_clock.py)), not song playhead. Timeline: per-track lanes (`TimelineLane` / `SlotCue(t, level)` in [cleave/timeline.py](cleave/timeline.py); `timeline.lanes` in YAML; strip in [cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)). Render credits overlay in [cleave/viz/render_overlay.py](cleave/viz/render_overlay.py) (`render.overlay` in YAML): live preview in play, burned in by [cleave/viz/render.py](cleave/viz/render.py) offline. Cleave effects: [cleave/effects/](cleave/effects/) (dispatch via [cleave/effects/handlers.py](cleave/effects/handlers.py)). Paths: [cleave/paths.py](cleave/paths.py) (repo-root data dir by default, `projects//`; `CLEAVE_DATA` override). Config: parse and defaults in [cleave/config_schema.py](cleave/config_schema.py); repo-root [cleave-viz.yaml](cleave-viz.yaml) copied into projects; `unnamed-N.yaml` snapshots via [cleave/config_snapshot.py](cleave/config_snapshot.py). Shared easing: [cleave/easing.py](cleave/easing.py). Architecture conventions: [.cursor/rules/architecture-principles.mdc](.cursor/rules/architecture-principles.mdc). +- BAU iterative development. Editor: `python -m cleave play` via [cleave/cli.py](cleave/cli.py) calling `cleave.viz.launch()`; [cleave.py](cleave.py) is an alias; implementation in [cleave/viz/](cleave/viz/) (up to eight Milkdrop/libprojectM layers, default four; add/remove in live tuning, 1280x720 default resolution, live at display frame rate, offline render fps via `render.fps`, stem PCM). Black-key stack in [cleave/gl_compositor.py](cleave/gl_compositor.py). GPU post-FX (bloom, grit, highlight rolloff, chroma boost): [cleave/gl_post_process.py](cleave/gl_post_process.py). Live tuning: session in [cleave/viz/session.py](cleave/viz/session.py), input in [cleave/viz/controls.py](cleave/viz/controls.py), view state in [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py), panel draw in [cleave/viz/tuning_panel_draw.py](cleave/viz/tuning_panel_draw.py); layout in [cleave/viz/row_layout.py](cleave/viz/row_layout.py); section tree in [cleave/viz/row_sections.py](cleave/viz/row_sections.py); panel field manifest in [cleave/viz/row_fields.py](cleave/viz/row_fields.py); shared live/offline frame finish in [cleave/viz/frame_finish.py](cleave/viz/frame_finish.py). projectM user time is a monotonic render clock ([cleave/viz/projectm_frame_clock.py](cleave/viz/projectm_frame_clock.py)), not song playhead. Timeline: per-track lanes (`TimelineLane` / `SlotCue(t, level)` in [cleave/timeline.py](cleave/timeline.py); `timeline.lanes` in YAML; strip in [cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)); opt-in stem conductor for generative presets in [cleave/timeline_presets/conductor.py](cleave/timeline_presets/conductor.py). Render credits overlay in [cleave/viz/render_overlay.py](cleave/viz/render_overlay.py) (`render.overlay` in YAML): live preview in play, burned in by [cleave/viz/render.py](cleave/viz/render.py) offline. Cleave effects: [cleave/effects/](cleave/effects/) (dispatch via [cleave/effects/handlers.py](cleave/effects/handlers.py)). Paths: [cleave/paths.py](cleave/paths.py) (repo-root data dir by default, `projects//`; `CLEAVE_DATA` override). Config: parse and defaults in [cleave/config_schema.py](cleave/config_schema.py); repo-root [cleave-viz.yaml](cleave-viz.yaml) copied into projects; `unnamed-N.yaml` snapshots via [cleave/config_snapshot.py](cleave/config_snapshot.py). Shared easing: [cleave/easing.py](cleave/easing.py). Architecture conventions: [.cursor/rules/architecture-principles.mdc](.cursor/rules/architecture-principles.mdc). - Must-do list: [docs/todos.md](docs/todos.md). Aspirational: [docs/roadmap.md](docs/roadmap.md). - See [README.md](README.md) for usage. -- Project layout: `projects//` under repo root (or `CLEAVE_DATA` override) with copied mix audio, `project.yaml`, `signals.json`, `stems/` (four stem wavs), and optional configs. `separate` copies the source file, writes the manifest, runs Demucs, and writes `signals.json`; the editor reads the mix from `project.yaml`. CLI: `python -m cleave separate|play` (or `python cleave.py`, same entry point). +- Project layout: `projects//` under repo root (or `CLEAVE_DATA` override) with copied mix audio, `project.yaml`, `signals.json` (version 4), `stems/` (four stem wavs), and optional configs. `separate` copies the source file, writes the manifest, runs Demucs, and writes `signals.json`; the editor reads the mix from `project.yaml`. CLI: `python -m cleave separate|play` (or `python cleave.py`, same entry point). - Stem PCM ([cleave/stem_pcm.py](cleave/stem_pcm.py), [cleave/pcm_io.py](cleave/pcm_io.py)) preserves native mono or stereo from WAV files; [cleave/projectm.py](cleave/projectm.py) feeds libprojectM with matching channel layout. Mix playback and solo use stereo SDL output. Analysis for `signals.json` ([cleave/extract.py](cleave/extract.py)) stays mono. - Overlay layout and typography: [.cursor/rules/live-tuning-ui.mdc](.cursor/rules/live-tuning-ui.mdc). - Preset curation: `favourites/` and `blacklist/` under `paths.preset_root`; file ops in [cleave/preset_curation.py](cleave/preset_curation.py), modal orchestration in [cleave/viz/preset_curation_controls.py](cleave/viz/preset_curation_controls.py); **f** / **b** / **r** on preset file rows (`PRESET_FILE_ROW_KINDS` in [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py)); dedup via [cleave/viz/user_presets.py](cleave/viz/user_presets.py) `resolve_user_preset_dest`. diff --git a/README.md b/README.md index 164c943..0653a98 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ The project directory stores all files required in a self-contained bundle... * `project.yaml` - project metadata * `cleave-viz.yaml` - editor & final render configuration. Not everything in here is surfaced in the editor UI just yet -* `signals.json` - audio analysis data used by `cleave effects` +* `signals.json` - audio analysis data (schema version 4) used by `cleave effects` and the opt-in timeline preset conductor; re-run `separate` on existing projects after a schema bump so envelopes stay current * `mysong.wav` - original source audio is copied into the project directory * `stems/` - separated audio stems * `renders/` - final output renders diff --git a/cleave/analyse.py b/cleave/analyse.py index 511bd76..022f37d 100644 --- a/cleave/analyse.py +++ b/cleave/analyse.py @@ -68,7 +68,7 @@ def run_analyse( downbeat_times = [float(t) for t in downbeats] output: dict = { - "version": 3, + "version": 4, "sample_rate_hz": int(TARGET_HZ), "duration_sec": duration_sec, "beat_detection_stem": beat_detection_stem, @@ -91,7 +91,10 @@ def run_analyse( ), }, "other": { - "spectral_centroid": resample_to_100hz(*other, duration_sec).tolist(), + "spectral_centroid": resample_to_100hz( + *other["spectral_centroid"], duration_sec + ).tolist(), + "rms": resample_to_100hz(*other["rms"], duration_sec).tolist(), }, "full_mix": { "onset_strength": resample_to_100hz(*mix_onset, duration_sec).tolist(), diff --git a/cleave/config.py b/cleave/config.py index 7649fbf..f5e440f 100644 --- a/cleave/config.py +++ b/cleave/config.py @@ -109,6 +109,7 @@ ) from cleave.timeline import TimelineLane from cleave.timeline_presets.characters import DEFAULT_TIMELINE_PRESET_KIND +from cleave.timeline_presets.conductor import DEFAULT_TIMELINE_PRESET_CONDUCTOR from cleave.timeline_presets.crescendo import CrescendoTarget from cleave.timeline_presets.density import ( DEFAULT_TIMELINE_PRESET_DENSITY, @@ -268,11 +269,12 @@ class TimelineFadesConfig: @dataclass(frozen=True) class TimelinePresetConfig: - """Staged character / crescendo / density for the timeline preset action.""" + """Staged character / crescendo / density / conductor for the timeline preset action.""" character: str = DEFAULT_TIMELINE_PRESET_KIND crescendo: CrescendoTarget | None = None density: TimelinePresetDensity = DEFAULT_TIMELINE_PRESET_DENSITY + conductor: bool = DEFAULT_TIMELINE_PRESET_CONDUCTOR @dataclass(frozen=True) diff --git a/cleave/config_schema.py b/cleave/config_schema.py index a4857b5..c911365 100644 --- a/cleave/config_schema.py +++ b/cleave/config_schema.py @@ -19,6 +19,7 @@ DEFAULT_TIMELINE_PRESET_KIND, TIMELINE_PRESET_KIND_OPTIONS, ) +from cleave.timeline_presets.conductor import DEFAULT_TIMELINE_PRESET_CONDUCTOR from cleave.timeline_presets.crescendo import CrescendoTarget from cleave.timeline_presets.density import ( DEFAULT_TIMELINE_PRESET_DENSITY, @@ -413,6 +414,12 @@ def parse_timeline_preset_density(raw: Any, label: str) -> TimelinePresetDensity return value # type: ignore[return-value] +def parse_timeline_preset_conductor(raw: Any, label: str) -> bool: + if not isinstance(raw, bool): + raise ValueError(f"{label} must be true or false") + return raw + + FieldSource = Literal["cfg", "session", "both"] T = TypeVar("T") @@ -2104,6 +2111,10 @@ def _parse_timeline_preset(raw: Any) -> Any: preset_map.get("density", DEFAULT_TIMELINE_PRESET_DENSITY), "timeline.preset.density", ), + conductor=parse_timeline_preset_conductor( + preset_map.get("conductor", DEFAULT_TIMELINE_PRESET_CONDUCTOR), + "timeline.preset.conductor", + ), ) @@ -2240,6 +2251,7 @@ def persist_timeline(ctx: PersistCtx) -> dict[str, Any]: "character": runtime.timeline_preset_kind, "crescendo": runtime.timeline_preset_crescendo, "density": runtime.timeline_preset_density, + "conductor": runtime.timeline_preset_conductor, }, } lanes_out: dict[str, Any] = {} diff --git a/cleave/extract.py b/cleave/extract.py index 5fda065..1e9255c 100644 --- a/cleave/extract.py +++ b/cleave/extract.py @@ -89,6 +89,11 @@ class VocalSignals(TypedDict): pitch_hz: tuple[np.ndarray, np.ndarray] +class OtherSignals(TypedDict): + spectral_centroid: tuple[np.ndarray, np.ndarray] + rms: tuple[np.ndarray, np.ndarray] + + def _load(path: Path | str) -> tuple[np.ndarray, float]: y, sr = librosa.load(path, sr=None, mono=True) return y, float(sr) @@ -203,11 +208,14 @@ def extract_vocals(path: Path | str, *, high_quality: bool = False) -> VocalSign } -def extract_other(path: Path | str) -> tuple[np.ndarray, np.ndarray]: - """Spectral centroid over time.""" +def extract_other(path: Path | str) -> OtherSignals: + """Spectral centroid and RMS envelope over time.""" y, sr = _load(path) - values = librosa.feature.spectral_centroid( + centroid = librosa.feature.spectral_centroid( y=y, sr=sr, hop_length=HOP_LENGTH, n_fft=N_FFT )[0] - times = _frame_times(len(values), sr, HOP_LENGTH) - return values, times + centroid_times = _frame_times(len(centroid), sr, HOP_LENGTH) + return { + "spectral_centroid": (centroid, centroid_times), + "rms": _rms_envelope(y, sr, HOP_LENGTH), + } diff --git a/cleave/signals.py b/cleave/signals.py index 5b06aa0..498c3f1 100644 --- a/cleave/signals.py +++ b/cleave/signals.py @@ -11,9 +11,10 @@ _META_KEYS = frozenset( {"version", "sample_rate_hz", "duration_sec", "beat_times", "downbeat_times"} ) -SIGNALS_VERSION = 3 +SIGNALS_VERSION = 4 _EXPECTED_STEMS = frozenset({"drums", "bass", "vocals", "other", "full_mix"}) _FULL_MIX_KEYS = frozenset({"onset_strength", "rms"}) +_OTHER_KEYS = frozenset({"spectral_centroid", "rms"}) def resolve_signals_path(path: Path) -> Path: @@ -75,6 +76,33 @@ def normalized( self._normalized_cache[cache_key] = result return result + def window_mean( + self, + stem: str, + key: str, + t0: float, + t1: float, + *, + percentile: float = 99.0, + ) -> float: + """Mean of the percentile-normalized envelope over ``[t0, t1)``; NaN-safe.""" + values = self.normalized(stem, key, percentile) + if len(values) == 0: + return 0.0 + i0 = int(t0 * self.sample_rate_hz) + i1 = int(t1 * self.sample_rate_hz) + i0 = max(0, min(len(values), i0)) + i1 = max(0, min(len(values), i1)) + if i1 <= i0: + return 0.0 + window = values[i0:i1] + if np.all(np.isnan(window)): + return 0.0 + mean = float(np.nanmean(window)) + if np.isnan(mean): + return 0.0 + return mean + @property def onset_normalized(self) -> np.ndarray: return self.normalized("drums", "onset_strength") @@ -85,7 +113,7 @@ def _validate_signals_data(data: dict, stems: dict[str, dict[str, np.ndarray]]) if version != SIGNALS_VERSION: raise ValueError( f"unsupported signals.json version {version!r}; " - f"re-run: python -m cleave analyse " + f"re-run: python -m cleave separate " ) missing_stems = _EXPECTED_STEMS - set(stems) @@ -96,7 +124,7 @@ def _validate_signals_data(data: dict, stems: dict[str, dict[str, np.ndarray]]) if "mix_onset_strength" in stems.get("drums", {}): raise ValueError( "signals.json drums.mix_onset_strength is obsolete; " - "re-run: python -m cleave analyse " + "re-run: python -m cleave separate " ) full_mix_keys = set(stems.get("full_mix", {})) @@ -105,6 +133,12 @@ def _validate_signals_data(data: dict, stems: dict[str, dict[str, np.ndarray]]) missing = ", ".join(sorted(missing_full_mix)) raise ValueError(f"signals.json full_mix missing key(s): {missing}") + other_keys = set(stems.get("other", {})) + missing_other = _OTHER_KEYS - other_keys + if missing_other: + missing = ", ".join(sorted(missing_other)) + raise ValueError(f"signals.json other missing key(s): {missing}") + def load_signals(path: Path) -> Signals: signals_path = resolve_signals_path(path) diff --git a/cleave/timeline_presets/__init__.py b/cleave/timeline_presets/__init__.py index 891ac8c..40a524d 100644 --- a/cleave/timeline_presets/__init__.py +++ b/cleave/timeline_presets/__init__.py @@ -3,8 +3,10 @@ from __future__ import annotations import random -from collections.abc import Sequence +from collections.abc import Mapping, Sequence +from cleave.extract import StemSource +from cleave.signals import Signals from cleave.timeline import TimelineLane from cleave.timeline_presets.arrange import PHRASE_SEC_MIN, compose_timeline from cleave.timeline_presets.characters import ( @@ -55,6 +57,8 @@ def build_breathing_cues( bar_times: Sequence[float] = (), song_marker_times: Sequence[float] = (), density_bias: int = 0, + signals: Signals | None = None, + slot_stems: Mapping[str, StemSource] | None = None, ) -> dict[str, TimelineLane]: return compose_timeline( slots, @@ -64,6 +68,8 @@ def build_breathing_cues( bar_times, song_marker_times=song_marker_times, density_bias=density_bias, + signals=signals, + slot_stems=slot_stems, ) @@ -74,6 +80,8 @@ def build_dialogue_cues( bar_times: Sequence[float] = (), song_marker_times: Sequence[float] = (), density_bias: int = 0, + signals: Signals | None = None, + slot_stems: Mapping[str, StemSource] | None = None, ) -> dict[str, TimelineLane]: return compose_timeline( slots, @@ -83,6 +91,8 @@ def build_dialogue_cues( bar_times, song_marker_times=song_marker_times, density_bias=density_bias, + signals=signals, + slot_stems=slot_stems, ) @@ -93,6 +103,8 @@ def build_arc_cues( bar_times: Sequence[float] = (), song_marker_times: Sequence[float] = (), density_bias: int = 0, + signals: Signals | None = None, + slot_stems: Mapping[str, StemSource] | None = None, ) -> dict[str, TimelineLane]: return compose_timeline( slots, @@ -102,6 +114,8 @@ def build_arc_cues( bar_times, song_marker_times=song_marker_times, density_bias=density_bias, + signals=signals, + slot_stems=slot_stems, ) @@ -112,6 +126,8 @@ def build_pulse_cues( bar_times: Sequence[float] = (), song_marker_times: Sequence[float] = (), density_bias: int = 0, + signals: Signals | None = None, + slot_stems: Mapping[str, StemSource] | None = None, ) -> dict[str, TimelineLane]: return compose_timeline( slots, @@ -121,6 +137,8 @@ def build_pulse_cues( bar_times, song_marker_times=song_marker_times, density_bias=density_bias, + signals=signals, + slot_stems=slot_stems, ) diff --git a/cleave/timeline_presets/arrange.py b/cleave/timeline_presets/arrange.py index 58a6af0..18f89dd 100644 --- a/cleave/timeline_presets/arrange.py +++ b/cleave/timeline_presets/arrange.py @@ -3,8 +3,10 @@ from __future__ import annotations import random -from collections.abc import Sequence +from collections.abc import Mapping, Sequence +from cleave.extract import StemSource +from cleave.signals import Signals from cleave.timeline import TimelineLane from cleave.timeline_presets.characters import CharacterProfile, in_climax_window from cleave.timeline_presets.chords import ( @@ -13,6 +15,7 @@ build_vocab, density_score_bonus, ) +from cleave.timeline_presets.conductor import PhraseWeights, StemConductor from cleave.timeline_presets.emit import cues_from_states, levels_from_active from cleave.timeline_presets.grid import thin_bar_times_for_arrange from cleave.timeline_presets.motifs import ( @@ -34,6 +37,8 @@ # Motif-pick score weight: favors fuller peaks when density_bias > 0. _DENSITY_PICK_WEIGHT = 1.0 +# Soft bias toward chords whose stems are active in the current phrase. +_CONDUCTOR_PICK_WEIGHT = 2.0 def compose_timeline( @@ -44,6 +49,8 @@ def compose_timeline( bar_times: Sequence[float], song_marker_times: Sequence[float] = (), density_bias: int = 0, + signals: Signals | None = None, + slot_stems: Mapping[str, StemSource] | None = None, ) -> dict[str, TimelineLane]: slot_list = list(slots) if not slot_list or duration_sec <= 0.0: @@ -69,6 +76,10 @@ def compose_timeline( opening = frozenset({order[0]}) return cues_from_states(slot_list, [(0.0, levels_from_active(opening))]) + conductor = StemConductor.build( + signals, slot_stems, phrases, density_bias=density_bias + ) + states: list[tuple[float, frozenset[str]]] = [] prev_active: frozenset[str] | None = None prev_motif_id: str | None = None @@ -82,8 +93,11 @@ def compose_timeline( budget_scale = budget_scale_for(density_bias) for phrase_i, (phrase_start, phrase_end) in enumerate(phrases): - progress = (phrase_start + phrase_end) * 0.5 / duration_sec - budget = max(1.0, profile.envelope(progress) * budget_scale) + midpoint = (phrase_start + phrase_end) * 0.5 + progress = midpoint / duration_sec + weights = None if conductor is None else conductor.phrase_at(midpoint) + budget_gain = 1.0 if weights is None else weights.budget_gain + budget = max(1.0, profile.envelope(progress) * budget_scale * budget_gain) climax_window = in_climax_window(progress) force_climax = ( profile.climax_motif_id is not None @@ -91,6 +105,12 @@ def compose_timeline( and not climax_used ) + rotation = solo_rotation + if conductor is not None and weights is not None: + # vocab.slots is index-aligned with vocab.singles, so the returned + # index feeds the motif rotation directly. + rotation = conductor.rotation_for(vocab.slots, weights, layer_airtime) + motif = _pick_motif( motifs=motifs, profile=profile, @@ -102,10 +122,12 @@ def compose_timeline( prev_active=prev_active, prev_motif_id=prev_motif_id, motif_streak=motif_streak, - solo_rotation=solo_rotation, + solo_rotation=rotation, layer_airtime=layer_airtime, duration_sec=duration_sec, force_climax=force_climax, + conductor=conductor, + phrase_weights=weights, ) if motif.id == profile.climax_motif_id and climax_window: @@ -121,7 +143,7 @@ def compose_timeline( motif, vocab, rng, - solo_rotation, + rotation, phrase_start, phrase_end, bars, @@ -165,10 +187,12 @@ def compose_timeline( _apply_resolve(profile, vocab, states, duration_sec, bars, rng) states.sort(key=lambda item: item[0]) - return cues_from_states( - slot_list, - [(t, levels_from_active(active)) for t, active in states], + level_states = ( + [(t, levels_from_active(active)) for t, active in states] + if conductor is None + else conductor.level_states(states, duration_sec) ) + return cues_from_states(slot_list, level_states) def _normalize_song_markers( @@ -361,6 +385,8 @@ def _pick_motif( layer_airtime: dict[str, float], duration_sec: float, force_climax: bool = False, + conductor: StemConductor | None = None, + phrase_weights: PhraseWeights | None = None, ) -> MotifDef: if force_climax and profile.climax_motif_id is not None: forced = next((m for m in motifs if m.id == profile.climax_motif_id), None) @@ -389,6 +415,11 @@ def _pick_motif( ) score += _DENSITY_PICK_WEIGHT * vocab.density_bias * (peak_card - 1) + if conductor is not None and phrase_weights is not None: + score += _CONDUCTOR_PICK_WEIGHT * conductor.chord_score( + first_active, phrase_weights + ) + if prev_active is not None: dist = hamming_distance(prev_active, first_active) if dist == 1: diff --git a/cleave/timeline_presets/conductor.py b/cleave/timeline_presets/conductor.py new file mode 100644 index 0000000..5cfe4ed --- /dev/null +++ b/cleave/timeline_presets/conductor.py @@ -0,0 +1,299 @@ +"""Stem conductor: audio-derived phrase weights and continuous cue levels.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass + +import numpy as np + +from cleave.extract import StemSource +from cleave.signals import Signals +from cleave.timeline import LEVEL_QUANTUM, clamp_level, quantize_level + +_STEM_PRESENCE: dict[StemSource, str] = { + "drums": "onset_strength", + "bass": "rms", + "vocals": "rms", + "other": "rms", + "full_mix": "rms", +} +CONDUCTOR_LEVEL_FLOOR = LEVEL_QUANTUM # an active slot is never dimmer +# Budget multipliers span a range centred on 1.0: the conductor moves weight +# between phrases rather than lowering it across the whole song. +CONDUCTOR_GAIN_MIN, CONDUCTOR_GAIN_MAX = 0.65, 1.35 +# Lead level follows phrase energy relative to the song peak, so only genuinely +# quiet passages dim. A within-song rank would dim half of every song. +# Exponent > 1 steepens the curve so typical compressed masters still land +# across multiple LEVEL_QUANTUM steps after quantize (0.5 softens toward 1.0). +CONDUCTOR_CEILING_MIN = 0.5 +CONDUCTOR_CEILING_EXPONENT = 2.0 +CONDUCTOR_SILENCE_FLOOR = 0.08 +# Neutral point of a standardised slot activity (rank fraction across phrases). +CONDUCTOR_ACTIVITY_MIDPOINT = 0.5 +# Lowest fraction of the lead level a supporting slot may take, by density bias: +# denser staging keeps the whole stack visible instead of a lead plus ghosts. +# Floors must leave headroom below 1.0 after LEVEL_QUANTUM quantize; 0.75+ with +# a near-full lead ceiling collapses every active slot to 1.0. +CONDUCTOR_SUPPORT_FLOOR_BY_BIAS: dict[int, float] = { + -2: 0.25, + -1: 0.35, + 0: 0.45, + 1: 0.55, + 2: 0.70, +} +# Penalty on a slot's share of accumulated airtime above an even share. +AIRTIME_PENALTY = 1.0 + +DEFAULT_TIMELINE_PRESET_CONDUCTOR = False + +_RAW_ACTIVITY_FLOOR = 1e-3 +_ENERGY_FLAT_EPS = 1e-9 +# Raw presence below this reads as absent, so a dead stem never ranks as a lead. +_SLOT_SILENCE_FLOOR = 0.02 +# Percentile of a slot's own phrase presence that counts as "fully present". +_SLOT_SCALE_PERCENTILE = 80.0 + + +def _lerp(a: float, b: float, t: float) -> float: + return a + (b - a) * float(t) + + +def _rank_fractions(values: np.ndarray) -> np.ndarray: + """Within-series rank in ``0..1``; a flat or single-element series is neutral.""" + n = len(values) + if n == 0: + return values + if n == 1 or float(np.nanmax(values) - np.nanmin(values)) <= _ENERGY_FLAT_EPS: + return np.full(n, CONDUCTOR_ACTIVITY_MIDPOINT, dtype=np.float64) + ranks = np.argsort(np.argsort(values)).astype(np.float64) + return ranks / (n - 1) + + +def support_floor_for(density_bias: int) -> float: + """Support-slot floor as a fraction of the lead level for ``density_bias``.""" + return CONDUCTOR_SUPPORT_FLOOR_BY_BIAS.get( + density_bias, CONDUCTOR_SUPPORT_FLOOR_BY_BIAS[0] + ) + + +@dataclass(frozen=True) +class PhraseWeights: + budget_gain: float + slot_activity: dict[str, float] + lead_ceiling: float + near_silent: bool + + +def timeline_preset_conductor_display(conductor: bool) -> str: + return "on" if conductor else "off" + + +def cycle_timeline_preset_conductor(value: bool, *, forward: bool) -> bool: + options = (False, True) + try: + index = options.index(bool(value)) + except ValueError: + index = options.index(DEFAULT_TIMELINE_PRESET_CONDUCTOR) + delta = 1 if forward else -1 + return options[(index + delta) % len(options)] + + +class StemConductor: + def __init__( + self, + *, + signals: Signals, + slot_stems: Mapping[str, StemSource], + phrase_bounds: Sequence[tuple[float, float]], + phrase_weights: Sequence[PhraseWeights], + slot_scales: Mapping[str, float], + support_floor: float, + ) -> None: + self._signals = signals + self._slot_stems = dict(slot_stems) + self._phrase_bounds = list(phrase_bounds) + self._phrase_weights = list(phrase_weights) + self._slot_scales = dict(slot_scales) + self._support_floor = float(support_floor) + + @classmethod + def build( + cls, + signals: Signals | None, + slot_stems: Mapping[str, StemSource] | None, + phrases: Sequence[tuple[float, float]], + density_bias: int = 0, + ) -> StemConductor | None: + if signals is None or not slot_stems or not phrases: + return None + try: + signals.array("full_mix", "rms") + signals.array("full_mix", "onset_strength") + for stem in slot_stems.values(): + signals.array(stem, _STEM_PRESENCE[stem]) + except KeyError: + return None + + energies: list[float] = [] + for t0, t1 in phrases: + rms = signals.window_mean("full_mix", "rms", t0, t1) + onset = signals.window_mean("full_mix", "onset_strength", t0, t1) + energies.append(0.65 * rms + 0.35 * onset) + + energy_arr = np.asarray(energies, dtype=np.float64) + if not np.isfinite(energy_arr).any(): + return None + peak = float(np.nanmax(energy_arr)) + if peak <= 0.0: + return None + if ( + len(energy_arr) >= 2 + and float(np.nanmax(energy_arr) - np.nanmin(energy_arr)) <= _ENERGY_FLAT_EPS + ): + return None + + rank_frac = _rank_fractions(energy_arr) + + # Standardise each slot within the song: raw envelopes are not comparable + # across stems (drum onsets are spiky and low-mean, rms is dense and + # high-mean), so activity means "busier than usual for this stem". + slot_activity_series: dict[str, np.ndarray] = {} + slot_scales: dict[str, float] = {} + for slot, stem in slot_stems.items(): + key = _STEM_PRESENCE[stem] + raw = np.asarray( + [signals.window_mean(stem, key, t0, t1) for t0, t1 in phrases], + dtype=np.float64, + ) + slot_activity_series[slot] = np.where( + raw < _SLOT_SILENCE_FLOOR, 0.0, _rank_fractions(raw) + ) + slot_scales[slot] = max( + float(np.nanpercentile(raw, _SLOT_SCALE_PERCENTILE)), + _RAW_ACTIVITY_FLOOR, + ) + + phrase_weights: list[PhraseWeights] = [] + for i in range(len(phrases)): + rank = float(rank_frac[i]) + energy = float(energy_arr[i]) + loudness = clamp_level(energy / peak) ** CONDUCTOR_CEILING_EXPONENT + phrase_weights.append( + PhraseWeights( + budget_gain=_lerp(CONDUCTOR_GAIN_MIN, CONDUCTOR_GAIN_MAX, rank), + slot_activity={ + slot: float(series[i]) + for slot, series in slot_activity_series.items() + }, + lead_ceiling=_lerp(CONDUCTOR_CEILING_MIN, 1.0, loudness), + near_silent=energy < CONDUCTOR_SILENCE_FLOOR, + ) + ) + + return cls( + signals=signals, + slot_stems=slot_stems, + phrase_bounds=phrases, + phrase_weights=phrase_weights, + slot_scales=slot_scales, + support_floor=support_floor_for(density_bias), + ) + + def phrase_at(self, t: float) -> PhraseWeights: + if not self._phrase_weights: + raise ValueError("StemConductor has no phrases") + for i, (t0, t1) in enumerate(self._phrase_bounds): + if t0 <= t < t1: + return self._phrase_weights[i] + if t < self._phrase_bounds[0][0]: + return self._phrase_weights[0] + return self._phrase_weights[-1] + + def rotation_for( + self, + singles_slots: Sequence[str], + weights: PhraseWeights, + layer_airtime: Mapping[str, float], + ) -> int: + """Index of the slot that should lead: busiest stem, minus airtime hogging.""" + if not singles_slots: + return 0 + total = sum(max(0.0, float(value)) for value in layer_airtime.values()) + even_share = 1.0 / len(singles_slots) + best_i = 0 + best_score = float("-inf") + for i, slot in enumerate(singles_slots): + activity = weights.slot_activity.get(slot, 0.0) + if total > 0.0: + share = max(0.0, float(layer_airtime.get(slot, 0.0))) / total + else: + share = even_share + score = activity - AIRTIME_PENALTY * (share - even_share) + if score > best_score: + best_score = score + best_i = i + return best_i + + def chord_score(self, active: frozenset[str], weights: PhraseWeights) -> float: + """Mean slot activity centred on neutral, so chord size is not penalised.""" + if not active: + return 0.0 + total = sum( + weights.slot_activity.get(slot, 0.0) - CONDUCTOR_ACTIVITY_MIDPOINT + for slot in active + ) + return total / len(active) + + def level_states( + self, + states: Sequence[tuple[float, frozenset[str]]], + duration_sec: float, + ) -> list[tuple[float, dict[str, float]]]: + if not states: + return [] + out: list[tuple[float, dict[str, float]]] = [] + for i, (t, active) in enumerate(states): + t1 = states[i + 1][0] if i + 1 < len(states) else float(duration_sec) + weights = self.phrase_at(t) + out.append((float(t), self._levels_for_state(t, t1, active, weights))) + return out + + def _levels_for_state( + self, + t0: float, + t1: float, + active: frozenset[str], + weights: PhraseWeights, + ) -> dict[str, float]: + if not active: + return {} + + if weights.near_silent: + lead = max( + sorted(active), key=lambda s: weights.slot_activity.get(s, 0.0) + ) + return { + slot: (CONDUCTOR_LEVEL_FLOOR if slot == lead else 0.0) + for slot in active + } + + # Presence is each slot's window mean against its own "fully present" + # scale, so the loudest slot in the state is comparable across stems. + presence: dict[str, float] = {} + for slot in active: + stem = self._slot_stems[slot] + key = _STEM_PRESENCE[stem] + mean = self._signals.window_mean(stem, key, t0, t1) + presence[slot] = clamp_level(mean / self._slot_scales[slot]) + + peak = max(presence.values()) + levels: dict[str, float] = {} + for slot, value in presence.items(): + relative = value / peak if peak > _RAW_ACTIVITY_FLOOR else 1.0 + shaped = _lerp(self._support_floor, 1.0, relative) + level = quantize_level(clamp_level(shaped * weights.lead_ceiling)) + if level < CONDUCTOR_LEVEL_FLOOR: + level = CONDUCTOR_LEVEL_FLOOR + levels[slot] = level + return levels diff --git a/cleave/timeline_presets/crescendo.py b/cleave/timeline_presets/crescendo.py index 431b87b..3aa7541 100644 --- a/cleave/timeline_presets/crescendo.py +++ b/cleave/timeline_presets/crescendo.py @@ -7,7 +7,15 @@ from dataclasses import dataclass from typing import Literal -from cleave.timeline import LEVEL_EPS, TimelineLane, lane_level_at, levels_equal +from cleave.timeline import ( + LEVEL_EPS, + LEVEL_QUANTUM, + TimelineLane, + clamp_level, + lane_level_at, + levels_equal, + quantize_level, +) from cleave.timeline_presets.chords import MAX_CONCURRENT_LAYERS from cleave.timeline_presets.emit import cues_from_states @@ -15,6 +23,10 @@ CRESCENDO_MIN_MARKERS = 3 _FALLBACK_START_FRACTION = 0.20 +# An entrant appears at the dimmest visible level and climbs to full by t_full. +CRESCENDO_ENTRY_LEVEL = LEVEL_QUANTUM +# Enough steps for a lane to climb through every quantised level on the way up. +CRESCENDO_RAMP_STEPS = int(round(1.0 / LEVEL_QUANTUM)) TIMELINE_PRESET_CRESCENDO_OPTIONS: tuple[CrescendoTarget | None, ...] = ( None, @@ -29,6 +41,10 @@ } +def _lerp(a: float, b: float, t: float) -> float: + return a + (b - a) * float(t) + + def timeline_preset_crescendo_display(target: CrescendoTarget | None) -> str: return _CRESCENDO_DISPLAY.get(target, _CRESCENDO_DISPLAY[None]) @@ -188,15 +204,26 @@ def _crescendo_states( rng.shuffle(order) max_n = min(len(order), MAX_CONCURRENT_LAYERS) stack = order[:max_n] - times = _spread_times(window.t_start, window.t_full, max_n, bar_times) + # One more step than entrants so the last layer also fades in rather than + # appearing at full level on the final step. + step_count = max(max_n + 1, CRESCENDO_RAMP_STEPS) + entry_steps = [int(j * step_count / max_n) for j in range(max_n)] + times = _spread_times(window.t_start, window.t_full, step_count, bar_times) states: list[tuple[float, dict[str, float]]] = [] last_i = len(times) - 1 for i, t in enumerate(times): if i == last_i: levels = {slot: 1.0 for slot in stack} else: - levels = {slot: 1.0 for slot in stack[:i]} - levels[stack[i]] = 0.5 + levels = {} + for j, slot in enumerate(stack): + entry = entry_steps[j] + if entry > i: + continue + progress = (i - entry) / max(1, last_i - entry) + levels[slot] = quantize_level( + clamp_level(_lerp(CRESCENDO_ENTRY_LEVEL, 1.0, progress)) + ) states.append((float(t), levels)) # Drop to a single layer at the selected marker; hold through song end. solo = {stack[0]: 1.0} @@ -220,7 +247,7 @@ def _spread_times( count: int, bar_times: Sequence[float], ) -> list[float]: - """``count`` times from ``t_start`` to ``t_full`` inclusive, preferring bars.""" + """``count`` times evenly from ``t_start`` to ``t_full`` inclusive, snapped to bars.""" if count <= 1: return [float(t_start)] if t_full <= t_start + 1e-9: @@ -229,26 +256,40 @@ def _spread_times( if count == 2: return [float(t_start), float(t_full)] - interior_need = count - 2 bars = [float(t) for t in bar_times if t_start + 1e-9 < float(t) < t_full - 1e-9] - if len(bars) >= interior_need: - interior = [ - bars[_even_index(i, interior_need, len(bars))] - for i in range(interior_need) - ] - else: - span = t_full - t_start - interior = [ - t_start + span * (i + 1) / (count - 1) - for i in range(interior_need) - ] - return [float(t_start), *[float(t) for t in interior], float(t_full)] - - -def _even_index(i: int, need: int, n: int) -> int: - if need <= 1: - return n // 2 - return int(round(i * (n - 1) / (need - 1))) + span = t_full - t_start + ideal_step = span / (count - 1) + times = [float(t_start)] + for i in range(1, count - 1): + target = t_start + span * i / (count - 1) + snapped = _nearest_bar_after( + bars, target, times[-1], tolerance=0.5 * ideal_step + ) + times.append(snapped if snapped is not None else max(target, times[-1])) + times.append(float(t_full)) + return times + + +def _nearest_bar_after( + bars: Sequence[float], + target: float, + previous: float, + *, + tolerance: float, +) -> float | None: + """Bar nearest ``target`` within ``tolerance`` and later than ``previous``. + + A sparse grid should not distort the ramp, so a distant bar is refused and + the caller falls back to the evenly spaced time. + """ + candidates = [ + t + for t in bars + if t > previous + 1e-9 and abs(t - target) <= tolerance + 1e-9 + ] + if not candidates: + return None + return min(candidates, key=lambda t: (abs(t - target), t)) def _merge_states( diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index 412fd83..1cbb634 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -14,6 +14,7 @@ from cleave.config_schema import PRESET_SWITCHING_MODES, PRESET_SWITCHING_ROTATION_SETS from cleave.blend_modes import BLEND_MODES, BlendMode from cleave.extract import STEM_SOURCES +from cleave.signals import Signals from cleave.song_markers import format_marker_time, place_marker from cleave.preset_curation import PresetCurationIndex from cleave.preset_playlist import is_top_level_browse_dir @@ -109,6 +110,7 @@ def __init__( post_process: GlPostProcess | None = None, beat_times: Sequence[float] = (), bar_times: Sequence[float] = (), + signals: Signals | None = None, ) -> None: self.session = session self.cfg = cfg @@ -162,6 +164,7 @@ def __init__( self._modal_host, beat_times, bar_times, + signals=signals, on_notification=self.show_notification, ) layers_by_slot = ( diff --git a/cleave/viz/row_fields.py b/cleave/viz/row_fields.py index 5692e30..77f0c18 100644 --- a/cleave/viz/row_fields.py +++ b/cleave/viz/row_fields.py @@ -30,6 +30,10 @@ cycle_timeline_preset_kind, timeline_preset_kind_display, ) +from cleave.timeline_presets.conductor import ( + cycle_timeline_preset_conductor, + timeline_preset_conductor_display, +) from cleave.timeline_presets.crescendo import ( cycle_timeline_preset_crescendo, timeline_preset_crescendo_display, @@ -247,6 +251,28 @@ def _apply_timeline_preset_density( ) +def _format_timeline_preset_conductor( + state: TuningViewState, _desc: RowDescriptor +) -> str: + return timeline_preset_conductor_display( + state.render_timeline.timeline_preset_conductor + ) + + +def _apply_timeline_preset_conductor( + controls: TuningControls, + _desc: RowDescriptor, + forward: bool, + _ctrl: bool, + _shift: bool, +) -> None: + tl = controls.session.timeline + tl.timeline_preset_conductor = cycle_timeline_preset_conductor( + tl.timeline_preset_conductor, + forward=forward, + ) + + def _format_timeline_song_marker_fades_enabled( state: TuningViewState, _desc: RowDescriptor ) -> str: @@ -1669,6 +1695,12 @@ def _apply_transport( format_value=_format_timeline_preset_density, apply_horizontal=_apply_timeline_preset_density, ), + RowKind.TIMELINE_PRESET_CONDUCTOR: RowFieldDef( + panel_label="conductor", + present_style=RowPresentStyle.LABELED_VALUE, + format_value=_format_timeline_preset_conductor, + apply_horizontal=_apply_timeline_preset_conductor, + ), RowKind.TIMELINE_PRESETS: RowFieldDef( panel_label="apply timeline preset", present_style=RowPresentStyle.FULL_LINE, diff --git a/cleave/viz/row_sections.py b/cleave/viz/row_sections.py index 7071aea..9debf17 100644 --- a/cleave/viz/row_sections.py +++ b/cleave/viz/row_sections.py @@ -701,6 +701,7 @@ def _timeline_standard_cue_fades_enabled( SectionNode(leaf_kind=RowKind.TIMELINE_PRESET_CHARACTER), SectionNode(leaf_kind=RowKind.TIMELINE_PRESET_CRESCENDO), SectionNode(leaf_kind=RowKind.TIMELINE_PRESET_DENSITY), + SectionNode(leaf_kind=RowKind.TIMELINE_PRESET_CONDUCTOR), SectionNode(leaf_kind=RowKind.TIMELINE_PRESETS), ), ) @@ -847,6 +848,7 @@ def kinds_in_expand_section(section: ExpandSectionDef) -> frozenset[RowKind]: RowKind.TIMELINE_PRESET_CHARACTER, RowKind.TIMELINE_PRESET_CRESCENDO, RowKind.TIMELINE_PRESET_DENSITY, + RowKind.TIMELINE_PRESET_CONDUCTOR, RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_RESET, RowKind.TIMELINE_BEAT_BAR_GRID_HEADER, diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index b8f54d3..72c6593 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -87,6 +87,7 @@ class RowKind(Enum): TIMELINE_PRESET_CHARACTER = auto() TIMELINE_PRESET_CRESCENDO = auto() TIMELINE_PRESET_DENSITY = auto() + TIMELINE_PRESET_CONDUCTOR = auto() TIMELINE_PRESETS = auto() TIMELINE_RESET = auto() TIMELINE_BEAT_BAR_GRID_HEADER = auto() @@ -754,8 +755,8 @@ class RowBehavior: is_sub_header=True, help_title="Timeline preset", help_description=( - "Stage character, crescendo, and density, then apply a randomly", - "generated timeline preset. This overwrites the current timeline.", + "Stage character, crescendo, density, and conductor, then apply a", + "randomly generated timeline preset. This overwrites the current timeline.", ), ), RowKind.TIMELINE_PRESET_CHARACTER: RowBehavior( @@ -792,6 +793,17 @@ class RowBehavior: "Normal matches the default stack-cost ramp for the layer count.", ), ), + RowKind.TIMELINE_PRESET_CONDUCTOR: RowBehavior( + RowAffordance.VALUE_STEP, + navigable=True, + blocked_by_section_lock=True, + help_title="Conductor", + help_entries=(("Left/Right", "toggle conductor on/off"),), + help_description=( + "When on, stem energy shapes motif casting and cue levels.", + "Requires project signals; otherwise apply skips the conductor.", + ), + ), RowKind.TIMELINE_PRESETS: RowBehavior( RowAffordance.ACTION, navigable=True, @@ -799,7 +811,7 @@ class RowBehavior: help_title="Apply timeline preset", help_entries=(("Enter", "apply timeline preset"),), help_description=( - "Apply the staged character, crescendo, and density.", + "Apply the staged character, crescendo, density, and conductor.", "This overwrites the current timeline.", ), ), diff --git a/cleave/viz/session.py b/cleave/viz/session.py index ffaf89f..2db277f 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -48,6 +48,7 @@ from cleave.timeline import SlotCue, TimelineLane, copy_lane, empty_lane from cleave.blend_modes import BlendMode from cleave.timeline_presets.characters import DEFAULT_TIMELINE_PRESET_KIND +from cleave.timeline_presets.conductor import DEFAULT_TIMELINE_PRESET_CONDUCTOR from cleave.timeline_presets.crescendo import CrescendoTarget from cleave.timeline_presets.density import ( DEFAULT_TIMELINE_PRESET_DENSITY, @@ -201,6 +202,7 @@ class TimelineRuntime: timeline_preset_kind: str = DEFAULT_TIMELINE_PRESET_KIND timeline_preset_crescendo: CrescendoTarget | None = None timeline_preset_density: TimelinePresetDensity = DEFAULT_TIMELINE_PRESET_DENSITY + timeline_preset_conductor: bool = DEFAULT_TIMELINE_PRESET_CONDUCTOR song_marker_fades: TimelineFadeGroupRuntime = field( default_factory=default_timeline_fade_group_runtime ) @@ -387,6 +389,9 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime: preset_density = ( DEFAULT_TIMELINE_PRESET_DENSITY if preset is None else preset.density ) + preset_conductor = ( + DEFAULT_TIMELINE_PRESET_CONDUCTOR if preset is None else preset.conductor + ) lanes: dict[str, TimelineLane] = {} for slot in cfg.layer_z_order: if slot in source_lanes: @@ -402,6 +407,7 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime: timeline_preset_kind=preset_kind, timeline_preset_crescendo=preset_crescendo, timeline_preset_density=preset_density, + timeline_preset_conductor=preset_conductor, ) return TimelineRuntime( enabled=enabled, @@ -411,6 +417,7 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime: timeline_preset_kind=preset_kind, timeline_preset_crescendo=preset_crescendo, timeline_preset_density=preset_density, + timeline_preset_conductor=preset_conductor, song_marker_fades=_fade_group_runtime_from_cfg(fades.song_markers), standard_cue_fades=_fade_group_runtime_from_cfg(fades.standard), ) diff --git a/cleave/viz/timeline_preset_controls.py b/cleave/viz/timeline_preset_controls.py index 172bb1e..55c332a 100644 --- a/cleave/viz/timeline_preset_controls.py +++ b/cleave/viz/timeline_preset_controls.py @@ -5,6 +5,7 @@ import random from collections.abc import Callable, Sequence +from cleave.signals import Signals from cleave.timeline import TimelineLane, copy_lane, empty_lane, shift_bars_by_beats from cleave.timeline_presets import ( build_arc_cues, @@ -13,6 +14,7 @@ build_pulse_cues, ) from cleave.timeline_presets.characters import timeline_preset_kind_display +from cleave.timeline_presets.conductor import timeline_preset_conductor_display from cleave.timeline_presets.crescendo import ( CRESCENDO_MIN_MARKERS, CrescendoTarget, @@ -48,12 +50,14 @@ def __init__( beat_times: Sequence[float] = (), bar_times: Sequence[float] = (), *, + signals: Signals | None = None, 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._signals = signals self._on_notification = on_notification def prompt(self, duration_sec: float) -> None: @@ -73,6 +77,7 @@ def _apply_prompt_message(self) -> str: f"character: {timeline_preset_kind_display(tl.timeline_preset_kind)}", f"crescendo: {timeline_preset_crescendo_display(tl.timeline_preset_crescendo)}", f"density: {timeline_preset_density_display(tl.timeline_preset_density)}", + f"conductor: {timeline_preset_conductor_display(tl.timeline_preset_conductor)}", ) return "\n".join(("Apply timeline preset?", *choice_lines)) @@ -128,13 +133,25 @@ def _apply( slots = list(self.session.layer_z_order) markers = list(self.session.song_markers.times) rng = random.Random() + builder_kwargs: dict = { + "bar_times": grid, + "song_marker_times": markers, + "density_bias": density_bias_for(tl.timeline_preset_density), + } + conductor_skipped = False + if tl.timeline_preset_conductor: + if self._signals is None: + conductor_skipped = True + else: + builder_kwargs["signals"] = self._signals + builder_kwargs["slot_stems"] = { + slot: self.session.layers[slot].stem for slot in slots + } built = builder( slots, duration_sec, rng, - bar_times=grid, - song_marker_times=markers, - density_bias=density_bias_for(tl.timeline_preset_density), + **builder_kwargs, ) if crescendo is not None: built = apply_crescendo( @@ -149,7 +166,10 @@ def _apply( message = f"{message} (crescendo)" for slot in slots: tl.lanes[slot] = copy_lane(built.get(slot, empty_lane())) - self._notify(message) + if conductor_skipped: + self._notify("No signals; conductor skipped") + else: + self._notify(message) def _reset(self, *, all_on: bool) -> None: self._clear_timeline_state() diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index b2f15f1..b8d885c 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -40,6 +40,7 @@ from cleave.extract import StemSource from cleave.preset_curation import PresetCurationIndex from cleave.preset_playlist import PresetPlaylist, preset_filename_display +from cleave.timeline_presets.conductor import DEFAULT_TIMELINE_PRESET_CONDUCTOR from cleave.timeline_presets.crescendo import CrescendoTarget from cleave.timeline_presets.density import ( DEFAULT_TIMELINE_PRESET_DENSITY, @@ -176,6 +177,7 @@ class RenderTimelineBlock: timeline_preset_kind: str = "breathing" timeline_preset_crescendo: CrescendoTarget | None = None timeline_preset_density: TimelinePresetDensity = DEFAULT_TIMELINE_PRESET_DENSITY + timeline_preset_conductor: bool = DEFAULT_TIMELINE_PRESET_CONDUCTOR song_marker_fades: TimelineFadeGroupBlock = field( default_factory=TimelineFadeGroupBlock ) @@ -537,6 +539,7 @@ def _build_structure( timeline_preset_kind=tl.timeline_preset_kind, timeline_preset_crescendo=tl.timeline_preset_crescendo, timeline_preset_density=tl.timeline_preset_density, + timeline_preset_conductor=tl.timeline_preset_conductor, song_marker_fades=TimelineFadeGroupBlock( enabled=tl.song_marker_fades.enabled, fade_in=tl.song_marker_fades.fade_in, @@ -718,6 +721,7 @@ def build( timeline_preset_kind=tl.timeline_preset_kind, timeline_preset_crescendo=tl.timeline_preset_crescendo, timeline_preset_density=tl.timeline_preset_density, + timeline_preset_conductor=tl.timeline_preset_conductor, song_marker_fades=TimelineFadeGroupBlock( enabled=tl.song_marker_fades.enabled, fade_in=tl.song_marker_fades.fade_in, diff --git a/cleave/viz/wiring.py b/cleave/viz/wiring.py index bdb729b..1a74f65 100644 --- a/cleave/viz/wiring.py +++ b/cleave/viz/wiring.py @@ -467,6 +467,7 @@ def on_chroma_boost_apply_mode_change(old_mode: str, new_mode: str) -> None: "post_process": post_process, "beat_times": beat_times, "bar_times": bar_times, + "signals": signals, } if modal_host is not None: kwargs["modal_host"] = modal_host diff --git a/docs/improved-timeline-presets.md b/docs/improved-timeline-presets.md index cfb96c2..062793a 100644 --- a/docs/improved-timeline-presets.md +++ b/docs/improved-timeline-presets.md @@ -17,26 +17,25 @@ What works today: - Each layer is its own projectM instance fed stem PCM; black-key (and other) blends stack them in [cleave/gl_compositor.py](../cleave/gl_compositor.py). - Cue levels drive continuous opacity: `lane_level_breakpoints` / `lane_level_envelope` in [cleave/timeline.py](../cleave/timeline.py) feed `layer.timeline_level` via `apply_layer_visibility` in [cleave/viz/layer_visibility.py](../cleave/viz/layer_visibility.py). The strip draws the same breakpoints as variable-height bars. - `preset_switching: timeline` advances a seek-stable rotation on each rise from zero; the milk file is not chosen per cue. -- Per-stem envelopes in `signals.json` drive live Cleave effects ([cleave/effects/](../cleave/effects/)), not timeline generation. +- Per-stem envelopes in `signals.json` (version 4) drive live Cleave effects ([cleave/effects/](../cleave/effects/)) and, when the staged conductor row is on, generative timeline Apply via [cleave/timeline_presets/conductor.py](../cleave/timeline_presets/conductor.py). Gaps the ideas below target: -1. The arranger still emits level-only cues; it does not yet assign blend or role automatically. -2. The arranger is musically phrased but not stem-content-aware: it never looks at what the drums or vocals are doing. -3. Song form still needs manual markers for the best results. -4. Busy collisions often come from milk personality; role casting helps when pools are curated, but automatic pool fill needs Idea 3 fingerprints. -5. Nothing recurs. Chorus 2 gets a fresh roll of the dice, so the output reads as plausible-but-random rather than composed. +1. The arranger still does not assign blend or role automatically (levels and stem gating are shipped; automatic mix assignment remains deferred). +2. Song form still needs manual markers for the best results. +3. Busy collisions often come from milk personality; role casting helps when pools are curated, but automatic pool fill needs Idea 3 fingerprints. +4. Nothing recurs. Chorus 2 gets a fresh roll of the dice, so the output reads as plausible-but-random rather than composed. --- ## Idea 1: Arrangement as a mix, not a gate (shipped: levels, blend, role) -**Shipped:** cues are level keyframes with optional blend and role (`SlotCue(t, level, blend?, role?)`), fade groups become constant-slope ramps on a piecewise-linear envelope, the strip draws variable-height bars, and crescendo ramps entrants in at `0.5` before a full stack. Layer opacity stays the static fader; the lane multiplies into `fbo.opacity` as before. Per-cue blend writes `LayerFbo.blend_mode` each frame ([cleave/blend_modes.py](../cleave/blend_modes.py)); per-cue role casts from `preset_root/roles//` on rises from zero. Blend and role are authored on on / visible cues (the next period), not on disable cues; off cues are stripped of both in `canonicalize`. +**Shipped:** cues are level keyframes with optional blend and role (`SlotCue(t, level, blend?, role?)`), fade groups become constant-slope ramps on a piecewise-linear envelope, the strip draws variable-height bars, and crescendo ramps entrants in from `LEVEL_QUANTUM` through every quantised step to a full stack. Layer opacity stays the static fader; the lane multiplies into `fbo.opacity` as before. Per-cue blend writes `LayerFbo.blend_mode` each frame ([cleave/blend_modes.py](../cleave/blend_modes.py)); per-cue role casts from `preset_root/roles//` on rises from zero. Blend and role are authored on on / visible cues (the next period), not on disable cues; off cues are stripped of both in `canonicalize`. **Deferred** (still part of the mix vision, not implemented): - Manual level authoring in the strip (record toggles still write `0.0` / `1.0`). -- Automatic blend/role assignment from the generative arranger (Idea 2 / Idea 3). +- Automatic blend/role assignment from the generative arranger (Idea 3). ### Intent @@ -51,13 +50,13 @@ Gaps the ideas below target: 2. Envelope: `lane_level_breakpoints` / `lane_level_envelope` replace edge-only fade alpha; rise completes at cue time, fall starts at cue time; slopes scale with level delta. 3. Runtime: `timeline_level` / `timeline_level_multiplier`; levels apply even when both fade groups are disabled (piecewise-constant envelope). 4. Preset-switch trigger: rise from zero only; `cue.t - fade_in * cue.level`. -5. Generator: `cues_from_states` emits level mappings; crescendo uses `0.5` entrants. +5. Generator: `cues_from_states` emits level mappings; crescendo climbs each entrant from `LEVEL_QUANTUM` to full. 6. Strip: polygon fill from breakpoints; committed eye alpha follows level. 7. Blend: held like level via `lane_blend_at`; applied per frame in `apply_layer_visibility` with layer static fallback. 8. Role: event property on on-transitions; seek-stable per-role pools in [cleave/viz/preset_switching.py](../cleave/viz/preset_switching.py); empty pool falls back to the main rotation. 9. Strip authoring: `,` / `.` select on cues (`level > 0`, including mid-on changes; offs skipped); `b` / `c` cycle blend and cast on those only; selected tick highlight, role glyphs on on cues, and badge readout. -Overlaps [roadmap.md](roadmap.md) richer cue types; automatic assignment from stem content and fingerprints remains Idea 2 / Idea 3. +Overlaps [roadmap.md](roadmap.md) richer cue types; automatic blend/role assignment from stem content and fingerprints remains Idea 3. ### User effort @@ -65,9 +64,15 @@ None for 0/1 lanes. Partial levels come from generative Apply (crescendo). Blend --- -## Idea 2: Stem conductor +## Idea 2: Stem conductor (shipped: opt-in audio-aware arrange) -Treat each layer as a player that may only enter when its stem has something to say. Visibility becomes a consequence of audio, not only of motif vocabulary. +**Shipped:** an opt-in staged `conductor` row under timeline preset. When on and `signals.json` is present (version 4), Apply builds a [StemConductor](../cleave/timeline_presets/conductor.py) from full-mix energy ranks and per-slot stem presence, scales the character budget, biases solo rotation and chord picks toward active stems, and emits continuous cue levels quantised to `LEVEL_QUANTUM` (active slots never land between 0 and 0.25; near-silent phrases keep one slot at 0.25). Missing signals notify and fall through to plain arrangement. `other` now carries `rms` alongside `spectral_centroid`. + +**Deferred** (still part of the conductor vision, not implemented): + +- Section-role bias from auto form (Idea 4). +- Closed-loop visual limiter (companion below). +- Automatic blend or role assignment from stem content (Idea 1 deferred / Idea 3). ### Intent @@ -75,34 +80,54 @@ Treat each layer as a player that may only enter when its stem has something to - Cap how much of the stack is hot at once so the composite stays readable. - Keep Breathing / Dialogue / Arc / Pulse as feel knobs (switch rate, climax shape) while the conductor supplies who speaks and how loudly. -### Design sketch +### What landed + +1. Signals: `other.rms` in extract/analyse; `SIGNALS_VERSION = 4`; `Signals.window_mean` for phrase and state windows. +2. Conductor module: phrase energy ranks, per-slot activity, rotation hint, chord score, `level_states`; staging helpers mirror density. +3. Arranger hooks: optional `signals` / `slot_stems` on all four builders into `compose_timeline`; UI toggle expressed by whether Apply passes those kwargs. +4. Panel: `timeline.preset.conductor` persisted; confirm modal lists `conductor: on|off`. + +### Tuning notes + +Raw stem envelopes are not comparable across stems: drum onset strength is +spiky and low-mean while rms is dense and high-mean, so a raw comparison hands +the same slot the lead in nearly every phrase. The conductor therefore +standardises each slot **within the song**: `slot_activity` is a rank fraction +across phrases, so activity reads as "busier than usual for this stem" and every +slot averages the same. A raw presence below `_SLOT_SILENCE_FLOOR` gates to +zero so a dead stem never ranks as a lead, and a flat envelope resolves to the +neutral midpoint instead of a spurious ranking. + +The rest of the curve exists to keep the conductor a redistribution rather than +a global attenuator: + +| Constant | Role | Turn it when | +| --- | --- | --- | +| `CONDUCTOR_GAIN_MIN` / `CONDUCTOR_GAIN_MAX` | Budget multiplier by phrase energy rank; the pair sums to 2.0 so the mean gain is exactly 1.0 | Loud phrases should unlock denser chords (raise the max, lower the min to match) | +| `CONDUCTOR_CEILING_MIN` / `CONDUCTOR_CEILING_EXPONENT` | Lead level from phrase energy relative to the song peak, not its rank, so only genuinely quiet passages dim; exponent steeps the curve so compressed masters still span quantised steps | Quiet passages should read darker (lower the min) or mid-song levels stay pinned at 1.0 (raise the exponent) | +| `CONDUCTOR_SUPPORT_FLOOR_BY_BIAS` | Lowest fraction of the lead level a supporting slot may take, per density bias; kept below ~0.70 so dense stacks still duck into 0.5/0.75 | Overlap still reads as one layer plus ghosts (raise the table carefully; too high flattens to binary) | +| `AIRTIME_PENALTY` | Penalty on a slot's share of accumulated airtime above an even share | One slot still holds too much screen time | +| `CONDUCTOR_ACTIVITY_MIDPOINT` | Neutral point subtracted in `chord_score`, so chord size is scored on quality only and larger chords are not penalised | Never, it is fixed by the rank standardisation | + +Crescendo shares the level model: entrants appear at `LEVEL_QUANTUM` and climb +through every quantised step to a full stack at `t_full`, spread over +`max(entrants + 1, 1 / LEVEL_QUANTUM)` evenly spaced times snapped to nearby +bars. Ramps are only visible when a timeline fade group is enabled; both groups +default to disabled, which turns every level change into a hard step. -1. From full-mix energy and onset novelty, build a **target weight curve** over time (sparse verse near one layer of weight, chorus two to three, drop to zero then punch back in). -2. **Gate and weight each slot** by its assigned stem using the `signals.json` envelopes that already exist at 100 Hz: +### Stem presence keys | Stem | Fields | Reads as | | --- | --- | --- | | drums | `onset_strength` | onset density, fills | | bass | `rms`, `sub_bass`, `mid_bass` | foundation presence | | vocals | `rms`, `pitch_hz` | lead activity, phrase ends | -| other | `spectral_centroid` | color and brightness | +| other | `rms`, `spectral_centroid` | presence plus color/brightness | | full_mix | `onset_strength`, `rms` | global energy, section contrast | -3. Apply **anti-busy rules** while filling the budget: prefer one bright accent over several; solo when one stem dominates; duck competing hot layers instead of cutting them (needs Idea 1 levels, which are available). -4. Emit ordinary lane cues. Phrase grid and song-marker walls still bound switch times. - -### Fits existing code - -- Arranger entry: [cleave/timeline_presets/arrange.py](../cleave/timeline_presets/arrange.py) `compose_timeline`, which already computes a per-phrase `budget` from the character envelope. The conductor replaces that scalar with an audio-derived curve and adds per-slot eligibility to motif scoring. -- Signal load: [cleave/signals.py](../cleave/signals.py); extract path: [cleave/extract.py](../cleave/extract.py). No new analyse output required. - -### Libraries - -Current stack; librosa envelopes are already written at analyse time. Optional later: [madmom](https://github.com/CPJKU/madmom) for cleaner onsets. - ### User effort -Run `separate` (already required for beats), then Apply. No new markers. +Run `separate` (required for beats and v4 envelopes; re-run on existing projects after the schema bump), stage conductor on, then Apply. No new markers. --- @@ -218,14 +243,14 @@ Breathing / Dialogue / Arc / Pulse remain character profiles that bias switch ra | --- | --- | --- | --- | --- | | 1 | Rich cues: levels (Idea 1, shipped) | Enabler; everything else wants levels, not booleans | Better output immediately, even with today's arranger | Done | | 1b | Rich cues: blend and role (Idea 1, shipped) | Completes the mix vision; casting uses the cue role field | Medium | Done | -| 2 | Stem conductor (Idea 2) | Uses data already in `signals.json`; first thing that makes lanes song-tied | High | Medium (arranger plus signals) | +| 2 | Stem conductor (Idea 2, shipped) | Uses data already in `signals.json`; first thing that makes lanes song-tied | High | Done | | 3 | Closed-loop limiter (companion) | Cheap once levels exist; buys headroom before casting lands | Medium | Low | | 4 | Reactivity fingerprints and casting (Idea 3) | Probe harness exists; fixes busy-on-busy at the milk level | High | High (probe set, metrics, pools, casting) | | 5 | Reprise and auto form (Idea 4) | Highest ceiling, most analysis risk; benefits from 1 to 4 being in place | High | High (analyse dependency, marker suggestions, arranger restructure) | Sequencing notes: -- Levels (1) and stem conductor (2) are independently shippable and together cover most of the "feels tied to the song" goal. +- Levels (1) and stem conductor (2) are shipped and together cover most of the "feels tied to the song" goal. - Step 4 can ship in halves: fingerprints and a scan report first, casting second. - Step 5 can ship in halves: suggested markers first (useful alone), cluster reprise second. diff --git a/tests/cleave/test_analyse.py b/tests/cleave/test_analyse.py index b6de86a..5da9782 100644 --- a/tests/cleave/test_analyse.py +++ b/tests/cleave/test_analyse.py @@ -30,6 +30,11 @@ def _stub_vocals() -> dict[str, tuple[np.ndarray, np.ndarray]]: return {"rms": (values, times), "pitch_hz": (values, times)} +def _stub_other() -> dict[str, tuple[np.ndarray, np.ndarray]]: + signal = _stub_signal() + return {"spectral_centroid": signal, "rms": signal} + + def _write_project(project: Path) -> None: project.mkdir() stem_root = stems_dir(project) @@ -49,7 +54,7 @@ def _write_project(project: Path) -> None: @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_other", return_value=_stub_other()) @patch("cleave.analyse.extract_vocals", return_value=_stub_vocals()) @patch("cleave.analyse.extract_bass", return_value=_stub_bass()) @patch( @@ -58,7 +63,7 @@ def _write_project(project: Path) -> None: ) @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_3_full_mix( +def test_run_analyse_writes_version_4_full_mix( _duration: object, _drums: object, beats_downbeats: object, @@ -75,12 +80,14 @@ def test_run_analyse_writes_version_3_full_mix( signals_path = run_analyse(project, high_quality=False) data = json.loads(signals_path.read_text(encoding="utf-8")) - assert data["version"] == 3 + assert data["version"] == 4 assert data["beat_detection_stem"] == "full_mix" assert "mix_onset_strength" not in data["drums"] assert set(data["full_mix"]) == {"onset_strength", "rms"} + assert set(data["other"]) == {"spectral_centroid", "rms"} assert len(data["full_mix"]["onset_strength"]) > 0 assert len(data["full_mix"]["rms"]) > 0 + assert len(data["other"]["rms"]) > 0 assert data["beat_times"] == [0.5, 1.0, 1.5] assert data["downbeat_times"] == [0.5, 1.5] beats_downbeats.assert_called_once_with(project / "mix.wav") @@ -88,7 +95,7 @@ def test_run_analyse_writes_version_3_full_mix( @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_other", return_value=_stub_other()) @patch("cleave.analyse.extract_vocals", return_value=_stub_vocals()) @patch("cleave.analyse.extract_bass", return_value=_stub_bass()) @patch( @@ -122,7 +129,7 @@ def test_run_analyse_uses_beat_detection_stem_path( @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_other", return_value=_stub_other()) @patch("cleave.analyse.extract_vocals", return_value=_stub_vocals()) @patch("cleave.analyse.extract_bass", return_value=_stub_bass()) @patch( diff --git a/tests/cleave/test_cli.py b/tests/cleave/test_cli.py index 090db36..76d9bcc 100644 --- a/tests/cleave/test_cli.py +++ b/tests/cleave/test_cli.py @@ -113,7 +113,7 @@ def test_cmd_separate_noop_when_complete( original_path=tmp_path / "song.flac", demucs_model="htdemucs", ) - (project / "signals.json").write_text('{"version": 3}') + (project / "signals.json").write_text('{"version": 4}') with patch("cleave.separate.run_separate") as run_separate: cmd_separate(build_parser().parse_args(["separate", "song"])) @@ -141,7 +141,7 @@ def test_cmd_separate_does_not_noop_when_beat_stem_differs( demucs_model="htdemucs", ) (project / "signals.json").write_text( - '{"version": 3, "beat_detection_stem": "full_mix"}' + '{"version": 4, "beat_detection_stem": "full_mix"}' ) with patch("cleave.separate.run_separate", return_value=project.resolve()) as run_separate: @@ -255,7 +255,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('{"version": 3}') + (project / "signals.json").write_text('{"version": 4}') return project diff --git a/tests/cleave/test_config.py b/tests/cleave/test_config.py index 0d124a7..c0fa209 100644 --- a/tests/cleave/test_config.py +++ b/tests/cleave/test_config.py @@ -1187,6 +1187,7 @@ def test_persist_timeline_preset_round_trip() -> None: timeline_preset_kind="arc", timeline_preset_crescendo="penultimate", timeline_preset_density="very dense", + timeline_preset_conductor=True, ), ) cfg = CleaveConfig( @@ -1202,6 +1203,7 @@ def test_persist_timeline_preset_round_trip() -> None: "character": "arc", "crescendo": "penultimate", "density": "very dense", + "conductor": True, } round_trip = parse_timeline_section( {"timeline": payload}, @@ -1212,6 +1214,7 @@ def test_persist_timeline_preset_round_trip() -> None: character="arc", crescendo="penultimate", density="very dense", + conductor=True, ) @@ -1223,6 +1226,7 @@ def test_parse_timeline_reads_preset() -> None: "character": "pulse", "crescendo": "last", "density": "sparse", + "conductor": True, } } }, @@ -1233,6 +1237,7 @@ def test_parse_timeline_reads_preset() -> None: character="pulse", crescendo="last", density="sparse", + conductor=True, ) @@ -1245,6 +1250,7 @@ def test_parse_timeline_preset_null_crescendo() -> None: assert timeline.preset.character == "dialogue" assert timeline.preset.crescendo is None assert timeline.preset.density == "normal" + assert timeline.preset.conductor is False def test_parse_timeline_rejects_invalid_placement_snap() -> None: @@ -1271,6 +1277,14 @@ def test_parse_timeline_rejects_invalid_preset_density() -> None: ) +def test_parse_timeline_rejects_invalid_preset_conductor() -> None: + with pytest.raises(ValueError, match="conductor"): + parse_timeline_section( + {"timeline": {"preset": {"conductor": "on"}}}, + _timeline_parse_ctx(), + ) + + def test_parse_timeline_reads_locked() -> None: timeline = parse_timeline_section( {"timeline": {"enabled": True, "locked": True}}, diff --git a/tests/cleave/test_config_snapshot.py b/tests/cleave/test_config_snapshot.py index 4f20dcf..4609de5 100644 --- a/tests/cleave/test_config_snapshot.py +++ b/tests/cleave/test_config_snapshot.py @@ -1140,6 +1140,7 @@ def test_write_session_snapshot_persists_timeline_disabled_without_cues( "character": "breathing", "crescendo": None, "density": "normal", + "conductor": False, }, } @@ -1149,6 +1150,7 @@ def test_write_session_snapshot_round_trips_timeline_preset(tmp_path: Path) -> N session.timeline.timeline_preset_kind = "arc" session.timeline.timeline_preset_crescendo = "last" session.timeline.timeline_preset_density = "dense" + session.timeline.timeline_preset_conductor = True write_session_snapshot(out_path, cfg=cfg, session=session) data = yaml.safe_load(out_path.read_text(encoding="utf-8")) @@ -1156,6 +1158,7 @@ def test_write_session_snapshot_round_trips_timeline_preset(tmp_path: Path) -> N "character": "arc", "crescendo": "last", "density": "dense", + "conductor": True, } timeline = parse_timeline_section( @@ -1177,6 +1180,7 @@ def test_write_session_snapshot_round_trips_timeline_preset(tmp_path: Path) -> N assert session2.timeline.timeline_preset_kind == "arc" assert session2.timeline.timeline_preset_crescendo == "last" assert session2.timeline.timeline_preset_density == "dense" + assert session2.timeline.timeline_preset_conductor is True def _round_trip_preset_dirs(root: Path) -> Path: diff --git a/tests/cleave/test_separate.py b/tests/cleave/test_separate.py index faeccf1..50633ed 100644 --- a/tests/cleave/test_separate.py +++ b/tests/cleave/test_separate.py @@ -54,7 +54,7 @@ def test_signals_complete_false_when_missing(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('{"version": 3}') + (project / "signals.json").write_text('{"version": 4}') assert signals_complete(project) is True @@ -124,7 +124,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('{"version": 3}') + (project / "signals.json").write_text('{"version": 4}') with patch("cleave.separate._run_demucs") as run_demucs, patch( "cleave.separate.run_analyse" @@ -149,7 +149,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('{"version": 3}') + (project / "signals.json").write_text('{"version": 4}') with patch("cleave.separate._run_demucs") as run_demucs, patch( "cleave.separate.run_analyse" @@ -237,7 +237,7 @@ def test_run_separate_reanalyses_on_explicit_beat_stem_mismatch( demucs_model="htdemucs", ) (project / "signals.json").write_text( - '{"version": 3, "beat_detection_stem": "full_mix"}' + '{"version": 4, "beat_detection_stem": "full_mix"}' ) with patch("cleave.separate._run_demucs") as run_demucs, patch( @@ -263,7 +263,7 @@ def test_run_separate_skips_when_explicit_beat_stem_matches_stored( project.mkdir(parents=True) _write_stub_stems(project) (project / "signals.json").write_text( - '{"version": 3, "beat_detection_stem": "drums"}' + '{"version": 4, "beat_detection_stem": "drums"}' ) with patch("cleave.separate._run_demucs") as run_demucs, patch( @@ -287,7 +287,7 @@ def test_run_separate_skips_when_flag_omitted_even_if_stored_is_drums( project.mkdir(parents=True) _write_stub_stems(project) (project / "signals.json").write_text( - '{"version": 3, "beat_detection_stem": "drums"}' + '{"version": 4, "beat_detection_stem": "drums"}' ) with patch("cleave.separate._run_demucs") as run_demucs, patch( @@ -385,7 +385,7 @@ def test_run_separate_force_uses_stored_beat_detection_stem( demucs_model="htdemucs", ) (project / "signals.json").write_text( - '{"version": 3, "beat_detection_stem": "drums"}' + '{"version": 4, "beat_detection_stem": "drums"}' ) with patch("cleave.separate._run_demucs") as run_demucs, patch( @@ -417,7 +417,7 @@ def test_run_separate_force_explicit_beat_stem_overrides_stored( demucs_model="htdemucs", ) (project / "signals.json").write_text( - '{"version": 3, "beat_detection_stem": "drums"}' + '{"version": 4, "beat_detection_stem": "drums"}' ) with patch("cleave.separate._run_demucs") as run_demucs, patch( diff --git a/tests/cleave/test_signals.py b/tests/cleave/test_signals.py index 231797f..240b8b5 100644 --- a/tests/cleave/test_signals.py +++ b/tests/cleave/test_signals.py @@ -31,7 +31,8 @@ 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"] == 3 + assert raw["version"] == 4 + assert "rms" in raw["other"] signals = load_signals(minimal_signals_json_path) @@ -49,6 +50,8 @@ def test_load_signals_minimal_fixture( assert full_mix_onset[3] == pytest.approx(0.7) full_mix_rms = signals.array("full_mix", "rms") assert full_mix_rms[0] == pytest.approx(0.10) + other_rms = signals.array("other", "rms") + assert other_rms[0] == pytest.approx(0.02) def test_load_signals_missing_beat_times_defaults_empty(tmp_path: Path) -> None: @@ -56,13 +59,13 @@ def test_load_signals_missing_beat_times_defaults_empty(tmp_path: Path) -> None: path.write_text( json.dumps( { - "version": 3, + "version": 4, "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]}, + "other": {"spectral_centroid": [1000.0], "rms": [0.0]}, "full_mix": {"onset_strength": [0.0], "rms": [0.0]}, } ), @@ -115,7 +118,7 @@ def test_load_signals_rejects_version_2(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_3(tmp_path: Path) -> None: path = tmp_path / "signals.json" path.write_text( json.dumps( @@ -127,6 +130,29 @@ def test_load_signals_rejects_missing_full_mix(tmp_path: Path) -> None: "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=r"re-run: python -m cleave separate " + ): + 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": 4, + "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], "rms": [0.0]}, } ), encoding="utf-8", @@ -135,6 +161,27 @@ def test_load_signals_rejects_missing_full_mix(tmp_path: Path) -> None: load_signals(path) +def test_load_signals_rejects_missing_other_rms(tmp_path: Path) -> None: + path = tmp_path / "signals.json" + path.write_text( + json.dumps( + { + "version": 4, + "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", + ) + with pytest.raises(ValueError, match="other missing key"): + load_signals(path) + + def test_sample_clamps_below_zero(minimal_signals: Signals) -> None: assert minimal_signals.sample("drums", "onset_strength", -1.0) == pytest.approx( 0.0 @@ -172,3 +219,37 @@ def test_normalized_uses_cache() -> None: first = signals.normalized("drums", "onset_strength", percentile=99.0) second = signals.normalized("drums", "onset_strength", percentile=99.0) assert first is second + + +def test_window_mean_range() -> None: + signals = make_signals("drums", "onset_strength", [0.0, 50.0, 100.0, 50.0, 0.0]) + # indices [1, 3) at 100 Hz -> t in [0.01, 0.03) + mean = signals.window_mean("drums", "onset_strength", 0.01, 0.03) + normed = signals.normalized("drums", "onset_strength") + assert mean == pytest.approx(float(np.nanmean(normed[1:3]))) + + +def test_window_mean_clamps_and_empty_slice() -> None: + signals = make_signals("drums", "onset_strength", [10.0, 20.0, 30.0]) + assert signals.window_mean("drums", "onset_strength", -1.0, 0.0) == pytest.approx( + 0.0 + ) + assert signals.window_mean("drums", "onset_strength", 0.05, 0.05) == pytest.approx( + 0.0 + ) + + +def test_window_mean_nan_safe() -> None: + signals = Signals( + sample_rate_hz=100.0, + duration_sec=0.02, + path=Path("."), + stems={ + "drums": { + "onset_strength": np.array([np.nan, np.nan, np.nan], dtype=np.float64) + } + }, + ) + assert signals.window_mean("drums", "onset_strength", 0.0, 0.03) == pytest.approx( + 0.0 + ) diff --git a/tests/cleave/test_timeline_conductor.py b/tests/cleave/test_timeline_conductor.py new file mode 100644 index 0000000..0a7dda0 --- /dev/null +++ b/tests/cleave/test_timeline_conductor.py @@ -0,0 +1,735 @@ +"""Tests for cleave.timeline_presets.conductor.""" + +from __future__ import annotations + +import random +from pathlib import Path + +import numpy as np +import pytest + +from cleave.extract import StemSource +from cleave.signals import Signals +from cleave.timeline import LEVEL_QUANTUM, TimelineLane, empty_lane, lane_level_at +from cleave.timeline_presets import ALL_BUILDERS, build_breathing_cues, build_pulse_cues +from cleave.timeline_presets.conductor import ( + CONDUCTOR_ACTIVITY_MIDPOINT, + CONDUCTOR_CEILING_MIN, + CONDUCTOR_GAIN_MAX, + CONDUCTOR_GAIN_MIN, + CONDUCTOR_LEVEL_FLOOR, + DEFAULT_TIMELINE_PRESET_CONDUCTOR, + PhraseWeights, + StemConductor, + cycle_timeline_preset_conductor, + support_floor_for, + timeline_preset_conductor_display, +) +from cleave.timeline_presets.crescendo import ( + apply_crescendo, + resolve_crescendo_window, +) + +_SR = 100.0 +_DUR = 20.0 +_N = int(_DUR * _SR) + + +def _envelope(quiet: float, loud: float) -> np.ndarray: + """Quiet first half, loud second half.""" + arr = np.empty(_N, dtype=np.float64) + mid = _N // 2 + arr[:mid] = quiet + arr[mid:] = loud + return arr + + +def _make_signals( + *, + drums: np.ndarray | None = None, + bass: np.ndarray | None = None, + vocals: np.ndarray | None = None, + other: np.ndarray | None = None, + mix_rms: np.ndarray | None = None, + mix_onset: np.ndarray | None = None, +) -> Signals: + drums_arr = drums if drums is not None else _envelope(0.1, 0.9) + bass_arr = bass if bass is not None else _envelope(0.2, 0.8) + vocals_arr = vocals if vocals is not None else _envelope(0.05, 0.7) + other_arr = other if other is not None else _envelope(0.15, 0.6) + mix_rms_arr = mix_rms if mix_rms is not None else _envelope(0.1, 1.0) + mix_onset_arr = mix_onset if mix_onset is not None else _envelope(0.05, 0.8) + return Signals( + sample_rate_hz=_SR, + duration_sec=_DUR, + path=Path("."), + stems={ + "drums": {"onset_strength": drums_arr}, + "bass": {"rms": bass_arr, "sub_bass": bass_arr, "mid_bass": bass_arr}, + "vocals": {"rms": vocals_arr, "pitch_hz": vocals_arr}, + "other": { + "spectral_centroid": other_arr * 1000.0, + "rms": other_arr, + }, + "full_mix": { + "onset_strength": mix_onset_arr, + "rms": mix_rms_arr, + }, + }, + ) + + +def _slot_stems() -> dict[str, StemSource]: + return { + "layer_1": "drums", + "layer_2": "bass", + "layer_3": "vocals", + "layer_4": "other", + } + + +def _phrases() -> list[tuple[float, float]]: + return [(0.0, 10.0), (10.0, 20.0)] + + +def test_build_returns_none_without_signals() -> None: + assert StemConductor.build(None, _slot_stems(), _phrases()) is None + + +def test_build_returns_none_without_slot_stems() -> None: + assert StemConductor.build(_make_signals(), None, _phrases()) is None + assert StemConductor.build(_make_signals(), {}, _phrases()) is None + + +def test_build_returns_none_when_energy_empty() -> None: + zeros = np.zeros(_N, dtype=np.float64) + signals = _make_signals(mix_rms=zeros, mix_onset=zeros) + assert StemConductor.build(signals, _slot_stems(), _phrases()) is None + + +def test_build_returns_none_when_energy_flat() -> None: + flat = np.full(_N, 0.5, dtype=np.float64) + signals = _make_signals(mix_rms=flat, mix_onset=flat) + assert StemConductor.build(signals, _slot_stems(), _phrases()) is None + + +def test_loud_phrase_has_higher_gain_and_ceiling() -> None: + conductor = StemConductor.build(_make_signals(), _slot_stems(), _phrases()) + assert conductor is not None + quiet = conductor.phrase_at(5.0) + loud = conductor.phrase_at(15.0) + assert quiet.budget_gain < loud.budget_gain + assert quiet.lead_ceiling < loud.lead_ceiling + assert quiet.budget_gain == pytest.approx(CONDUCTOR_GAIN_MIN) + assert loud.budget_gain == pytest.approx(CONDUCTOR_GAIN_MAX) + assert CONDUCTOR_CEILING_MIN <= quiet.lead_ceiling < 1.0 + assert loud.lead_ceiling == pytest.approx(1.0) + + +def test_budget_gain_curve_is_centred_on_one() -> None: + """Gain redistributes budget between phrases instead of lowering it overall.""" + assert CONDUCTOR_GAIN_MIN + CONDUCTOR_GAIN_MAX == pytest.approx(2.0) + n = int(_DUR * 5) + step = _DUR / n + ramp = np.linspace(0.05, 1.0, _N, dtype=np.float64) + signals = _make_signals(mix_rms=ramp, mix_onset=ramp) + phrases = [(i * step, (i + 1) * step) for i in range(n)] + conductor = StemConductor.build(signals, _slot_stems(), phrases) + assert conductor is not None + gains = [ + conductor.phrase_at((t0 + t1) * 0.5).budget_gain for t0, t1 in phrases + ] + assert sum(gains) / len(gains) == pytest.approx(1.0) + + +def test_lead_ceiling_only_dims_quiet_phrases() -> None: + """A loud song keeps the lead near full instead of dimming half its phrases.""" + n = 10 + step = _DUR / n + # Consistently loud apart from one quiet phrase. + arr = np.full(_N, 0.9, dtype=np.float64) + arr[: _N // n] = 0.15 + signals = _make_signals(mix_rms=arr, mix_onset=arr) + phrases = [(i * step, (i + 1) * step) for i in range(n)] + conductor = StemConductor.build(signals, _slot_stems(), phrases) + assert conductor is not None + ceilings = [ + conductor.phrase_at((t0 + t1) * 0.5).lead_ceiling for t0, t1 in phrases + ] + assert ceilings[0] < 0.8 + assert all(ceiling > 0.95 for ceiling in ceilings[1:]) + + +def test_near_silent_level_states_floor_lead_only() -> None: + # Absolute energy below silence floor in the quiet half after normalize. + mix_rms = _envelope(0.001, 1.0) + mix_onset = _envelope(0.001, 1.0) + signals = _make_signals(mix_rms=mix_rms, mix_onset=mix_onset) + conductor = StemConductor.build(signals, _slot_stems(), _phrases()) + assert conductor is not None + quiet = conductor.phrase_at(5.0) + assert quiet.near_silent + + states = [ + (0.0, frozenset({"layer_1", "layer_2", "layer_3"})), + (10.0, frozenset({"layer_1", "layer_2"})), + ] + levels = conductor.level_states(states, _DUR) + quiet_levels = levels[0][1] + on = [slot for slot, level in quiet_levels.items() if level > 0.0] + assert len(on) == 1 + assert quiet_levels[on[0]] == pytest.approx(CONDUCTOR_LEVEL_FLOOR) + for slot, level in quiet_levels.items(): + if slot != on[0]: + assert level == pytest.approx(0.0) + + +def test_rotation_for_prefers_active_stem() -> None: + # Vocals silent throughout; drums loud. + drums = np.full(_N, 1.0, dtype=np.float64) + vocals = np.zeros(_N, dtype=np.float64) + signals = _make_signals(drums=drums, vocals=vocals) + conductor = StemConductor.build(signals, _slot_stems(), _phrases()) + assert conductor is not None + weights = conductor.phrase_at(15.0) + assert weights.slot_activity["layer_3"] == pytest.approx(0.0) + singles = ["layer_3", "layer_1"] # vocals (silent), drums (loud) + airtime = {slot: 0.0 for slot in singles} + index = conductor.rotation_for(singles, weights, airtime) + assert singles[index] == "layer_1" + + +def test_slot_activity_is_comparable_across_stems() -> None: + """Standardised activity compares each stem to itself, not to other scales.""" + n = 8 + step = _DUR / n + phrases = [(i * step, (i + 1) * step) for i in range(n)] + # Drum onsets are spiky and low-mean; bass rms is dense and high-mean. Both + # peak in the same phrase, so both should rank top there. + shape = np.zeros(_N, dtype=np.float64) + for i in range(n): + lo = int(i * _N / n) + hi = int((i + 1) * _N / n) + shape[lo:hi] = 0.1 + 0.1 * i + signals = _make_signals(drums=shape * 0.2, bass=shape * 5.0) + conductor = StemConductor.build(signals, _slot_stems(), phrases) + assert conductor is not None + top = conductor.phrase_at(phrases[-1][0] + 0.1) + assert top.slot_activity["layer_1"] == pytest.approx(1.0) + assert top.slot_activity["layer_2"] == pytest.approx(1.0) + + +def test_rotation_for_penalises_airtime_share() -> None: + conductor = StemConductor.build(_make_signals(), _slot_stems(), _phrases()) + assert conductor is not None + weights = conductor.phrase_at(15.0) + singles = ["layer_1", "layer_2", "layer_3", "layer_4"] + balanced = {slot: 0.0 for slot in singles} + lead = singles[conductor.rotation_for(singles, weights, balanced)] + # Airtime is a share of total screen time, so a hogging slot loses the lead. + hogging = {slot: (600.0 if slot == lead else 1.0) for slot in singles} + assert singles[conductor.rotation_for(singles, weights, hogging)] != lead + + +def test_levels_are_quantum_multiples_and_active_floor() -> None: + conductor = StemConductor.build(_make_signals(), _slot_stems(), _phrases()) + assert conductor is not None + states = [ + (10.0, frozenset({"layer_1", "layer_2", "layer_3", "layer_4"})), + ] + levels = conductor.level_states(states, _DUR)[0][1] + for slot, level in levels.items(): + steps = level / LEVEL_QUANTUM + assert steps == pytest.approx(round(steps)) + assert level == 0.0 or level >= CONDUCTOR_LEVEL_FLOOR + assert not (0.0 < level < CONDUCTOR_LEVEL_FLOOR) + + +def test_deterministic_for_same_inputs() -> None: + signals = _make_signals() + slots = _slot_stems() + phrases = _phrases() + a = StemConductor.build(signals, slots, phrases) + b = StemConductor.build(signals, slots, phrases) + assert a is not None and b is not None + states = [(0.0, frozenset({"layer_1", "layer_2"})), (10.0, frozenset({"layer_3"}))] + assert a.level_states(states, _DUR) == b.level_states(states, _DUR) + assert a.phrase_at(12.0) == b.phrase_at(12.0) + weights = a.phrase_at(12.0) + airtime = {"layer_1": 0.2, "layer_2": 0.1} + assert a.rotation_for(["layer_1", "layer_2"], weights, airtime) == b.rotation_for( + ["layer_1", "layer_2"], weights, airtime + ) + + +def test_chord_score_is_centred_mean_activity() -> None: + conductor = StemConductor.build(_make_signals(), _slot_stems(), _phrases()) + assert conductor is not None + weights = conductor.phrase_at(15.0) + score = conductor.chord_score(frozenset({"layer_1", "layer_2"}), weights) + expected = ( + weights.slot_activity["layer_1"] + + weights.slot_activity["layer_2"] + - 2.0 * CONDUCTOR_ACTIVITY_MIDPOINT + ) / 2.0 + assert score == pytest.approx(expected) + assert conductor.chord_score(frozenset(), weights) == pytest.approx(0.0) + + +def test_chord_score_does_not_penalise_larger_chords() -> None: + """A chord of neutral slots scores the same at any cardinality.""" + conductor = StemConductor.build(_make_signals(), _slot_stems(), _phrases()) + assert conductor is not None + weights = PhraseWeights( + budget_gain=1.0, + slot_activity={ + slot: CONDUCTOR_ACTIVITY_MIDPOINT for slot in _slot_stems() + }, + lead_ceiling=1.0, + near_silent=False, + ) + solo = conductor.chord_score(frozenset({"layer_1"}), weights) + quartet = conductor.chord_score(frozenset(_slot_stems()), weights) + assert solo == pytest.approx(0.0) + assert quartet == pytest.approx(solo) + + +def test_staging_display_and_cycle() -> None: + assert DEFAULT_TIMELINE_PRESET_CONDUCTOR is False + assert timeline_preset_conductor_display(False) == "off" + assert timeline_preset_conductor_display(True) == "on" + assert cycle_timeline_preset_conductor(False, forward=True) is True + assert cycle_timeline_preset_conductor(True, forward=True) is False + assert cycle_timeline_preset_conductor(False, forward=False) is True + assert cycle_timeline_preset_conductor(True, forward=False) is False + + +def _bar_times(duration_sec: float, bar_sec: float = 2.0) -> list[float]: + bars: list[float] = [] + t = 0.0 + while t < duration_sec - 1e-9: + bars.append(t) + t += bar_sec + return bars + + +def _lane_signature(lanes: dict[str, TimelineLane]) -> dict[str, object]: + return { + slot: (lane.baseline, tuple((c.t, c.level) for c in lane.cues)) + for slot, lane in sorted(lanes.items()) + } + + +@pytest.mark.parametrize("builder", ALL_BUILDERS) +def test_arranger_without_signals_matches_baseline(builder) -> None: + slots = ["layer_1", "layer_2", "layer_3", "layer_4"] + duration_sec = 60.0 + bars = _bar_times(duration_sec) + seed = 42 + baseline = builder( + slots, duration_sec, random.Random(seed), bar_times=bars + ) + without = builder( + slots, + duration_sec, + random.Random(seed), + bar_times=bars, + signals=None, + slot_stems=None, + ) + assert _lane_signature(without) == _lane_signature(baseline) + + +def test_arranger_with_conductor_emits_quantized_levels() -> None: + slots = ["layer_1", "layer_2", "layer_3", "layer_4"] + duration_sec = 20.0 + bars = _bar_times(duration_sec) + signals = _make_signals() + slot_stems = _slot_stems() + lanes = build_breathing_cues( + slots, + duration_sec, + random.Random(7), + bar_times=bars, + signals=signals, + slot_stems=slot_stems, + ) + assert set(lanes) == set(slots) + saw_partial = False + levels: list[float] = [] + for lane in lanes.values(): + if lane.baseline is not None: + levels.append(float(lane.baseline)) + levels.extend(float(cue.level) for cue in lane.cues) + assert levels + for level in levels: + steps = level / LEVEL_QUANTUM + assert steps == pytest.approx(round(steps)) + assert level == 0.0 or level >= CONDUCTOR_LEVEL_FLOOR + assert not (0.0 < level < CONDUCTOR_LEVEL_FLOOR) + if 0.0 < level < 1.0 - 1e-9: + saw_partial = True + assert saw_partial + + +def _compressed_master_signals(duration_sec: float) -> Signals: + """Compressed mix with stems always near their own scale (typical master).""" + n = int(duration_sec * _SR) + t = np.linspace(0.0, 1.0, n, dtype=np.float64) + # Mild full-mix sway only: min ~0.6 of peak. + mix = 0.6 + 0.4 * (0.5 + 0.5 * np.sin(np.pi * t)) + + def flat(scale: float, wobble: float = 0.02) -> np.ndarray: + return scale * (1.0 + wobble * np.sin(8.0 * np.pi * t)) + + return Signals( + sample_rate_hz=_SR, + duration_sec=duration_sec, + path=Path("."), + stems={ + "drums": {"onset_strength": flat(0.2)}, + "bass": { + "rms": flat(0.7), + "sub_bass": flat(0.7), + "mid_bass": flat(0.7), + }, + "vocals": {"rms": flat(0.5), "pitch_hz": flat(0.5)}, + "other": { + "spectral_centroid": flat(700.0), + "rms": flat(0.65), + }, + "full_mix": {"onset_strength": mix * 0.8, "rms": mix}, + }, + ) + + +def test_conductor_emits_partial_cue_levels_on_compressed_master() -> None: + """Regression: high support floors + soft ceiling pinned every active cue at 1.0.""" + slots = ["layer_1", "layer_2", "layer_3", "layer_4"] + duration_sec = 180.0 + bars = _bar_times(duration_sec) + signals = _compressed_master_signals(duration_sec) + slot_stems = _slot_stems() + off = build_pulse_cues( + slots, + duration_sec, + random.Random(42), + bar_times=bars, + density_bias=1, + ) + on = build_pulse_cues( + slots, + duration_sec, + random.Random(42), + bar_times=bars, + density_bias=1, + signals=signals, + slot_stems=slot_stems, + ) + off_active = [ + float(cue.level) + for lane in off.values() + for cue in lane.cues + if cue.level > 1e-9 + ] + on_active = [ + float(cue.level) + for lane in on.values() + for cue in lane.cues + if cue.level > 1e-9 + ] + assert off_active + assert on_active + assert all(level == pytest.approx(1.0) for level in off_active) + partial = [level for level in on_active if 0.0 < level < 1.0 - 1e-9] + assert partial, f"expected partial levels, got {sorted(set(on_active))}" + assert any(level in (0.25, 0.5, 0.75) for level in partial) + assert sum(on_active) / len(on_active) < 1.0 - 1e-9 + + +def test_arranger_conductor_is_deterministic() -> None: + slots = ["layer_1", "layer_2", "layer_3", "layer_4"] + duration_sec = 20.0 + bars = _bar_times(duration_sec) + signals = _make_signals() + slot_stems = _slot_stems() + a = build_breathing_cues( + slots, + duration_sec, + random.Random(11), + bar_times=bars, + signals=signals, + slot_stems=slot_stems, + ) + b = build_breathing_cues( + slots, + duration_sec, + random.Random(11), + bar_times=bars, + signals=signals, + slot_stems=slot_stems, + ) + assert _lane_signature(a) == _lane_signature(b) + + +def test_loud_phrases_have_more_level_weight() -> None: + conductor = StemConductor.build(_make_signals(), _slot_stems(), _phrases()) + assert conductor is not None + active = frozenset({"layer_1", "layer_2", "layer_3", "layer_4"}) + levels = conductor.level_states( + [(0.0, active), (10.0, active)], + _DUR, + ) + quiet_weight = sum(levels[0][1].values()) + loud_weight = sum(levels[1][1].values()) + assert loud_weight > quiet_weight + + +def test_arranger_silent_stem_soloed_less_often() -> None: + slots = ["layer_1", "layer_2", "layer_3", "layer_4"] + duration_sec = 60.0 + bars = _bar_times(duration_sec) + n = int(duration_sec * _SR) + drums = np.full(n, 1.0, dtype=np.float64) + vocals = np.zeros(n, dtype=np.float64) + bass = np.full(n, 0.5, dtype=np.float64) + other = np.full(n, 0.4, dtype=np.float64) + mix_rms = np.linspace(0.2, 1.0, n, dtype=np.float64) + mix_onset = np.linspace(0.1, 0.9, n, dtype=np.float64) + signals = Signals( + sample_rate_hz=_SR, + duration_sec=duration_sec, + path=Path("."), + stems={ + "drums": {"onset_strength": drums}, + "bass": {"rms": bass, "sub_bass": bass, "mid_bass": bass}, + "vocals": {"rms": vocals, "pitch_hz": vocals}, + "other": {"spectral_centroid": other * 1000.0, "rms": other}, + "full_mix": {"onset_strength": mix_onset, "rms": mix_rms}, + }, + ) + solo_counts = {"layer_1": 0, "layer_3": 0} + samples = 0 + for seed in range(20): + lanes = build_breathing_cues( + slots, + duration_sec, + random.Random(seed), + bar_times=bars, + signals=signals, + slot_stems=_slot_stems(), + ) + t = 0.0 + while t < duration_sec - 1e-9: + levels = { + slot: lane_level_at(lanes[slot], t, inherit=0.0) for slot in slots + } + active = [slot for slot, level in levels.items() if level > 0.0] + if len(active) == 1 and active[0] in solo_counts: + solo_counts[active[0]] += 1 + samples += 1 + t += 1.0 + assert samples > 0 + assert solo_counts["layer_1"] > solo_counts["layer_3"] + + +def _ducked_conductor(density_bias: int) -> StemConductor: + """Layer 2 is loud early and faint late, so it supports rather than leads.""" + faint = np.empty(_N, dtype=np.float64) + mid = _N // 2 + faint[:mid] = 0.9 + faint[mid:] = 0.05 + signals = _make_signals( + drums=np.full(_N, 0.9, dtype=np.float64), + bass=faint, + vocals=np.full(_N, 0.85, dtype=np.float64), + other=np.full(_N, 0.8, dtype=np.float64), + mix_rms=_envelope(0.8, 1.0), + mix_onset=_envelope(0.7, 1.0), + ) + conductor = StemConductor.build( + signals, _slot_stems(), _phrases(), density_bias=density_bias + ) + assert conductor is not None + return conductor + + +def test_support_floor_rises_with_density_bias() -> None: + floors = [support_floor_for(bias) for bias in (-2, -1, 0, 1, 2)] + assert floors == sorted(floors) + assert floors[0] < floors[2] < floors[-1] + + +def test_supporting_slot_stays_visible() -> None: + """A ducked slot lands well above the floor, not at an invisible 0.25.""" + active = frozenset({"layer_1", "layer_2", "layer_3", "layer_4"}) + levels = _ducked_conductor(0).level_states([(10.0, active)], _DUR)[0][1] + lead = max(levels.values()) + support = levels["layer_2"] + assert lead == pytest.approx(1.0) + assert support < lead + assert support >= 2.0 * LEVEL_QUANTUM + + +def test_duck_depth_shrinks_as_density_rises() -> None: + active = frozenset({"layer_1", "layer_2", "layer_3", "layer_4"}) + sparse = _ducked_conductor(-2).level_states([(10.0, active)], _DUR)[0][1] + dense = _ducked_conductor(2).level_states([(10.0, active)], _DUR)[0][1] + assert sparse["layer_2"] < dense["layer_2"] + # Very dense lifts the support toward the lead without erasing ducking. + assert dense["layer_2"] < max(dense.values()) + + +def _varied_signals(duration_sec: float) -> Signals: + """Each stem takes its turn as the busiest; stem scales differ widely.""" + n = int(duration_sec * _SR) + t = np.linspace(0.0, 1.0, n, dtype=np.float64) + + def turn(phase: float, scale: float) -> np.ndarray: + cycle = np.sin(2.0 * np.pi * (3.0 * t + phase)) + return scale * (0.35 + 0.3 * (1.0 + cycle)) + + arc = 0.3 + 0.6 * np.abs(np.sin(np.pi * t)) + return Signals( + sample_rate_hz=_SR, + duration_sec=duration_sec, + path=Path("."), + stems={ + "drums": {"onset_strength": turn(0.0, 0.2)}, + "bass": { + "rms": turn(0.25, 0.9), + "sub_bass": turn(0.25, 0.9), + "mid_bass": turn(0.25, 0.9), + }, + "vocals": {"rms": turn(0.5, 0.6), "pitch_hz": turn(0.5, 0.6)}, + "other": { + "spectral_centroid": turn(0.75, 700.0), + "rms": turn(0.75, 0.75), + }, + "full_mix": {"onset_strength": arc * 0.8, "rms": arc}, + }, + ) + + +def _sampled_weight_and_shares( + lanes: dict[str, TimelineLane], + slots: list[str], + duration_sec: float, + step: float = 0.5, +) -> tuple[float, dict[str, float]]: + total = 0.0 + samples = 0 + on = {slot: 0 for slot in slots} + t = 0.0 + while t < duration_sec - 1e-9: + levels = { + slot: lane_level_at(lanes[slot], t, inherit=0.0) for slot in slots + } + total += sum(levels.values()) + for slot, level in levels.items(): + if level > 0.0: + on[slot] += 1 + samples += 1 + t += step + return total / samples, {slot: count / samples for slot, count in on.items()} + + +def test_density_bias_raises_concurrent_weight_with_conductor() -> None: + slots = ["layer_1", "layer_2", "layer_3", "layer_4"] + duration_sec = 180.0 + bars = _bar_times(duration_sec) + signals = _varied_signals(duration_sec) + means: list[float] = [] + for bias in (-2, 0, 2): + totals = [] + for seed in range(6): + lanes = build_breathing_cues( + slots, + duration_sec, + random.Random(seed), + bar_times=bars, + density_bias=bias, + signals=signals, + slot_stems=_slot_stems(), + ) + mean, _shares = _sampled_weight_and_shares(lanes, slots, duration_sec) + totals.append(mean) + means.append(sum(totals) / len(totals)) + assert means == sorted(means), means + assert means[-1] > means[0], means + + +def test_conductor_does_not_let_one_slot_dominate() -> None: + """With every stem taking its turn, no slot hogs the composite.""" + slots = ["layer_1", "layer_2", "layer_3", "layer_4"] + duration_sec = 180.0 + bars = _bar_times(duration_sec) + signals = _varied_signals(duration_sec) + totals = {slot: 0.0 for slot in slots} + seeds = 6 + for seed in range(seeds): + lanes = build_breathing_cues( + slots, + duration_sec, + random.Random(seed), + bar_times=bars, + signals=signals, + slot_stems=_slot_stems(), + ) + _mean, shares = _sampled_weight_and_shares(lanes, slots, duration_sec) + # Each seed must move the composite around rather than hold one layer. + assert max(shares.values()) < 0.85, (seed, shares) + assert len(lanes[max(shares, key=shares.get)].cues) > 0 + for slot, share in shares.items(): + totals[slot] += share / seeds + assert max(totals.values()) < 0.6, totals + assert min(totals.values()) > 0.05, totals + + +def test_arranger_conductor_crescendo_preserves_prefix_levels() -> None: + slots = ["layer_1", "layer_2", "layer_3", "layer_4"] + duration_sec = 120.0 + bars = _bar_times(duration_sec) + n = int(duration_sec * _SR) + mix_rms = np.linspace(0.15, 1.0, n, dtype=np.float64) + mix_onset = np.linspace(0.1, 0.9, n, dtype=np.float64) + stem = np.linspace(0.2, 0.9, n, dtype=np.float64) + signals = Signals( + sample_rate_hz=_SR, + duration_sec=duration_sec, + path=Path("."), + stems={ + "drums": {"onset_strength": stem}, + "bass": {"rms": stem, "sub_bass": stem, "mid_bass": stem}, + "vocals": {"rms": stem, "pitch_hz": stem}, + "other": {"spectral_centroid": stem * 1000.0, "rms": stem}, + "full_mix": {"onset_strength": mix_onset, "rms": mix_rms}, + }, + ) + markers = [20.0, 50.0, 80.0, 100.0] + window = resolve_crescendo_window(markers, duration_sec, "last") + assert window is not None + base = build_breathing_cues( + slots, + duration_sec, + random.Random(5), + bar_times=bars, + signals=signals, + slot_stems=_slot_stems(), + ) + after = apply_crescendo( + base, + slots, + duration_sec=duration_sec, + bar_times=bars, + song_marker_times=markers, + target="last", + rng=random.Random(6), + ) + t = 0.0 + while t < window.t_start - 1e-9: + for slot in slots: + assert lane_level_at(after[slot], t, inherit=0.0) == pytest.approx( + lane_level_at(base[slot], t, inherit=0.0) + ) + t += 1.0 diff --git a/tests/cleave/test_timeline_presets.py b/tests/cleave/test_timeline_presets.py index 360c532..f973c10 100644 --- a/tests/cleave/test_timeline_presets.py +++ b/tests/cleave/test_timeline_presets.py @@ -719,8 +719,12 @@ def test_resolve_crescendo_window_requires_three_markers() -> None: assert resolve_crescendo_window([10.0, 50.0], 100.0, "last") is None -def test_crescendo_states_emit_half_entrant_then_full_stack() -> None: - from cleave.timeline_presets.crescendo import CrescendoWindow, _crescendo_states +def test_crescendo_states_ramp_through_quantised_levels() -> None: + from cleave.timeline_presets.crescendo import ( + CRESCENDO_ENTRY_LEVEL, + CrescendoWindow, + _crescendo_states, + ) slots = _slots(4) bars = _bar_times_for(120.0) @@ -733,16 +737,36 @@ def test_crescendo_states_emit_half_entrant_then_full_stack() -> None: rng=random.Random(0), ) assert states - # Steps before t_full include a 0.5 entrant; t_full is a full stack. - half_steps = [levels for t, levels in states if t < window.t_full - 1e-9] - assert half_steps - for levels in half_steps: - assert 0.5 in levels.values() + ramp = [levels for t, levels in states if t < window.t_full - 1e-9] + # A build, not a switch: more than two steps and each is dimmer than the last. + assert len(ramp) > 2 + weights = [sum(levels.values()) for levels in ramp] + assert weights == sorted(weights) + assert weights[0] == pytest.approx(CRESCENDO_ENTRY_LEVEL) + distinct_levels = {level for levels in ramp for level in levels.values()} + assert len(distinct_levels) > 2 + # Entrant count grows monotonically toward the full stack. + counts = [len(levels) for levels in ramp] + assert counts == sorted(counts) full = next(levels for t, levels in states if abs(t - window.t_full) < 1e-9) assert set(full.values()) == {1.0} assert len(full) == min(4, MAX_CONCURRENT_LAYERS) +def test_crescendo_spread_times_are_evenly_spaced() -> None: + from cleave.timeline_presets.crescendo import _spread_times + + bars = _bar_times_for(120.0) + times = _spread_times(50.0, 80.0, 4, bars) + assert len(times) == 4 + assert times[0] == pytest.approx(50.0) + assert times[-1] == pytest.approx(80.0) + gaps = [b - a for a, b in zip(times, times[1:])] + assert all(gap > 0.0 for gap in gaps) + # Interior steps land mid-window, not bunched against either end. + assert max(gaps) <= 2.0 * min(gaps) + + def test_apply_crescendo_ramps_holds_then_solos() -> None: slots = _slots(4) duration_sec = 120.0 diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 0c36552..b85a1a9 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -117,6 +117,7 @@ def _make_controls( bar_times: tuple[float, ...] = (), project_dir: Path | None = None, duration_sec: float = 120.0, + signals=None, ) -> TuningControls: preset_root = Path("/tmp/presets") cfg = make_test_cfg(slots, preset_root=preset_root, config_path=launch_config_path or _DEFAULT_ACTIVE_CONFIG) @@ -145,6 +146,7 @@ def _make_controls( repo_root_example=repo_root_example, beat_times=beat_times, bar_times=bar_times, + signals=signals, ) @@ -1595,6 +1597,7 @@ def test_timeline_presets_enter_opens_yes_cancel_modal() -> None: controls = _make_controls(("layer_1", "layer_2", "layer_3", "layer_4")) controls.session.timeline.timeline_preset_kind = "arc" controls.session.timeline.timeline_preset_crescendo = "last" + controls.session.timeline.timeline_preset_conductor = True _focus_timeline_presets(controls) assert controls.handle_keydown(_keydown(pygame.K_RETURN)) is True modal_view = controls.modal_host.view_state() @@ -1605,7 +1608,8 @@ def test_timeline_presets_enter_opens_yes_cancel_modal() -> None: "Apply timeline preset?\n" "character: arc\n" "crescendo: last song marker\n" - "density: normal" + "density: normal\n" + "conductor: on" ) @@ -1802,6 +1806,88 @@ def test_timeline_presets_refuse_without_bars() -> None: assert view.notification_message == "No bars available; re-run separate" +def test_timeline_presets_conductor_passes_signals_to_builder() -> None: + from cleave.signals import Signals + import numpy as np + + beats = tuple(float(i) for i in range(241)) + bars = tuple(float(i) for i in range(0, 241, 4)) + n = 100 + ones = np.ones(n, dtype=np.float64) + signals = Signals( + sample_rate_hz=100.0, + duration_sec=1.0, + path=Path("."), + stems={ + "drums": {"onset_strength": ones}, + "bass": {"rms": ones, "sub_bass": ones, "mid_bass": ones}, + "vocals": {"rms": ones, "pitch_hz": ones}, + "other": {"spectral_centroid": ones, "rms": ones}, + "full_mix": {"onset_strength": ones, "rms": ones}, + }, + ) + controls = _make_controls( + ("layer_1", "layer_2", "layer_3", "layer_4"), + beat_times=beats, + bar_times=bars, + signals=signals, + ) + controls.session.timeline.timeline_preset_kind = "breathing" + controls.session.timeline.timeline_preset_conductor = True + captured: dict = {} + + def _fake_builder(slots, duration_sec, rng, **kwargs): + captured["kwargs"] = kwargs + return {slot: TimelineLane(baseline=1.0, cues=[]) for slot in slots} + + with patch.dict( + "cleave.viz.timeline_preset_controls._KIND_BUILDERS", + {"breathing": (_fake_builder, "Applied Breathing timeline preset")}, + ): + _focus_timeline_presets(controls) + _confirm_timeline_preset(controls) + assert captured["kwargs"]["signals"] is signals + assert captured["kwargs"]["slot_stems"] == { + "layer_1": "drums", + "layer_2": "bass", + "layer_3": "vocals", + "layer_4": "other", + } + view = controls.build_view_state(paused=False) + assert view.notification_message == "Applied Breathing timeline preset" + + +def test_timeline_presets_conductor_skips_without_signals() -> None: + beats = tuple(float(i) for i in range(241)) + bars = tuple(float(i) for i in range(0, 241, 4)) + controls = _make_controls( + ("layer_1", "layer_2"), + beat_times=beats, + bar_times=bars, + signals=None, + ) + controls.session.timeline.timeline_preset_kind = "breathing" + controls.session.timeline.timeline_preset_conductor = True + captured: dict = {} + + def _fake_builder(slots, duration_sec, rng, **kwargs): + captured["kwargs"] = kwargs + return {slot: TimelineLane(baseline=1.0, cues=[]) for slot in slots} + + with patch.dict( + "cleave.viz.timeline_preset_controls._KIND_BUILDERS", + {"breathing": (_fake_builder, "Applied Breathing timeline preset")}, + ): + _focus_timeline_presets(controls) + _confirm_timeline_preset(controls) + assert "signals" not in captured["kwargs"] + assert "slot_stems" not in captured["kwargs"] + assert controls.session.timeline.enabled is True + assert set(controls.session.timeline.lanes) == {"layer_1", "layer_2"} + view = controls.build_view_state(paused=False) + assert view.notification_message == "No signals; conductor skipped" + + def test_timeline_presets_cancel_and_escape_leave_unchanged() -> None: controls = _make_controls(("layer_1", "layer_2")) prior = { @@ -2641,6 +2727,7 @@ def test_render_timeline_sub_rows_dim_when_disabled() -> None: RowKind.TIMELINE_PRESET_CHARACTER, RowKind.TIMELINE_PRESET_CRESCENDO, RowKind.TIMELINE_PRESET_DENSITY, + RowKind.TIMELINE_PRESET_CONDUCTOR, RowKind.TIMELINE_PRESETS, RowKind.TIMELINE_RESET, ): diff --git a/tests/cleave/viz/test_render.py b/tests/cleave/viz/test_render.py index bdebf9b..b567fcc 100644 --- a/tests/cleave/viz/test_render.py +++ b/tests/cleave/viz/test_render.py @@ -175,7 +175,7 @@ def _setup_render_project( overrides["editor"] = editor write_minimal_config(project, preset_root, **overrides) _write_stub_stems(project) - (project / "signals.json").write_text('{"version": 3}') + (project / "signals.json").write_text('{"version": 4}') mix = project / "my-track.flac" mix.write_bytes(b"mix") write_manifest( @@ -233,7 +233,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('{"version": 3}') + (project / "signals.json").write_text('{"version": 4}') mix = project / "my-track.flac" mix.write_bytes(b"mix") write_manifest( @@ -252,7 +252,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('{"version": 3}') + (project / "signals.json").write_text('{"version": 4}') write_manifest( project, slug="my-track", diff --git a/tests/cleave/viz/test_row_fields.py b/tests/cleave/viz/test_row_fields.py index 88c7e02..24f7235 100644 --- a/tests/cleave/viz/test_row_fields.py +++ b/tests/cleave/viz/test_row_fields.py @@ -319,7 +319,7 @@ def test_apply_field_horizontal_track_header_solo_and_expand() -> None: def test_row_fields_count() -> None: - assert len(ROW_FIELDS) == 91 + assert len(ROW_FIELDS) == 92 def test_row_kinds_requiring_fields_registry_complete() -> None: diff --git a/tests/cleave/viz/test_row_semantics.py b/tests/cleave/viz/test_row_semantics.py index 5d6d84d..26c3dcc 100644 --- a/tests/cleave/viz/test_row_semantics.py +++ b/tests/cleave/viz/test_row_semantics.py @@ -291,6 +291,7 @@ def test_render_value_children_blocked_by_section_lock() -> None: assert row_blocked_by_section_lock(RowKind.TIMELINE_PRESET_CHARACTER) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_PRESET_CRESCENDO) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_PRESET_DENSITY) is True + assert row_blocked_by_section_lock(RowKind.TIMELINE_PRESET_CONDUCTOR) is True assert row_blocked_by_section_lock(RowKind.TRACK_PRESET_SWITCHING_SEED) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_BAR_PHASE) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_SNAP_TO_GRID) is True diff --git a/tests/cleave/viz/test_text_fit.py b/tests/cleave/viz/test_text_fit.py index 230274b..01f6d7e 100644 --- a/tests/cleave/viz/test_text_fit.py +++ b/tests/cleave/viz/test_text_fit.py @@ -187,13 +187,20 @@ def test_wrap_text_to_width_keeps_short_line() -> None: def test_wrap_text_to_width_preserves_explicit_newlines() -> None: font = overlay_font() - text = "Apply timeline preset?\ncharacter: arc\ncrescendo: no\ndensity: normal" + text = ( + "Apply timeline preset?\n" + "character: arc\n" + "crescendo: no\n" + "density: normal\n" + "conductor: off" + ) max_px = max(font.size(part)[0] for part in text.split("\n")) + 10 assert wrap_text_to_width(font, text, max_px) == [ "Apply timeline preset?", "character: arc", "crescendo: no", "density: normal", + "conductor: off", ] diff --git a/tests/cleave/viz/test_view_state_structure.py b/tests/cleave/viz/test_view_state_structure.py index 48b38d6..8339015 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -149,6 +149,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: preset_character = RowDescriptor(RowKind.TIMELINE_PRESET_CHARACTER) preset_crescendo = RowDescriptor(RowKind.TIMELINE_PRESET_CRESCENDO) preset_density = RowDescriptor(RowKind.TIMELINE_PRESET_DENSITY) + preset_conductor = RowDescriptor(RowKind.TIMELINE_PRESET_CONDUCTOR) presets_apply = RowDescriptor(RowKind.TIMELINE_PRESETS) reset = RowDescriptor(RowKind.TIMELINE_RESET) beat_bar_header = RowDescriptor(RowKind.TIMELINE_BEAT_BAR_GRID_HEADER) @@ -184,6 +185,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert preset_character not in view_open.layout.rows assert preset_crescendo not in view_open.layout.rows assert preset_density not in view_open.layout.rows + assert preset_conductor not in view_open.layout.rows assert presets_apply not in view_open.layout.rows assert reset in view_open.layout.rows assert beat_bar_header in view_open.layout.rows @@ -269,10 +271,13 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert view_presets_expanded.layout.rows.index(preset_density) == ( presets_header_idx + 3 ) - assert view_presets_expanded.layout.rows.index(presets_apply) == ( + assert view_presets_expanded.layout.rows.index(preset_conductor) == ( presets_header_idx + 4 ) - assert view_presets_expanded.layout.rows.index(reset) == presets_header_idx + 5 + assert view_presets_expanded.layout.rows.index(presets_apply) == ( + presets_header_idx + 5 + ) + assert view_presets_expanded.layout.rows.index(reset) == presets_header_idx + 6 session.timeline.timeline_presets_expanded = False view_presets_collapsed = builder.build(paused=False) @@ -280,6 +285,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert preset_character not in view_presets_collapsed.layout.rows assert preset_crescendo not in view_presets_collapsed.layout.rows assert preset_density not in view_presets_collapsed.layout.rows + assert preset_conductor not in view_presets_collapsed.layout.rows assert presets_apply not in view_presets_collapsed.layout.rows assert presets_header in view_presets_collapsed.layout.rows diff --git a/tests/fixtures/minimal_signals.json b/tests/fixtures/minimal_signals.json index 5cd719f..5b34164 100644 --- a/tests/fixtures/minimal_signals.json +++ b/tests/fixtures/minimal_signals.json @@ -1,5 +1,5 @@ { - "version": 3, + "version": 4, "sample_rate_hz": 100, "duration_sec": 0.14, "beat_times": [0.0, 0.5, 1.0], @@ -17,7 +17,8 @@ "pitch_hz": [220.0, 225.0, null, 235.0, 240.0, 245.0, 250.0, 255.0, 260.0, 265.0, 270.0, 275.0, 280.0, 285.0, 290.0] }, "other": { - "spectral_centroid": [1200.0, 1250.0, 1300.0, 1350.0, 1400.0, 1450.0, 1500.0, 1550.0, 1600.0, 1650.0, 1700.0, 1750.0, 1800.0, 1850.0, 1900.0] + "spectral_centroid": [1200.0, 1250.0, 1300.0, 1350.0, 1400.0, 1450.0, 1500.0, 1550.0, 1600.0, 1650.0, 1700.0, 1750.0, 1800.0, 1850.0, 1900.0], + "rms": [0.02, 0.03, 0.04, 0.05, 0.04, 0.03, 0.06, 0.07, 0.06, 0.05, 0.04, 0.08, 0.07, 0.05, 0.03] }, "full_mix": { "onset_strength": [0.0, 0.2, 0.1, 0.7, 0.3, 0.0, 0.4, 0.2, 0.1, 0.5, 0.0, 0.8, 0.2, 0.1, 0.3], From c923f508b6fde3fe62f61766ba38d9cf990c694d Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sun, 26 Jul 2026 17:57:12 +0100 Subject: [PATCH 04/12] Complete phase 3 - closed loop limiter --- .cursor/rules/gl-post-process.mdc | 2 +- .cursor/rules/live-tuning-ui.mdc | 2 +- cleave/config.py | 13 + cleave/config_schema.py | 73 ++++ cleave/timeline.py | 14 + cleave/viz/app.py | 5 + cleave/viz/controls.py | 14 + cleave/viz/frame_finish.py | 8 +- cleave/viz/layer.py | 1 + cleave/viz/layer_pipeline.py | 2 +- cleave/viz/layer_visibility.py | 4 +- cleave/viz/row_fields.py | 86 ++++ cleave/viz/row_sections.py | 48 +++ cleave/viz/row_semantics.py | 47 +++ cleave/viz/session.py | 33 ++ cleave/viz/tuning_view_state.py | 26 ++ cleave/viz/visual_limiter.py | 371 ++++++++++++++++++ docs/improved-timeline-presets.md | 48 ++- tests/cleave/test_config.py | 41 ++ tests/cleave/test_config_snapshot.py | 5 + tests/cleave/test_timeline.py | 19 + tests/cleave/viz/test_controls.py | 9 +- tests/cleave/viz/test_row_fields.py | 2 +- tests/cleave/viz/test_row_semantics.py | 2 + tests/cleave/viz/test_view_state_structure.py | 50 ++- tests/cleave/viz/test_visual_limiter.py | 371 ++++++++++++++++++ 26 files changed, 1279 insertions(+), 17 deletions(-) create mode 100644 cleave/viz/visual_limiter.py create mode 100644 tests/cleave/viz/test_visual_limiter.py diff --git a/.cursor/rules/gl-post-process.mdc b/.cursor/rules/gl-post-process.mdc index 2d04e66..2e8f2d1 100644 --- a/.cursor/rules/gl-post-process.mdc +++ b/.cursor/rules/gl-post-process.mdc @@ -27,7 +27,7 @@ Reuse or add `*_active()` helpers in `post_fx.py` that check `render.post_fx.ena When enabled, compositor FBOs use `GL_RGBA16F` ([cleave/gl_color_format.py](cleave/gl_color_format.py)). `GlPostProcess` must match via `moderngl_external_dtype` / `moderngl_internal_dtype` (`f2` half-float, `f1` for 8-bit internal). **Never `u1` for 8-bit internal buffers** (breaks copy/bloom/grit). Skip output clamp when wired for `RGBA16F` (`hdr` uniform). 8-bit black-key stacking washes bright layers before tone curves. -`finish_content_frame` order: HDR display shoulder (fixed constants in `post_fx.py`; not gated on post-FX `enabled`) -> user composite highlight rolloff -> user composite chroma boost -> frame fade -> overlay -> present (8-bit clamp). Per-layer rolloff/chroma_boost keep frozen compositor source textures for paused live tuning. +`finish_content_frame` order: HDR display shoulder (fixed constants in `post_fx.py`; not gated on post-FX `enabled`) -> visual-limiter busyness sample ([cleave/viz/visual_limiter.py](cleave/viz/visual_limiter.py)) -> user composite highlight rolloff -> user composite chroma boost -> frame fade -> overlay -> present (8-bit clamp). Per-layer rolloff/chroma_boost keep frozen compositor source textures for paused live tuning. ## Known pitfalls (GlPostProcess) diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index 293a8d0..4ec8ac0 100644 --- a/.cursor/rules/live-tuning-ui.mdc +++ b/.cursor/rules/live-tuning-ui.mdc @@ -52,7 +52,7 @@ Below overlay, **Render: POST FX** (`RowKind.RENDER_POST_FX_HEADER`) is always p **Section lock** (overlay, post-FX, timeline, and layer tracks share one mechanism in [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py)): persisted `locked` on `RenderOverlayConfig` / `RenderPostFxConfig` / `TimelineConfig` and the matching runtimes. **l** on `RENDER_OVERLAY_HEADER`, `RENDER_POST_FX_HEADER`, `RENDER_TIMELINE_HEADER` (or `TRACK_HEADER`) toggles it and draws a red `LOCK_ICON` on the header. When locked: the header still expands/opens and expandable child headers stay navigable, but value and action children are drawn in `LOCKED` and skipped in navigation (`section_locked(state, desc)` with `row_navigable_when_section_locked` / `row_blocked_by_section_lock`); **Ctrl** enable/disable is refused while solo stays allowed. `section_locked` accepts either a `TuningViewState` (tracks / `render_timeline`) or a `TuningSession` (layers / `timeline`). In preset curation mode, `section_locked` always returns false (lock icon, `LOCKED` coloring, navigation skip, and mutation blocks are all ignored) so layer browse and **f** / **b** / **r** stay available. -Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always present. This row is a **panel anchor** (not an expandable section): the timeline strip is hosted separately ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)), not as `RowLayout` children. Eye semantics match post-FX (no solo in v1). **Ctrl+Right** / **Ctrl+Left** sets `session.timeline.enabled`; **Right** opens the timeline strip without entering the submenu; **Left** closes it. **t** toggles the strip: when closed, opens and enters the submenu on row 0; when open, closes and returns focus to this header. Expand arrow reflects `session.timeline.panel_open` (▼ when open). Disable closes the strip; **Right** / **t** can still open it while disabled (same expand-while-disabled semantics as layer and other render headers). State: `RenderTimelineBlock` / `TimelineRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). `enabled` and `locked` persist via config snapshot; staged timeline preset character / crescendo / density / conductor persist under `timeline.preset`; `panel_open` is UI-only. When the strip is open, main-panel children under **Render: TIMELINE** are: song markers (expandable; marker items and **snap to song markers** when expanded), beat / bar grid (expandable; placement snap, bar grid, bar phase, snap to grid), timeline fades (expandable; song markers and standard cues each with enabled/disabled and fade in/out duration when enabled), timeline preset (expandable; character, crescendo, density, conductor, and **apply timeline preset** when expanded), and reset timeline. **snap to song markers** is a green action row inside the song markers section (last row after marker items; Enter opens proximity then layer-scope choice modals). **snap to grid** is a green action row under beat / bar grid (Enter opens BEATS / BARS / CANCEL). When the timeline is locked, those children are blocked and skipped in navigation (expandable headers stay navigable), and the strip stays openable and seekable (Seek Left/Right); Space still toggles transport play/pause (session-only preview), but arm, record, override, number-key visibility, and Ctrl+Space record stay blocked ([cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)); preset/snap/phase controllers also refuse while locked. **l** on the timeline header is refused while `timeline.recording` is true. +Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always present. This row is a **panel anchor** (not an expandable section): the timeline strip is hosted separately ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)), not as `RowLayout` children. Eye semantics match post-FX (no solo in v1). **Ctrl+Right** / **Ctrl+Left** sets `session.timeline.enabled`; **Right** opens the timeline strip without entering the submenu; **Left** closes it. **t** toggles the strip: when closed, opens and enters the submenu on row 0; when open, closes and returns focus to this header. Expand arrow reflects `session.timeline.panel_open` (▼ when open). Disable closes the strip; **Right** / **t** can still open it while disabled (same expand-while-disabled semantics as layer and other render headers). State: `RenderTimelineBlock` / `TimelineRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). `enabled` and `locked` persist via config snapshot; staged timeline preset character / crescendo / density / conductor persist under `timeline.preset`; visual limiter knobs persist under `timeline.limiter`; `panel_open` is UI-only. When the strip is open, main-panel children under **Render: TIMELINE** are: song markers (expandable; marker items and **snap to song markers** when expanded), beat / bar grid (expandable; placement snap, bar grid, bar phase, snap to grid), timeline fades (expandable; song markers and standard cues each with enabled/disabled and fade in/out duration when enabled), timeline preset (expandable; character, crescendo, density, conductor, and **apply timeline preset** when expanded), visual limiter (expandable; Ctrl enable/disable with enabled/disabled value; threshold and release when expanded and enabled), and reset timeline. **snap to song markers** is a green action row inside the song markers section (last row after marker items; Enter opens proximity then layer-scope choice modals). **snap to grid** is a green action row under beat / bar grid (Enter opens BEATS / BARS / CANCEL). When the timeline is locked, those children are blocked and skipped in navigation (expandable headers stay navigable), and the strip stays openable and seekable (Seek Left/Right); Space still toggles transport play/pause (session-only preview), but arm, record, override, number-key visibility, and Ctrl+Space record stay blocked ([cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)); preset/snap/phase controllers also refuse while locked. **l** on the timeline header is refused while `timeline.recording` is true. ## Timeline panel diff --git a/cleave/config.py b/cleave/config.py index f5e440f..6e7b5b4 100644 --- a/cleave/config.py +++ b/cleave/config.py @@ -64,6 +64,9 @@ DEFAULT_TIMELINE_FADE_IN, DEFAULT_TIMELINE_FADE_OUT, DEFAULT_TIMELINE_PLACEMENT_SNAP, + DEFAULT_VISUAL_LIMITER_ENABLED, + DEFAULT_VISUAL_LIMITER_THRESHOLD, + DEFAULT_VISUAL_LIMITER_RELEASE, TimelinePlacementSnap, DEFAULT_HDR_COMPOSITING, DEFAULT_RENDER_FPS, @@ -277,6 +280,15 @@ class TimelinePresetConfig: conductor: bool = DEFAULT_TIMELINE_PRESET_CONDUCTOR +@dataclass(frozen=True) +class TimelineLimiterConfig: + """Live visual limiter knobs under ``timeline.limiter``.""" + + enabled: bool = DEFAULT_VISUAL_LIMITER_ENABLED + threshold: float = DEFAULT_VISUAL_LIMITER_THRESHOLD + release: float = DEFAULT_VISUAL_LIMITER_RELEASE + + @dataclass(frozen=True) class TimelineConfig: enabled: bool @@ -285,6 +297,7 @@ class TimelineConfig: fades: TimelineFadesConfig = field(default_factory=TimelineFadesConfig) placement_snap: TimelinePlacementSnap = DEFAULT_TIMELINE_PLACEMENT_SNAP preset: TimelinePresetConfig = field(default_factory=TimelinePresetConfig) + limiter: TimelineLimiterConfig = field(default_factory=TimelineLimiterConfig) @dataclass diff --git a/cleave/config_schema.py b/cleave/config_schema.py index c911365..65c6f31 100644 --- a/cleave/config_schema.py +++ b/cleave/config_schema.py @@ -353,6 +353,42 @@ def new_layer_config(slot: str, preset: Path, preset_root: Path) -> Any: TIMELINE_FADE_DURATION_MAX = 30.0 TIMELINE_FADE_DURATION_STEP = 0.1 +# Visual limiter (timeline.limiter); module defaults match these. +DEFAULT_VISUAL_LIMITER_ENABLED = True +DEFAULT_VISUAL_LIMITER_THRESHOLD = 0.65 +DEFAULT_VISUAL_LIMITER_RELEASE = 0.45 +VISUAL_LIMITER_THRESHOLD_MIN = 0.40 +VISUAL_LIMITER_THRESHOLD_MAX = 0.90 +VISUAL_LIMITER_THRESHOLD_STEP = 0.01 +VISUAL_LIMITER_THRESHOLD_GAP = 0.17 +VISUAL_LIMITER_RELEASE_MIN = 0.15 +VISUAL_LIMITER_RELEASE_MAX = 1.5 +VISUAL_LIMITER_RELEASE_STEP = 0.1 +# RELEASE_SEC / RELEASE_RAMP_SEC at the tuned defaults (0.75 / 0.45). +VISUAL_LIMITER_RELEASE_HOLD_RATIO = 0.75 / 0.45 + + +def clamp_visual_limiter_threshold(value: float) -> float: + return max( + VISUAL_LIMITER_THRESHOLD_MIN, + min(VISUAL_LIMITER_THRESHOLD_MAX, float(value)), + ) + + +def clamp_visual_limiter_release(value: float) -> float: + return max( + VISUAL_LIMITER_RELEASE_MIN, + min(VISUAL_LIMITER_RELEASE_MAX, float(value)), + ) + + +def visual_limiter_threshold_off(threshold_on: float) -> float: + return max(0.0, float(threshold_on) - VISUAL_LIMITER_THRESHOLD_GAP) + + +def visual_limiter_release_hold_sec(release_ramp_sec: float) -> float: + return float(release_ramp_sec) * VISUAL_LIMITER_RELEASE_HOLD_RATIO + TimelinePlacementSnap = Literal["off", "beat", "bar"] TIMELINE_PLACEMENT_SNAP_OPTIONS: tuple[TimelinePlacementSnap, ...] = ( "off", @@ -2118,6 +2154,31 @@ def _parse_timeline_preset(raw: Any) -> Any: ) +def _parse_timeline_limiter(raw: Any) -> Any: + from cleave.config import TimelineLimiterConfig + + if raw is None: + return TimelineLimiterConfig() + limiter_map = as_mapping(raw, "timeline.limiter") + return TimelineLimiterConfig( + enabled=bool( + limiter_map.get("enabled", DEFAULT_VISUAL_LIMITER_ENABLED) + ), + threshold=clamp_visual_limiter_threshold( + require_non_negative_number( + limiter_map.get("threshold", DEFAULT_VISUAL_LIMITER_THRESHOLD), + "timeline.limiter.threshold", + ) + ), + release=clamp_visual_limiter_release( + require_non_negative_number( + limiter_map.get("release", DEFAULT_VISUAL_LIMITER_RELEASE), + "timeline.limiter.release", + ) + ), + ) + + def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None: from cleave.config import TimelineConfig, TimelineFadesConfig @@ -2147,6 +2208,7 @@ def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None: ), ) preset = _parse_timeline_preset(timeline_map.get("preset")) + limiter = _parse_timeline_limiter(timeline_map.get("limiter")) # Legacy timeline.cues is ignored (clean break; no migration). lanes_raw = timeline_map.get("lanes") if lanes_raw is None: @@ -2157,6 +2219,7 @@ def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None: fades=fades, placement_snap=placement_snap, preset=preset, + limiter=limiter, ) lanes_map = as_mapping(lanes_raw, "timeline.lanes") if ctx.layer_slots is None: @@ -2226,6 +2289,7 @@ def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None: fades=fades, placement_snap=placement_snap, preset=preset, + limiter=limiter, ) @@ -2237,6 +2301,14 @@ def _persist_timeline_fade_group(group: Any) -> dict[str, Any]: } +def _persist_timeline_limiter(limiter: Any) -> dict[str, Any]: + return { + "enabled": limiter.enabled, + "threshold": limiter.threshold, + "release": limiter.release, + } + + def persist_timeline(ctx: PersistCtx) -> dict[str, Any]: runtime = ctx.session.timeline out: dict[str, Any] = { @@ -2253,6 +2325,7 @@ def persist_timeline(ctx: PersistCtx) -> dict[str, Any]: "density": runtime.timeline_preset_density, "conductor": runtime.timeline_preset_conductor, }, + "limiter": _persist_timeline_limiter(runtime.limiter), } lanes_out: dict[str, Any] = {} for slot in sorted(runtime.lanes): diff --git a/cleave/timeline.py b/cleave/timeline.py index 52c4df3..0735b16 100644 --- a/cleave/timeline.py +++ b/cleave/timeline.py @@ -152,6 +152,20 @@ def lane_blend_at(lane: TimelineLane, t_sec: float) -> BlendMode | None: return blend +def lane_role_at(lane: TimelineLane, t_sec: float) -> CueRole | None: + """Held role at ``t_sec``: last cue's ``role`` at or before ``t``. + + ``None`` means no cast (limiter treats unset as ``pulse``). A later cue may + set ``role`` back to ``None`` to clear. + """ + role: CueRole | None = None + for cue in lane.cues: + if cue.t > t_sec: + break + role = cue.role + return role + + def lane_level_segments( lane: TimelineLane, duration_sec: float, diff --git a/cleave/viz/app.py b/cleave/viz/app.py index c4bdf78..a13073b 100644 --- a/cleave/viz/app.py +++ b/cleave/viz/app.py @@ -48,6 +48,7 @@ dispatch_should_notify_overlay, ) from cleave.viz.user_presets import cleanup_unreferenced_user_presets +from cleave.viz.visual_limiter import VisualLimiterState, apply_visual_limiter_gains from cleave.viz.wiring import LayerManager, make_timeline_controls, make_tuning_controls @@ -82,6 +83,9 @@ class VisualizerCore: layers_by_slot: dict[str, StemLayer] compositor: GlCompositor post_process: GlPostProcess + visual_limiter: VisualLimiterState = field( + default_factory=VisualLimiterState, kw_only=True + ) @dataclass @@ -372,6 +376,7 @@ def tick_frame_core( was_paused = paused apply_layer_visibility(runtime.seed.session, runtime.layers_by_slot, t_sec) + apply_visual_limiter_gains(runtime, blank_visualizers=blank_visualizers) if not paused and not blank_visualizers: advance_timeline_preset_switching( diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index 1cbb634..db56a2c 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -1318,6 +1318,20 @@ def _set_timeline_presets_expanded(self, expanded: bool) -> None: return tl.timeline_presets_expanded = expanded + def _set_visual_limiter_expanded(self, expanded: bool) -> None: + tl = self.session.timeline + if tl.visual_limiter_expanded == expanded: + return + tl.visual_limiter_expanded = expanded + + def _set_visual_limiter_enabled(self, enabled: bool) -> None: + lim = self.session.timeline.limiter + if lim.enabled == enabled: + return + lim.enabled = enabled + # Match render-header disable: collapse when off; open knobs when on. + self.session.timeline.visual_limiter_expanded = enabled + def _set_beat(self, slot: str, value: float) -> None: layer = self.session.layers[slot] layer.beat_sensitivity = clamp_beat_sensitivity(value) diff --git a/cleave/viz/frame_finish.py b/cleave/viz/frame_finish.py index 15d88c2..2297849 100644 --- a/cleave/viz/frame_finish.py +++ b/cleave/viz/frame_finish.py @@ -2,8 +2,9 @@ After layer compositing, both paths run the same sequence: HDR display shoulder (when ``render.hdr_compositing`` is enabled and not in preset -curation), optional user highlight rolloff, post-FX fade, render overlay -composite, then present to the display framebuffer. +curation), visual-limiter busyness sample, optional user highlight rolloff, +chroma boost, post-FX fade, render overlay composite, then present to the +display framebuffer. When ``cfg.render`` is absent, overlay resolution matches live WYSIWYG: ``render_overlay_base(cfg)`` falls back to ``default_render_overlay_config()``, @@ -43,6 +44,7 @@ panel_surface_key, ) from cleave.viz.session import TuningSession +from cleave.viz.visual_limiter import observe_frame_busyness @dataclass @@ -133,6 +135,8 @@ def finish_content_frame( compositor.content_height, ) + observe_frame_busyness(core, t_sec, session) + pp = session.render_post_fx sections_on = render_sections_active(session) hr = pp.highlight_rolloff diff --git a/cleave/viz/layer.py b/cleave/viz/layer.py index 6664cc8..44add85 100644 --- a/cleave/viz/layer.py +++ b/cleave/viz/layer.py @@ -22,6 +22,7 @@ class StemLayer: projectm_playlist: ProjectMPlaylist | None = None auto_preset_path: Path | None = None timeline_level: float = 1.0 + limiter_gain: float = 1.0 preset_rotation: PresetRotation | None = None role_rotations: dict[CueRole, PresetRotation] = field(default_factory=dict) timeline_switch_count: int = 0 diff --git a/cleave/viz/layer_pipeline.py b/cleave/viz/layer_pipeline.py index fbc3cc1..7948ab1 100644 --- a/cleave/viz/layer_pipeline.py +++ b/cleave/viz/layer_pipeline.py @@ -75,7 +75,7 @@ def apply_effect_modifiers( if not layer.fbo.enabled: continue mod = modifiers[slot] - layer.fbo.opacity = mod.opacity * layer.timeline_level + layer.fbo.opacity = mod.opacity * layer.timeline_level * layer.limiter_gain layer.fbo.flash_alpha = mod.flash_alpha layer.fbo.bloom_strength = mod.bloom_strength layer.fbo.hue_rgb = mod.hue_rgb diff --git a/cleave/viz/layer_visibility.py b/cleave/viz/layer_visibility.py index 412b211..ca417a7 100644 --- a/cleave/viz/layer_visibility.py +++ b/cleave/viz/layer_visibility.py @@ -220,7 +220,7 @@ def _as_fade_group(group: TimelineFadeGroupRuntime) -> TimelineFadeGroup: ) -def _timeline_levels_apply(session: TuningSession, slot: str) -> bool: +def timeline_levels_apply(session: TuningSession, slot: str) -> bool: """True when the timeline level envelope drives FBO enable/opacity for *slot*.""" from cleave.viz.editor_mode_controls import is_preset_curation_mode @@ -276,7 +276,7 @@ def apply_layer_visibility( t_sec: float, ) -> None: for slot, layer in layers_by_slot.items(): - if _timeline_levels_apply(session, slot): + if timeline_levels_apply(session, slot): level = timeline_level_multiplier(session, slot, t_sec) layer.timeline_level = level layer.fbo.enabled = level > LEVEL_EPS diff --git a/cleave/viz/row_fields.py b/cleave/viz/row_fields.py index 77f0c18..349478a 100644 --- a/cleave/viz/row_fields.py +++ b/cleave/viz/row_fields.py @@ -15,7 +15,11 @@ from cleave.config_schema import ( TIMELINE_FADE_DURATION_STEP, + VISUAL_LIMITER_RELEASE_STEP, + VISUAL_LIMITER_THRESHOLD_STEP, clamp_timeline_fade_duration, + clamp_visual_limiter_release, + clamp_visual_limiter_threshold, cycle_timeline_placement_snap, hard_cut_enabled_display, preset_start_clean_display, @@ -273,6 +277,70 @@ def _apply_timeline_preset_conductor( ) +def _format_visual_limiter_enabled( + state: TuningViewState, _desc: RowDescriptor +) -> str: + return hard_cut_enabled_display(state.render_timeline.limiter.enabled) + + +def _apply_visual_limiter_header( + controls: TuningControls, + desc: RowDescriptor, + forward: bool, + ctrl: bool, + _shift: bool, +) -> None: + if ctrl: + if ( + controls.session.timeline.locked + and row_behavior(desc.kind).can_enable_disable + ): + return + controls._set_visual_limiter_enabled(forward) + return + apply_expand_toggle(controls, desc.kind, desc.slot, forward) + + +def _format_visual_limiter_threshold( + state: TuningViewState, _desc: RowDescriptor +) -> str: + return f"{int(round(state.render_timeline.limiter.threshold * 100))}%" + + +def _apply_visual_limiter_threshold( + controls: TuningControls, + _desc: RowDescriptor, + forward: bool, + _ctrl: bool, + _shift: bool, +) -> None: + del _ctrl, _shift + lim = controls.session.timeline.limiter + delta = VISUAL_LIMITER_THRESHOLD_STEP if forward else -VISUAL_LIMITER_THRESHOLD_STEP + lim.threshold = clamp_visual_limiter_threshold( + round(lim.threshold + delta, 2) + ) + + +def _format_visual_limiter_release( + state: TuningViewState, _desc: RowDescriptor +) -> str: + return f"{state.render_timeline.limiter.release:.1f}s" + + +def _apply_visual_limiter_release( + controls: TuningControls, + _desc: RowDescriptor, + forward: bool, + _ctrl: bool, + _shift: bool, +) -> None: + del _ctrl, _shift + lim = controls.session.timeline.limiter + delta = VISUAL_LIMITER_RELEASE_STEP if forward else -VISUAL_LIMITER_RELEASE_STEP + lim.release = clamp_visual_limiter_release(round(lim.release + delta, 1)) + + def _format_timeline_song_marker_fades_enabled( state: TuningViewState, _desc: RowDescriptor ) -> str: @@ -1705,6 +1773,24 @@ def _apply_transport( panel_label="apply timeline preset", present_style=RowPresentStyle.FULL_LINE, ), + RowKind.TIMELINE_VISUAL_LIMITER_HEADER: RowFieldDef( + panel_label="visual limiter", + present_style=RowPresentStyle.EXPAND_SUBHEADER, + format_value=_format_visual_limiter_enabled, + apply_horizontal=_apply_visual_limiter_header, + ), + RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD: RowFieldDef( + panel_label="threshold", + present_style=RowPresentStyle.LABELED_VALUE, + format_value=_format_visual_limiter_threshold, + apply_horizontal=_apply_visual_limiter_threshold, + ), + RowKind.TIMELINE_VISUAL_LIMITER_RELEASE: RowFieldDef( + panel_label="release", + present_style=RowPresentStyle.LABELED_VALUE, + format_value=_format_visual_limiter_release, + apply_horizontal=_apply_visual_limiter_release, + ), RowKind.TIMELINE_RESET: RowFieldDef( panel_label="reset timeline", present_style=RowPresentStyle.FULL_LINE, diff --git a/cleave/viz/row_sections.py b/cleave/viz/row_sections.py index 9debf17..265ecf3 100644 --- a/cleave/viz/row_sections.py +++ b/cleave/viz/row_sections.py @@ -127,6 +127,16 @@ def _timeline_presets_expanded(state: TuningViewState, _slot: str | None) -> boo return state.render_timeline.timeline_presets_expanded +def _toggle_visual_limiter( + controls: TuningControls, _slot: str | None, forward: bool +) -> None: + controls._set_visual_limiter_expanded(forward) + + +def _visual_limiter_expanded(state: TuningViewState, _slot: str | None) -> bool: + return state.render_timeline.visual_limiter_expanded + + def _open_timeline_panel(controls: TuningControls, forward: bool) -> None: if forward: controls._open_timeline_panel() @@ -707,6 +717,32 @@ def _timeline_standard_cue_fades_enabled( ) +def _visual_limiter_enabled( + state: TuningViewState, _desc: RowDescriptor +) -> bool: + return state.render_timeline.limiter.enabled + + +TIMELINE_VISUAL_LIMITER_ACTIVE = ConditionalRowsDef( + name="visual_limiter_enabled", + predicate=_visual_limiter_enabled, + children=( + SectionNode(leaf_kind=RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD), + SectionNode(leaf_kind=RowKind.TIMELINE_VISUAL_LIMITER_RELEASE), + ), +) + +TIMELINE_VISUAL_LIMITER_SECTION = ExpandSectionDef( + header_kind=RowKind.TIMELINE_VISUAL_LIMITER_HEADER, + context="global", + read_expanded=_visual_limiter_expanded, + toggle=_toggle_visual_limiter, + children=( + SectionNode(conditional=TIMELINE_VISUAL_LIMITER_ACTIVE), + ), +) + + def _collect_expand_sections( *roots: ExpandSectionDef, extra_nodes: tuple[SectionNode, ...] = (), @@ -741,6 +777,7 @@ def walk_nodes(nodes: tuple[SectionNode, ...]) -> None: BEAT_BAR_GRID_SECTION, TIMELINE_FADES_SECTION, TIMELINE_PRESETS_SECTION, + TIMELINE_VISUAL_LIMITER_SECTION, extra_nodes=RENDER_SECTION_NODES, ) @@ -850,6 +887,9 @@ def kinds_in_expand_section(section: ExpandSectionDef) -> frozenset[RowKind]: RowKind.TIMELINE_PRESET_DENSITY, RowKind.TIMELINE_PRESET_CONDUCTOR, RowKind.TIMELINE_PRESETS, + RowKind.TIMELINE_VISUAL_LIMITER_HEADER, + RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD, + RowKind.TIMELINE_VISUAL_LIMITER_RELEASE, RowKind.TIMELINE_RESET, RowKind.TIMELINE_BEAT_BAR_GRID_HEADER, RowKind.TIMELINE_PLACEMENT_SNAP, @@ -912,10 +952,13 @@ def _build_row_tree_indent_depth() -> dict[RowKind, int]: _assign_expand_indent_depth(depths, BEAT_BAR_GRID_SECTION, 1) _assign_expand_indent_depth(depths, TIMELINE_FADES_SECTION, 1) _assign_expand_indent_depth(depths, TIMELINE_PRESETS_SECTION, 1) + _assign_expand_indent_depth(depths, TIMELINE_VISUAL_LIMITER_SECTION, 1) depths[RowKind.TIMELINE_SONG_MARKER_FADE_IN] = 3 depths[RowKind.TIMELINE_SONG_MARKER_FADE_OUT] = 3 depths[RowKind.TIMELINE_STANDARD_CUE_FADE_IN] = 3 depths[RowKind.TIMELINE_STANDARD_CUE_FADE_OUT] = 3 + depths[RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD] = 2 + depths[RowKind.TIMELINE_VISUAL_LIMITER_RELEASE] = 2 return depths @@ -1033,6 +1076,9 @@ def append_render_section_rows( append_expand_section_rows(row_list, BEAT_BAR_GRID_SECTION, state) append_expand_section_rows(row_list, TIMELINE_FADES_SECTION, state) append_expand_section_rows(row_list, TIMELINE_PRESETS_SECTION, state) + append_expand_section_rows( + row_list, TIMELINE_VISUAL_LIMITER_SECTION, state + ) row_list.append(RowDescriptor(RowKind.TIMELINE_RESET)) @@ -1085,9 +1131,11 @@ def _build_section_header_parent_map() -> dict[RowKind, RowKind]: _walk_expand_section_for_headers(BEAT_BAR_GRID_SECTION, out) _walk_expand_section_for_headers(TIMELINE_FADES_SECTION, out) _walk_expand_section_for_headers(TIMELINE_PRESETS_SECTION, out) + _walk_expand_section_for_headers(TIMELINE_VISUAL_LIMITER_SECTION, out) out[RowKind.TIMELINE_SNAP_TO_SONG_MARKERS] = RowKind.SONG_MARKERS_HEADER out[RowKind.TIMELINE_FADES_HEADER] = RowKind.RENDER_TIMELINE_HEADER out[RowKind.TIMELINE_PRESETS_HEADER] = RowKind.RENDER_TIMELINE_HEADER + out[RowKind.TIMELINE_VISUAL_LIMITER_HEADER] = RowKind.RENDER_TIMELINE_HEADER return out diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index 72c6593..954a321 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -89,6 +89,9 @@ class RowKind(Enum): TIMELINE_PRESET_DENSITY = auto() TIMELINE_PRESET_CONDUCTOR = auto() TIMELINE_PRESETS = auto() + TIMELINE_VISUAL_LIMITER_HEADER = auto() + TIMELINE_VISUAL_LIMITER_THRESHOLD = auto() + TIMELINE_VISUAL_LIMITER_RELEASE = auto() TIMELINE_RESET = auto() TIMELINE_BEAT_BAR_GRID_HEADER = auto() TIMELINE_BAR_PHASE = auto() @@ -815,6 +818,50 @@ class RowBehavior: "This overwrites the current timeline.", ), ), + RowKind.TIMELINE_VISUAL_LIMITER_HEADER: RowBehavior( + RowAffordance.EXPAND, + is_sub_header=True, + can_enable_disable=True, + help_title="Visual limiter", + help_entries=( + ("Left/Right", "collapse / expand"), + ("Ctrl + Left/Right", "disable / enable"), + ), + help_description=( + "Duck busy stacked layers using post-composite busyness.", + "When enabled and expanded, threshold and release are shown below.", + ), + ), + RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD: RowBehavior( + RowAffordance.VALUE_STEP, + navigable=True, + repeatable=True, + blocked_by_section_lock=True, + help_title="Visual limiter threshold", + help_entries=( + ("Left", "decrease threshold"), + ("Right", "increase threshold"), + ), + help_description=( + "Busyness level that starts ducking hot layers.", + "Off-threshold stays a fixed gap below this value.", + ), + ), + RowKind.TIMELINE_VISUAL_LIMITER_RELEASE: RowBehavior( + RowAffordance.VALUE_STEP, + navigable=True, + repeatable=True, + blocked_by_section_lock=True, + help_title="Visual limiter release", + help_entries=( + ("Left", "decrease release ramp"), + ("Right", "increase release ramp"), + ), + help_description=( + "Playhead seconds to ramp ducked layers back to full opacity.", + "Release hold before the ramp scales with this value.", + ), + ), RowKind.TIMELINE_RESET: RowBehavior( RowAffordance.ACTION, navigable=True, diff --git a/cleave/viz/session.py b/cleave/viz/session.py index 2db277f..e807778 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -12,6 +12,7 @@ RenderOverlayPosition, RenderOverlaySlideDirection, TimelineFadeGroupConfig, + TimelineLimiterConfig, VIZ_CONFIG_FILENAME, ) from cleave.config_schema import ( @@ -31,6 +32,9 @@ DEFAULT_TIMELINE_FADE_IN, DEFAULT_TIMELINE_FADE_OUT, DEFAULT_TIMELINE_PLACEMENT_SNAP, + DEFAULT_VISUAL_LIMITER_ENABLED, + DEFAULT_VISUAL_LIMITER_THRESHOLD, + DEFAULT_VISUAL_LIMITER_RELEASE, HighlightRolloffApplyMode, HighlightRolloffCurve, PresetSwitchingMode, @@ -172,6 +176,17 @@ def default_timeline_fade_group_runtime() -> TimelineFadeGroupRuntime: return TimelineFadeGroupRuntime() +@dataclass +class VisualLimiterRuntime: + enabled: bool = DEFAULT_VISUAL_LIMITER_ENABLED + threshold: float = DEFAULT_VISUAL_LIMITER_THRESHOLD + release: float = DEFAULT_VISUAL_LIMITER_RELEASE + + +def default_visual_limiter_runtime() -> VisualLimiterRuntime: + return VisualLimiterRuntime() + + @dataclass class TimelineRuntime: enabled: bool = True @@ -199,6 +214,7 @@ class TimelineRuntime: placement_snap: TimelinePlacementSnap = DEFAULT_TIMELINE_PLACEMENT_SNAP fades_expanded: bool = False timeline_presets_expanded: bool = False + visual_limiter_expanded: bool = False timeline_preset_kind: str = DEFAULT_TIMELINE_PRESET_KIND timeline_preset_crescendo: CrescendoTarget | None = None timeline_preset_density: TimelinePresetDensity = DEFAULT_TIMELINE_PRESET_DENSITY @@ -209,6 +225,7 @@ class TimelineRuntime: standard_cue_fades: TimelineFadeGroupRuntime = field( default_factory=default_timeline_fade_group_runtime ) + limiter: VisualLimiterRuntime = field(default_factory=default_visual_limiter_runtime) def default_timeline_runtime() -> TimelineRuntime: @@ -370,6 +387,18 @@ def _fade_group_runtime_from_cfg( ) +def _limiter_runtime_from_cfg( + limiter: TimelineLimiterConfig | None, +) -> VisualLimiterRuntime: + if limiter is None: + return VisualLimiterRuntime() + return VisualLimiterRuntime( + enabled=limiter.enabled, + threshold=limiter.threshold, + release=limiter.release, + ) + + def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime: timeline = cfg.timeline enabled = True if timeline is None else timeline.enabled @@ -382,6 +411,7 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime: else timeline.placement_snap ) preset = None if timeline is None else timeline.preset + limiter_cfg = None if timeline is None else timeline.limiter preset_kind = ( DEFAULT_TIMELINE_PRESET_KIND if preset is None else preset.character ) @@ -398,6 +428,7 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime: lanes[slot] = copy_lane(source_lanes[slot]) else: lanes[slot] = empty_lane() + limiter = _limiter_runtime_from_cfg(limiter_cfg) if fades is None: return TimelineRuntime( enabled=enabled, @@ -408,6 +439,7 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime: timeline_preset_crescendo=preset_crescendo, timeline_preset_density=preset_density, timeline_preset_conductor=preset_conductor, + limiter=limiter, ) return TimelineRuntime( enabled=enabled, @@ -420,6 +452,7 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime: timeline_preset_conductor=preset_conductor, song_marker_fades=_fade_group_runtime_from_cfg(fades.song_markers), standard_cue_fades=_fade_group_runtime_from_cfg(fades.standard), + limiter=limiter, ) diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index b8d885c..f576a34 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -34,6 +34,9 @@ DEFAULT_UI_FADE_SEC, DEFAULT_UI_WIDTH, DEFAULT_UI_WIDTH_MODE, + DEFAULT_VISUAL_LIMITER_ENABLED, + DEFAULT_VISUAL_LIMITER_THRESHOLD, + DEFAULT_VISUAL_LIMITER_RELEASE, default_render_overlay_runtime_values, default_render_post_fx_runtime_values, ) @@ -164,6 +167,13 @@ class TimelineFadeGroupBlock: fade_out: float = 2.0 +@dataclass +class VisualLimiterBlock: + enabled: bool = DEFAULT_VISUAL_LIMITER_ENABLED + threshold: float = DEFAULT_VISUAL_LIMITER_THRESHOLD + release: float = DEFAULT_VISUAL_LIMITER_RELEASE + + @dataclass class RenderTimelineBlock: enabled: bool = False @@ -174,6 +184,7 @@ class RenderTimelineBlock: placement_snap: str = "beat" fades_expanded: bool = False timeline_presets_expanded: bool = False + visual_limiter_expanded: bool = False timeline_preset_kind: str = "breathing" timeline_preset_crescendo: CrescendoTarget | None = None timeline_preset_density: TimelinePresetDensity = DEFAULT_TIMELINE_PRESET_DENSITY @@ -184,6 +195,7 @@ class RenderTimelineBlock: standard_cue_fades: TimelineFadeGroupBlock = field( default_factory=TimelineFadeGroupBlock ) + limiter: VisualLimiterBlock = field(default_factory=VisualLimiterBlock) locked: bool = False song_markers_expanded: bool = False song_marker_times: tuple[float, ...] = () @@ -369,8 +381,10 @@ def view_state_structure_signature( "beat_bar_grid_expanded": tl.beat_bar_grid_expanded, "fades_expanded": tl.fades_expanded, "timeline_presets_expanded": tl.timeline_presets_expanded, + "visual_limiter_expanded": tl.visual_limiter_expanded, "song_marker_fades_enabled": tl.song_marker_fades.enabled, "standard_cue_fades_enabled": tl.standard_cue_fades.enabled, + "visual_limiter_enabled": tl.limiter.enabled, }, "timeline": {"enabled": tl.enabled}, } @@ -536,6 +550,7 @@ def _build_structure( placement_snap=tl.placement_snap, fades_expanded=tl.fades_expanded, timeline_presets_expanded=tl.timeline_presets_expanded, + visual_limiter_expanded=tl.visual_limiter_expanded, timeline_preset_kind=tl.timeline_preset_kind, timeline_preset_crescendo=tl.timeline_preset_crescendo, timeline_preset_density=tl.timeline_preset_density, @@ -550,6 +565,11 @@ def _build_structure( fade_in=tl.standard_cue_fades.fade_in, fade_out=tl.standard_cue_fades.fade_out, ), + limiter=VisualLimiterBlock( + enabled=tl.limiter.enabled, + threshold=tl.limiter.threshold, + release=tl.limiter.release, + ), song_markers_expanded=self.session.song_markers.expanded, song_marker_times=tuple(self.session.song_markers.times), ) @@ -718,6 +738,7 @@ def build( placement_snap=tl.placement_snap, fades_expanded=tl.fades_expanded, timeline_presets_expanded=tl.timeline_presets_expanded, + visual_limiter_expanded=tl.visual_limiter_expanded, timeline_preset_kind=tl.timeline_preset_kind, timeline_preset_crescendo=tl.timeline_preset_crescendo, timeline_preset_density=tl.timeline_preset_density, @@ -732,6 +753,11 @@ def build( fade_in=tl.standard_cue_fades.fade_in, fade_out=tl.standard_cue_fades.fade_out, ), + limiter=VisualLimiterBlock( + enabled=tl.limiter.enabled, + threshold=tl.limiter.threshold, + release=tl.limiter.release, + ), locked=tl.locked, song_markers_expanded=self.session.song_markers.expanded, song_marker_times=tuple(self.session.song_markers.times), diff --git a/cleave/viz/visual_limiter.py b/cleave/viz/visual_limiter.py new file mode 100644 index 0000000..2703317 --- /dev/null +++ b/cleave/viz/visual_limiter.py @@ -0,0 +1,371 @@ +"""Closed-loop visual limiter: duck lowest-priority hot layers when busy. + +When enabled and timeline levels apply, measures post-composite busyness +(mean luma + frame delta) after the HDR display shoulder, decides ducks for +the next frame, and multiplies ``StemLayer.limiter_gain`` into opacity. +Persisted knobs live under ``timeline.limiter``; attack / duck / delta stay +fixed module constants. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +import numpy as np +from OpenGL.GL import ( + GL_FLOAT, + GL_FRAMEBUFFER, + GL_RGBA, + GL_UNSIGNED_BYTE, + glBindFramebuffer, + glReadPixels, +) + +from cleave.config_schema import ( + DEFAULT_VISUAL_LIMITER_RELEASE, + DEFAULT_VISUAL_LIMITER_THRESHOLD, + visual_limiter_release_hold_sec, + visual_limiter_threshold_off, +) +from cleave.cue_roles import CueRole +from cleave.gl_color_format import RGBA16F +from cleave.timeline import LEVEL_EPS, empty_lane, lane_role_at +from cleave.viz.editor_mode_controls import is_preset_curation_mode +from cleave.viz.layer_visibility import timeline_levels_apply + +if TYPE_CHECKING: + from cleave.gl_compositor import GlCompositor + from cleave.viz.app import VisualizerCore + from cleave.viz.layer import StemLayer + from cleave.viz.session import TuningSession, VisualLimiterRuntime + +# Busyness = mean_luma + DELTA_WEIGHT * mean_abs_delta (both in [0, 1]). +# ON / OFF / release defaults match schema; runtime values come from session. +THRESHOLD_ON = DEFAULT_VISUAL_LIMITER_THRESHOLD +THRESHOLD_OFF = visual_limiter_threshold_off(THRESHOLD_ON) +DELTA_WEIGHT = 0.85 +DUCK_GAIN = 0.50 +RELEASE_RAMP_SEC = DEFAULT_VISUAL_LIMITER_RELEASE +RELEASE_SEC = visual_limiter_release_hold_sec(RELEASE_RAMP_SEC) +ATTACK_SEC = 0.15 +SEEK_JUMP_SEC = 0.25 +GRID_WIDTH = 32 +GRID_HEIGHT = 18 + +_LUMA_R = 0.2126 +_LUMA_G = 0.7152 +_LUMA_B = 0.0722 + +# Lower rank is ducked first: bed < accent < pulse < lead. +_ROLE_RANK: dict[CueRole, int] = { + "bed": 0, + "accent": 1, + "pulse": 2, + "lead": 3, +} +_DEFAULT_ROLE_RANK = _ROLE_RANK["pulse"] + + +@dataclass(frozen=True) +class VisualLimiterParams: + threshold_on: float = THRESHOLD_ON + threshold_off: float = THRESHOLD_OFF + release_ramp_sec: float = RELEASE_RAMP_SEC + release_sec: float = RELEASE_SEC + + @classmethod + def from_runtime(cls, limiter: VisualLimiterRuntime) -> VisualLimiterParams: + ramp = float(limiter.release) + return cls( + threshold_on=float(limiter.threshold), + threshold_off=visual_limiter_threshold_off(limiter.threshold), + release_ramp_sec=ramp, + release_sec=visual_limiter_release_hold_sec(ramp), + ) + + +@dataclass(frozen=True) +class HotLayerRef: + slot: str + role_rank: int + timeline_level: float + z_index: int + + +@dataclass +class VisualLimiterState: + prev_luma: np.ndarray | None = None + gains: dict[str, float] = field(default_factory=dict) + under_off_since: float | None = None + last_t_sec: float | None = None + controller_t_sec: float | None = None + + def reset(self) -> None: + self.prev_luma = None + self.gains.clear() + self.under_off_since = None + self.last_t_sec = None + self.controller_t_sec = None + + def gain_for(self, slot: str) -> float: + return self.gains.get(slot, 1.0) + + def apply_to_layers(self, layers_by_slot: dict[str, StemLayer]) -> None: + for slot, layer in layers_by_slot.items(): + layer.limiter_gain = self.gain_for(slot) + + def clear_layer_gains(self, layers_by_slot: dict[str, StemLayer]) -> None: + for layer in layers_by_slot.values(): + layer.limiter_gain = 1.0 + + +def visual_limiter_active(session: TuningSession) -> bool: + """True when the limiter may duck layers (timeline levels driving opacity).""" + if is_preset_curation_mode(session): + return False + if session.solo_slot is not None: + return False + tl = session.timeline + if not tl.enabled: + return False + if not tl.limiter.enabled: + return False + if tl.recording or tl.preview_active: + return False + return True + + +def role_rank(role: CueRole | None) -> int: + if role is None: + return _DEFAULT_ROLE_RANK + return _ROLE_RANK.get(role, _DEFAULT_ROLE_RANK) + + +def busyness(mean_luma: float, mean_abs_delta: float) -> float: + return float(mean_luma) + DELTA_WEIGHT * float(mean_abs_delta) + + +def pick_victim( + hot: list[HotLayerRef], + gains: dict[str, float], +) -> str | None: + """Lowest-priority hot layer still above the duck floor.""" + candidates = [ + layer + for layer in hot + if gains.get(layer.slot, 1.0) > DUCK_GAIN + LEVEL_EPS + ] + if not candidates: + return None + chosen = min( + candidates, + key=lambda layer: (layer.role_rank, layer.timeline_level, layer.z_index), + ) + return chosen.slot + + +def collect_hot_layers( + session: TuningSession, + layers_by_slot: dict[str, StemLayer], + t_sec: float, +) -> list[HotLayerRef]: + hot: list[HotLayerRef] = [] + for z_index, slot in enumerate(session.layer_z_order): + if not timeline_levels_apply(session, slot): + continue + layer = layers_by_slot.get(slot) + if layer is None or layer.timeline_level <= LEVEL_EPS: + continue + lane = session.timeline.lanes.get(slot) or empty_lane() + role = lane_role_at(lane, t_sec) + hot.append( + HotLayerRef( + slot=slot, + role_rank=role_rank(role), + timeline_level=float(layer.timeline_level), + z_index=z_index, + ) + ) + return hot + + +def reset_if_seek(state: VisualLimiterState, t_sec: float) -> bool: + """Clear limiter memory on large playhead jumps. Returns True when reset.""" + jumped = ( + state.last_t_sec is not None + and abs(t_sec - state.last_t_sec) > SEEK_JUMP_SEC + ) + if jumped: + state.reset() + state.last_t_sec = t_sec + return jumped + + +def _playhead_dt(state: VisualLimiterState, t_sec: float) -> float: + prev = state.controller_t_sec + state.controller_t_sec = t_sec + if prev is None: + return 0.0 + return max(0.0, t_sec - prev) + + +def _ramp_gain_toward(current: float, target: float, dt: float, duration_sec: float) -> float: + """Move ``current`` toward ``target`` over ``duration_sec`` of playhead time.""" + if duration_sec <= LEVEL_EPS or dt <= LEVEL_EPS: + return current + span = abs(1.0 - DUCK_GAIN) + if span <= LEVEL_EPS: + return target + step = span * (dt / duration_sec) + if current > target: + return max(target, current - step) + return min(target, current + step) + + +def _ramp_release( + state: VisualLimiterState, dt: float, *, release_ramp_sec: float +) -> None: + finished: list[str] = [] + for slot, gain in state.gains.items(): + new_gain = _ramp_gain_toward(gain, 1.0, dt, release_ramp_sec) + if new_gain >= 1.0 - LEVEL_EPS: + finished.append(slot) + else: + state.gains[slot] = new_gain + for slot in finished: + del state.gains[slot] + if not state.gains: + state.under_off_since = None + + +def update_limiter_state( + state: VisualLimiterState, + *, + mean_luma: float, + mean_abs_delta: float, + t_sec: float, + hot: list[HotLayerRef], + params: VisualLimiterParams | None = None, +) -> None: + """Pure controller step from measured busyness and hot-layer refs.""" + knobs = params if params is not None else VisualLimiterParams() + dt = _playhead_dt(state, t_sec) + score = busyness(mean_luma, mean_abs_delta) + if score > knobs.threshold_on: + state.under_off_since = None + victim = pick_victim(hot, state.gains) + if victim is not None: + current = state.gains.get(victim, 1.0) + new_gain = _ramp_gain_toward(current, DUCK_GAIN, dt, ATTACK_SEC) + if new_gain < 1.0 - LEVEL_EPS: + state.gains[victim] = new_gain + return + + if score < knobs.threshold_off: + if state.under_off_since is None: + state.under_off_since = t_sec + elif t_sec - state.under_off_since >= knobs.release_sec: + _ramp_release(state, dt, release_ramp_sec=knobs.release_ramp_sec) + return + + state.under_off_since = None + + +def read_luma_grid( + compositor: GlCompositor, + *, + grid_width: int = GRID_WIDTH, + grid_height: int = GRID_HEIGHT, +) -> np.ndarray: + """Downsampled display-referred luma grid from the content FBO.""" + width = compositor.content_width + height = compositor.content_height + if width <= 0 or height <= 0: + return np.zeros((grid_height, grid_width), dtype=np.float32) + + xs = np.linspace(0, width - 1, grid_width, dtype=np.int32) + ys = np.linspace(0, height - 1, grid_height, dtype=np.int32) + hdr = compositor.color_format is RGBA16F + glBindFramebuffer(GL_FRAMEBUFFER, compositor.content_fbo_id) + + grid = np.empty((grid_height, grid_width), dtype=np.float32) + for row_i, y in enumerate(ys): + if hdr: + raw = glReadPixels(0, int(y), width, 1, GL_RGBA, GL_FLOAT) + rgba = np.frombuffer(raw, dtype=np.float32).reshape(width, 4) + rgb = np.clip(rgba[:, :3], 0.0, 1.0) + else: + raw = glReadPixels(0, int(y), width, 1, GL_RGBA, GL_UNSIGNED_BYTE) + rgba = np.frombuffer(raw, dtype=np.uint8).reshape(width, 4) + rgb = rgba[:, :3].astype(np.float32) / 255.0 + samples = rgb[xs] + grid[row_i] = ( + _LUMA_R * samples[:, 0] + + _LUMA_G * samples[:, 1] + + _LUMA_B * samples[:, 2] + ) + return grid + + +def metrics_from_grid( + state: VisualLimiterState, + luma_grid: np.ndarray, +) -> tuple[float, float]: + mean_luma = float(np.mean(luma_grid)) + if state.prev_luma is None or state.prev_luma.shape != luma_grid.shape: + mean_abs_delta = 0.0 + else: + mean_abs_delta = float(np.mean(np.abs(luma_grid - state.prev_luma))) + state.prev_luma = np.array(luma_grid, dtype=np.float32, copy=True) + return mean_luma, mean_abs_delta + + +def apply_visual_limiter_gains( + runtime: VisualizerCore, + *, + blank_visualizers: bool = False, +) -> None: + """Write limiter gains onto layers before opacity is applied this frame.""" + state = runtime.visual_limiter + if not isinstance(state, VisualLimiterState): + return + session = runtime.seed.session + if blank_visualizers or not visual_limiter_active(session): + state.reset() + state.clear_layer_gains(runtime.layers_by_slot) + return + state.apply_to_layers(runtime.layers_by_slot) + + +def observe_frame_busyness( + core: VisualizerCore, + t_sec: float, + session: TuningSession, + *, + blank_visualizers: bool = False, +) -> None: + """Sample content FBO and update limiter state for the next frame.""" + if blank_visualizers or not visual_limiter_active(session): + return + state = core.visual_limiter + if not isinstance(state, VisualLimiterState): + return + width = core.compositor.content_width + height = core.compositor.content_height + if not isinstance(width, int) or not isinstance(height, int): + return + if width <= 0 or height <= 0: + return + reset_if_seek(state, t_sec) + luma_grid = read_luma_grid(core.compositor) + mean_luma, mean_abs_delta = metrics_from_grid(state, luma_grid) + hot = collect_hot_layers(session, core.layers_by_slot, t_sec) + update_limiter_state( + state, + mean_luma=mean_luma, + mean_abs_delta=mean_abs_delta, + t_sec=t_sec, + hot=hot, + params=VisualLimiterParams.from_runtime(session.timeline.limiter), + ) diff --git a/docs/improved-timeline-presets.md b/docs/improved-timeline-presets.md index 062793a..3c58a5e 100644 --- a/docs/improved-timeline-presets.md +++ b/docs/improved-timeline-presets.md @@ -71,7 +71,6 @@ None for 0/1 lanes. Partial levels come from generative Apply (crescendo). Blend **Deferred** (still part of the conductor vision, not implemented): - Section-role bias from auto form (Idea 4). -- Closed-loop visual limiter (companion below). - Automatic blend or role assignment from stem content (Idea 1 deferred / Idea 3). ### Intent @@ -214,11 +213,50 @@ Run `separate`, glance at suggested markers, Apply. Manual drop remains for stub --- -## Companion: closed-loop visual limiter +## Companion: closed-loop visual limiter (shipped) -Small, cheap, and catches collisions no offline classifier predicted. The post-FX stack in [cleave/gl_post_process.py](../cleave/gl_post_process.py) already resolves the composited frame, so a coarse mean-luma and frame-delta reduction per frame costs almost nothing. When the stack exceeds a busyness threshold, duck the lowest-priority hot layer; release when it drops. A sidechain compressor for the composite. +**Shipped:** a runtime sidechain-style limiter shared by live play and offline render. After the composite (and HDR display shoulder when active), [cleave/viz/visual_limiter.py](../cleave/viz/visual_limiter.py) samples a downsampled luma grid, combines mean luma with mean absolute frame delta into busyness, and on the next frame multiplies a separate `StemLayer.limiter_gain` into opacity (authored `timeline_level` and strip eyes are unchanged). Active when timeline levels apply and `timeline.limiter.enabled` is true; skipped for blank visualizers, preset curation, solo, recording, preview, and when the panel toggle is off. -Depends on Idea 1 levels (available) and on offline determinism: the limiter must be a pure function of frame content so live preview and offline render in [cleave/viz/render.py](../cleave/viz/render.py) agree. +### Panel (Render: TIMELINE) + +Sibling expandable section after **timeline preset** (before **reset timeline**), not under the Apply-staged preset knobs: + +``` +Render: TIMELINE + ... + └─ visual limiter: enabled / disabled + └─ threshold + └─ release +``` + +Left/Right expands/collapses (like other timeline subsections). Ctrl+Left/Right disables/enables (same eye semantics as render section headers); the header value shows enabled/disabled. Threshold and release appear when the section is expanded and enabled. Locked with the timeline section. + +| Knob | YAML | Default | Range / display | +| --- | --- | --- | --- | +| enabled | `timeline.limiter.enabled` | `true` | on/off | +| threshold | `timeline.limiter.threshold` | `0.65` | 0.40-0.90; panel shows 40%-90%. Maps to trip-on; off-threshold stays 0.17 below | +| release | `timeline.limiter.release` | `0.45` | 0.15-1.5 s; panel shows seconds. Maps to release ramp; hold time scales at 0.75/0.45 | + +Attack, duck gain, and delta weight stay fixed (not panel knobs). + +### What landed + +1. Sensor in [cleave/viz/frame_finish.py](../cleave/viz/frame_finish.py) after the HDR shoulder, before highlight rolloff / chroma / fade / overlay. +2. Controller state on `VisualizerCore.visual_limiter` (gains / hysteresis; not YAML): role/z-order victim pick, playhead-timed attack/release ramps, seek reset. Trip and release times come from session `timeline.limiter`. +3. Actuator: `limiter_gain` on [cleave/viz/layer.py](../cleave/viz/layer.py); opacity multiply in [cleave/viz/layer_pipeline.py](../cleave/viz/layer_pipeline.py); gains applied in `tick_frame_core` after `apply_layer_visibility`. +4. Priority via `lane_role_at` in [cleave/timeline.py](../cleave/timeline.py): duck `bed` before `accent` before `pulse` before `lead`; missing role ranks as `pulse`; ties break on lower level, then earlier `layer_z_order`. + +### Fixed constants + +| Constant | Value | Role | +| --- | --- | --- | +| `DELTA_WEIGHT` | 0.85 | Motion term in `mean_luma + k * mean_abs_delta` | +| `DUCK_GAIN` | 0.50 | Opacity multiplier floor while ducked | +| `ATTACK_SEC` | 0.15 | Playhead seconds to ramp gain down to `DUCK_GAIN` | +| `SEEK_JUMP_SEC` | 0.25 | Playhead jump that clears gains and prev grid | +| `GRID_WIDTH` x `GRID_HEIGHT` | 32 x 18 | Downsample readback grid | + +Schema defaults for threshold / release match the tuned constants above. Attack and release are playhead-timed ramps (not wall-clock or fps) so live and offline stay aligned without instant opacity snaps. --- @@ -244,7 +282,7 @@ Breathing / Dialogue / Arc / Pulse remain character profiles that bias switch ra | 1 | Rich cues: levels (Idea 1, shipped) | Enabler; everything else wants levels, not booleans | Better output immediately, even with today's arranger | Done | | 1b | Rich cues: blend and role (Idea 1, shipped) | Completes the mix vision; casting uses the cue role field | Medium | Done | | 2 | Stem conductor (Idea 2, shipped) | Uses data already in `signals.json`; first thing that makes lanes song-tied | High | Done | -| 3 | Closed-loop limiter (companion) | Cheap once levels exist; buys headroom before casting lands | Medium | Low | +| 3 | Closed-loop limiter (companion, shipped) | Cheap once levels exist; buys headroom before casting lands | Medium | Done | | 4 | Reactivity fingerprints and casting (Idea 3) | Probe harness exists; fixes busy-on-busy at the milk level | High | High (probe set, metrics, pools, casting) | | 5 | Reprise and auto form (Idea 4) | Highest ceiling, most analysis risk; benefits from 1 to 4 being in place | High | High (analyse dependency, marker suggestions, arranger restructure) | diff --git a/tests/cleave/test_config.py b/tests/cleave/test_config.py index c0fa209..788b3f9 100644 --- a/tests/cleave/test_config.py +++ b/tests/cleave/test_config.py @@ -23,6 +23,7 @@ TimelineConfig, TimelineFadeGroupConfig, TimelineFadesConfig, + TimelineLimiterConfig, TimelinePresetConfig, EditorConfig, clamp_beat_sensitivity, @@ -1177,6 +1178,46 @@ def test_persist_timeline_placement_snap_round_trip() -> None: assert round_trip.placement_snap == "bar" +def test_persist_timeline_limiter_round_trip() -> None: + from cleave.viz.session import TimelineRuntime, VisualLimiterRuntime + + session = TuningSession( + layer_z_order=list(DEFAULT_LAYER_SLOTS), + timeline=TimelineRuntime( + enabled=True, + limiter=VisualLimiterRuntime( + enabled=False, + threshold=0.72, + release=0.6, + ), + ), + ) + cfg = CleaveConfig( + paths=PathsConfig(preset_root=Path("/tmp"), texture_paths=()), + layers={}, + editor=EditorConfig(), + config_path=Path("/tmp/cleave-viz.yaml"), + user_config_path=Path("/tmp/user.yaml"), + layer_z_order=list(DEFAULT_LAYER_SLOTS), + ) + payload = persist_timeline(PersistCtx(cfg=cfg, session=session, cfg_dir=None)) + assert payload["limiter"] == { + "enabled": False, + "threshold": 0.72, + "release": 0.6, + } + round_trip = parse_timeline_section( + {"timeline": payload}, + _timeline_parse_ctx(), + ) + assert round_trip is not None + assert round_trip.limiter == TimelineLimiterConfig( + enabled=False, + threshold=0.72, + release=0.6, + ) + + def test_persist_timeline_preset_round_trip() -> None: from cleave.viz.session import TimelineRuntime diff --git a/tests/cleave/test_config_snapshot.py b/tests/cleave/test_config_snapshot.py index 4609de5..0d6c1e8 100644 --- a/tests/cleave/test_config_snapshot.py +++ b/tests/cleave/test_config_snapshot.py @@ -1142,6 +1142,11 @@ def test_write_session_snapshot_persists_timeline_disabled_without_cues( "density": "normal", "conductor": False, }, + "limiter": { + "enabled": True, + "threshold": 0.65, + "release": 0.45, + }, } diff --git a/tests/cleave/test_timeline.py b/tests/cleave/test_timeline.py index 614b2fa..fc874ac 100644 --- a/tests/cleave/test_timeline.py +++ b/tests/cleave/test_timeline.py @@ -15,6 +15,7 @@ canonicalize, empty_lane, lane_blend_at, + lane_role_at, lane_level_at, lane_level_breakpoints, lane_level_envelope, @@ -237,6 +238,24 @@ def test_lane_blend_at_holds_and_reverts() -> None: assert lane_blend_at(lane, 4.0) == "screen" +def test_lane_role_at_holds_and_clears() -> None: + lane = TimelineLane( + baseline=0.0, + cues=[ + SlotCue(t=1.0, level=1.0, role="lead"), + SlotCue(t=2.0, level=1.0, role="bed"), + SlotCue(t=3.0, level=0.0, role=None), + SlotCue(t=4.0, level=1.0, role="accent"), + ], + ) + assert lane_role_at(lane, 0.5) is None + assert lane_role_at(lane, 1.0) == "lead" + assert lane_role_at(lane, 1.5) == "lead" + assert lane_role_at(lane, 2.0) == "bed" + assert lane_role_at(lane, 3.0) is None + assert lane_role_at(lane, 4.0) == "accent" + + def test_snap_and_shift_preserve_blend_and_role_on_ons_strip_offs() -> None: lane = TimelineLane( baseline=0.0, diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index b85a1a9..0105283 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -2409,6 +2409,9 @@ def test_render_timeline_down_enters_submenu() -> None: snap_grid_row = view.layout.find_by_kind(RowKind.TIMELINE_SNAP_TO_GRID) fades_row = view.layout.find_by_kind(RowKind.TIMELINE_FADES_HEADER) presets_header_row = view.layout.find_by_kind(RowKind.TIMELINE_PRESETS_HEADER) + limiter_header_row = view.layout.find_by_kind( + RowKind.TIMELINE_VISUAL_LIMITER_HEADER + ) reset_row = view.layout.find_by_kind(RowKind.TIMELINE_RESET) controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.focus_row = 2 @@ -2449,6 +2452,10 @@ def test_render_timeline_down_enters_submenu() -> None: assert controls.focus_descriptor == _desc(view, presets_header_row) assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) + assert controls.focus_descriptor == _desc(view, limiter_header_row) + assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) assert controls.focus_descriptor == _desc(view, reset_row) assert not isinstance(controls.focus_cursor, TimelineFocus) @@ -2468,7 +2475,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 - for _ in range(10): + for _ in range(11): controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 0 diff --git a/tests/cleave/viz/test_row_fields.py b/tests/cleave/viz/test_row_fields.py index 24f7235..b0962ce 100644 --- a/tests/cleave/viz/test_row_fields.py +++ b/tests/cleave/viz/test_row_fields.py @@ -319,7 +319,7 @@ def test_apply_field_horizontal_track_header_solo_and_expand() -> None: def test_row_fields_count() -> None: - assert len(ROW_FIELDS) == 92 + assert len(ROW_FIELDS) == 95 def test_row_kinds_requiring_fields_registry_complete() -> None: diff --git a/tests/cleave/viz/test_row_semantics.py b/tests/cleave/viz/test_row_semantics.py index 26c3dcc..5d11d11 100644 --- a/tests/cleave/viz/test_row_semantics.py +++ b/tests/cleave/viz/test_row_semantics.py @@ -84,6 +84,8 @@ def _track_lock_state(locked: bool) -> SimpleNamespace: RowKind.TIMELINE_STANDARD_CUE_FADES, RowKind.TIMELINE_STANDARD_CUE_FADE_IN, RowKind.TIMELINE_STANDARD_CUE_FADE_OUT, + RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD, + RowKind.TIMELINE_VISUAL_LIMITER_RELEASE, } ) diff --git a/tests/cleave/viz/test_view_state_structure.py b/tests/cleave/viz/test_view_state_structure.py index 8339015..f14ef1b 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -165,6 +165,9 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: standard_cue_fades = RowDescriptor(RowKind.TIMELINE_STANDARD_CUE_FADES) standard_cue_fade_in = RowDescriptor(RowKind.TIMELINE_STANDARD_CUE_FADE_IN) standard_cue_fade_out = RowDescriptor(RowKind.TIMELINE_STANDARD_CUE_FADE_OUT) + limiter_header = RowDescriptor(RowKind.TIMELINE_VISUAL_LIMITER_HEADER) + limiter_threshold = RowDescriptor(RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD) + limiter_release = RowDescriptor(RowKind.TIMELINE_VISUAL_LIMITER_RELEASE) markers_header = RowDescriptor(RowKind.SONG_MARKERS_HEADER) assert presets_header not in view_closed.layout.rows assert presets_apply not in view_closed.layout.rows @@ -176,6 +179,7 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert snap_grid not in view_closed.layout.rows assert snap_markers not in view_closed.layout.rows assert fades_header not in view_closed.layout.rows + assert limiter_header not in view_closed.layout.rows assert markers_header not in view_closed.layout.rows session.timeline.panel_open = True @@ -196,16 +200,21 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert snap_markers not in view_open.layout.rows assert fades_header in view_open.layout.rows assert song_marker_fades not in view_open.layout.rows + assert limiter_header in view_open.layout.rows + assert limiter_threshold not in view_open.layout.rows + assert limiter_release not in view_open.layout.rows assert markers_header in view_open.layout.rows markers_idx = view_open.layout.rows.index(markers_header) beat_bar_idx = view_open.layout.rows.index(beat_bar_header) fades_idx = view_open.layout.rows.index(fades_header) presets_header_idx = view_open.layout.rows.index(presets_header) + limiter_header_idx = view_open.layout.rows.index(limiter_header) reset_idx = view_open.layout.rows.index(reset) assert beat_bar_idx == markers_idx + 1 assert fades_idx == beat_bar_idx + 1 assert presets_header_idx == fades_idx + 1 - assert reset_idx == presets_header_idx + 1 + assert limiter_header_idx == presets_header_idx + 1 + assert reset_idx == limiter_header_idx + 1 session.song_markers.expanded = True view_markers_expanded = builder.build(paused=False) @@ -233,7 +242,8 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert song_marker_fades not in view_beat_expanded.layout.rows assert standard_cue_fades not in view_beat_expanded.layout.rows assert view_beat_expanded.layout.rows.index(presets_header) == fades_idx + 1 - assert view_beat_expanded.layout.rows.index(reset) == fades_idx + 2 + assert view_beat_expanded.layout.rows.index(limiter_header) == fades_idx + 2 + assert view_beat_expanded.layout.rows.index(reset) == fades_idx + 3 session.timeline.fades_expanded = True view_fades_expanded = builder.build(paused=False) @@ -277,7 +287,10 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert view_presets_expanded.layout.rows.index(presets_apply) == ( presets_header_idx + 5 ) - assert view_presets_expanded.layout.rows.index(reset) == presets_header_idx + 6 + assert view_presets_expanded.layout.rows.index(limiter_header) == ( + presets_header_idx + 6 + ) + assert view_presets_expanded.layout.rows.index(reset) == presets_header_idx + 7 session.timeline.timeline_presets_expanded = False view_presets_collapsed = builder.build(paused=False) @@ -592,6 +605,37 @@ def test_structure_signature_invalidates_on_standard_cue_fades_enabled() -> None assert sig_before != sig_after +def test_structure_signature_invalidates_on_visual_limiter_enabled() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + config_save = controls._config_save + session.timeline.visual_limiter_expanded = True + session.timeline.limiter.enabled = True + sig_before = view_state_structure_signature( + session, config_save, notification_active=False + ) + session.timeline.limiter.enabled = False + sig_after = view_state_structure_signature( + session, config_save, notification_active=False + ) + assert sig_before != sig_after + + +def test_structure_signature_invalidates_on_visual_limiter_expanded() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + config_save = controls._config_save + session.timeline.visual_limiter_expanded = False + sig_before = view_state_structure_signature( + session, config_save, notification_active=False + ) + session.timeline.visual_limiter_expanded = True + sig_after = view_state_structure_signature( + session, config_save, notification_active=False + ) + assert sig_before != sig_after + + def test_structure_signature_invalidates_on_latency_compensation_expanded() -> None: controls = _make_controls(("layer_1",)) session = controls.session diff --git a/tests/cleave/viz/test_visual_limiter.py b/tests/cleave/viz/test_visual_limiter.py new file mode 100644 index 0000000..c31a6ea --- /dev/null +++ b/tests/cleave/viz/test_visual_limiter.py @@ -0,0 +1,371 @@ +"""Pure logic tests for the closed-loop visual limiter (no GL).""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock + +import numpy as np +import pytest + +from cleave.config_schema import DEFAULT_LAYER_SLOTS +from cleave.cue_roles import CueRole +from cleave.preset_playlist import PresetPlaylist +from cleave.timeline import SlotCue, TimelineLane, empty_lane +from cleave.viz.layer import StemLayer +from cleave.viz.layer_pipeline import apply_effect_modifiers +from cleave.viz.session import LayerRuntime, TimelineRuntime, TuningSession +from cleave.viz.visual_limiter import ( + ATTACK_SEC, + DUCK_GAIN, + RELEASE_RAMP_SEC, + RELEASE_SEC, + THRESHOLD_OFF, + THRESHOLD_ON, + HotLayerRef, + VisualLimiterParams, + VisualLimiterState, + apply_visual_limiter_gains, + busyness, + collect_hot_layers, + pick_victim, + role_rank, + reset_if_seek, + update_limiter_state, + visual_limiter_active, +) +from tests.support.config import TEST_LAYER_STEMS + +_OVER = THRESHOLD_ON + 0.01 +_UNDER = THRESHOLD_OFF - 0.01 + + +def _playlist(slot: str) -> PresetPlaylist: + current_dir = Path(f"/tmp/presets/{slot}") + return PresetPlaylist( + current_dir=current_dir, + paths=(current_dir / "preset.milk",), + index=0, + ) + + +def _session(*, timeline_enabled: bool = True) -> TuningSession: + return TuningSession( + layer_z_order=list(DEFAULT_LAYER_SLOTS), + timeline=TimelineRuntime(enabled=timeline_enabled, lanes={}), + layers={ + slot: LayerRuntime( + playlist=_playlist(slot), + browse_floor=Path(f"/tmp/presets/{slot}"), + stem=TEST_LAYER_STEMS[slot], + ) + for slot in DEFAULT_LAYER_SLOTS + }, + ) + + +def _hot( + slot: str, + *, + role: CueRole | None = "pulse", + level: float = 1.0, + z_index: int = 0, +) -> HotLayerRef: + return HotLayerRef( + slot=slot, + role_rank=role_rank(role), + timeline_level=level, + z_index=z_index, + ) + + +def _stem(slot: str, *, timeline_level: float = 1.0) -> StemLayer: + return StemLayer( + slot=slot, + pm=MagicMock(), + fbo=MagicMock(enabled=timeline_level > 0.0), + playlist=_playlist(slot), + timeline_level=timeline_level, + ) + + +def _drive( + state: VisualLimiterState, + *, + mean_luma: float, + t_sec: float, + hot: list[HotLayerRef], + mean_abs_delta: float = 0.0, +) -> None: + update_limiter_state( + state, + mean_luma=mean_luma, + mean_abs_delta=mean_abs_delta, + t_sec=t_sec, + hot=hot, + ) + + +def _fully_duck( + state: VisualLimiterState, + hot: list[HotLayerRef], + *, + t0: float = 0.0, +) -> float: + """Advance playhead through a full attack; return final t_sec.""" + _drive(state, mean_luma=_OVER, t_sec=t0, hot=hot) + t1 = t0 + ATTACK_SEC + 0.01 + _drive(state, mean_luma=_OVER, t_sec=t1, hot=hot) + return t1 + + +def test_pick_victim_prefers_bed_over_lead() -> None: + hot = [ + _hot("layer_1", role="lead", z_index=0), + _hot("layer_2", role="bed", z_index=1), + ] + assert pick_victim(hot, {}) == "layer_2" + + +def test_unset_role_ranks_as_pulse() -> None: + assert role_rank(None) == role_rank("pulse") + hot = [ + _hot("layer_1", role="lead", z_index=0), + _hot("layer_2", role=None, z_index=1), + _hot("layer_3", role="bed", z_index=2), + ] + assert pick_victim(hot, {}) == "layer_3" + assert pick_victim(hot, {"layer_3": DUCK_GAIN}) == "layer_2" + + +def test_tie_break_level_then_z_order() -> None: + hot = [ + _hot("layer_1", role="pulse", level=1.0, z_index=0), + _hot("layer_2", role="pulse", level=0.5, z_index=1), + ] + assert pick_victim(hot, {}) == "layer_2" + + hot_same_level = [ + _hot("layer_1", role="pulse", level=1.0, z_index=0), + _hot("layer_2", role="pulse", level=1.0, z_index=1), + ] + assert pick_victim(hot_same_level, {}) == "layer_1" + + +def test_attack_ramps_over_playhead_time() -> None: + state = VisualLimiterState() + hot = [_hot("layer_1", role="bed")] + + _drive(state, mean_luma=_OVER, t_sec=0.0, hot=hot) + assert state.gain_for("layer_1") == pytest.approx(1.0) + + _drive(state, mean_luma=_OVER, t_sec=ATTACK_SEC * 0.5, hot=hot) + mid = state.gain_for("layer_1") + assert DUCK_GAIN < mid < 1.0 + + _drive(state, mean_luma=_OVER, t_sec=ATTACK_SEC + 0.01, hot=hot) + assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) + + +def test_hysteresis_holds_until_release_ramp() -> None: + state = VisualLimiterState() + hot = [_hot("layer_1", role="bed"), _hot("layer_2", role="lead")] + t = _fully_duck(state, hot) + assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) + + under_start = t + 0.1 + _drive(state, mean_luma=_UNDER, t_sec=under_start, hot=hot) + assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) + + _drive( + state, + mean_luma=_UNDER, + t_sec=under_start + RELEASE_SEC * 0.5, + hot=hot, + ) + assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) + + # Cross the hold with a small dt so the first release step is partial. + hold_end = under_start + RELEASE_SEC + _drive(state, mean_luma=_UNDER, t_sec=hold_end - 0.01, hot=hot) + _drive(state, mean_luma=_UNDER, t_sec=hold_end + 0.02, hot=hot) + mid_gain = state.gain_for("layer_1") + assert DUCK_GAIN < mid_gain < 1.0 + + _drive( + state, + mean_luma=_UNDER, + t_sec=hold_end + RELEASE_RAMP_SEC + 0.01, + hot=hot, + ) + assert state.gain_for("layer_1") == pytest.approx(1.0) + + +def test_between_thresholds_cancels_release() -> None: + state = VisualLimiterState() + hot = [_hot("layer_1", role="bed")] + mid = (THRESHOLD_ON + THRESHOLD_OFF) / 2.0 + t = _fully_duck(state, hot) + + under_start = t + 0.1 + _drive(state, mean_luma=_UNDER, t_sec=under_start, hot=hot) + _drive(state, mean_luma=mid, t_sec=under_start + 0.1, hot=hot) + _drive( + state, + mean_luma=_UNDER, + t_sec=under_start + 0.1 + RELEASE_SEC + RELEASE_RAMP_SEC + 0.01, + hot=hot, + ) + # Release timer restarted after the mid-band cancel; one under tick is not + # enough hold time, so the duck remains. + assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) + + +def test_seek_clears_ducks() -> None: + state = VisualLimiterState() + hot = [_hot("layer_1", role="bed")] + + state.last_t_sec = 10.0 + _fully_duck(state, hot, t0=10.0) + assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) + state.prev_luma = np.zeros((2, 2), dtype=np.float32) + + assert reset_if_seek(state, 1.0) is True + assert state.gains == {} + assert state.prev_luma is None + assert state.last_t_sec == pytest.approx(1.0) + assert state.controller_t_sec is None + + # Quiet frame after seek: no release hold inherited from the prior duck. + _drive(state, mean_luma=_UNDER, t_sec=1.0, hot=hot) + assert state.gains == {} + + +def test_cascading_ducks_next_victim() -> None: + state = VisualLimiterState() + hot = [ + _hot("layer_1", role="bed", z_index=0), + _hot("layer_2", role="accent", z_index=1), + ] + t = _fully_duck(state, hot) + assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) + assert state.gain_for("layer_2") == pytest.approx(1.0) + + t = _fully_duck(state, hot, t0=t) + assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) + assert state.gain_for("layer_2") == pytest.approx(DUCK_GAIN) + + +def test_busyness_includes_delta_weight() -> None: + assert busyness(0.2, 0.1) > busyness(0.2, 0.0) + + +def test_collect_hot_layers_uses_role_and_level() -> None: + session = _session(timeline_enabled=True) + session.timeline.lanes = { + "layer_1": TimelineLane( + baseline=0.0, + cues=[SlotCue(t=0.0, level=1.0, role="lead")], + ), + "layer_2": TimelineLane( + baseline=0.0, + cues=[SlotCue(t=0.0, level=0.5, role="bed")], + ), + "layer_3": empty_lane(), + "layer_4": empty_lane(), + } + layers = { + "layer_1": _stem("layer_1", timeline_level=1.0), + "layer_2": _stem("layer_2", timeline_level=0.5), + "layer_3": _stem("layer_3", timeline_level=0.0), + "layer_4": _stem("layer_4", timeline_level=0.0), + } + hot = collect_hot_layers(session, layers, 1.0) + assert [h.slot for h in hot] == ["layer_1", "layer_2"] + assert pick_victim(hot, {}) == "layer_2" + + +def test_apply_effect_modifiers_includes_limiter_gain() -> None: + session = _session(timeline_enabled=False) + layer = _stem("layer_1", timeline_level=0.5) + layer.limiter_gain = DUCK_GAIN + effect_runtime = MagicMock() + mod = MagicMock( + opacity=0.8, + flash_alpha=0.0, + bloom_strength=0.0, + hue_rgb=(1.0, 1.0, 1.0), + hue_mix=0.0, + grit_strength=0.0, + aberration_px=0.0, + ) + effect_runtime.modifiers.return_value = { + slot: mod for slot in session.layer_z_order + } + apply_effect_modifiers( + session, + {"layer_1": layer}, + effect_runtime, + None, + 0.0, + update=False, + ) + assert layer.fbo.opacity == pytest.approx(0.8 * 0.5 * DUCK_GAIN) + + +def test_thresholds_leave_hysteresis_gap() -> None: + assert THRESHOLD_OFF < THRESHOLD_ON + assert THRESHOLD_ON - THRESHOLD_OFF >= 0.15 + assert DUCK_GAIN > 0.25 + + +def test_visual_limiter_inactive_when_disabled() -> None: + session = _session(timeline_enabled=True) + assert visual_limiter_active(session) is True + session.timeline.limiter.enabled = False + assert visual_limiter_active(session) is False + + +def test_apply_visual_limiter_gains_resets_when_disabled() -> None: + session = _session(timeline_enabled=True) + session.timeline.limiter.enabled = False + state = VisualLimiterState() + state.gains["layer_1"] = DUCK_GAIN + layer = _stem("layer_1", timeline_level=1.0) + layer.limiter_gain = DUCK_GAIN + runtime = MagicMock() + runtime.visual_limiter = state + runtime.seed.session = session + runtime.layers_by_slot = {"layer_1": layer} + apply_visual_limiter_gains(runtime) + assert state.gains == {} + assert layer.limiter_gain == pytest.approx(1.0) + + +def test_update_limiter_state_uses_session_params() -> None: + state = VisualLimiterState() + hot = [_hot("layer_1", role="bed")] + params = VisualLimiterParams( + threshold_on=0.80, + threshold_off=0.63, + release_ramp_sec=0.45, + release_sec=0.75, + ) + update_limiter_state( + state, + mean_luma=0.70, + mean_abs_delta=0.0, + t_sec=0.0, + hot=hot, + params=params, + ) + assert state.gains == {} + update_limiter_state( + state, + mean_luma=0.81, + mean_abs_delta=0.0, + t_sec=ATTACK_SEC + 0.01, + hot=hot, + params=params, + ) + assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) From 3df79ff95a037b550b7b6d99edfcf98ef10edc0f Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Tue, 28 Jul 2026 23:13:47 +0100 Subject: [PATCH 05/12] Add cast curation hotkey --- cleave/cue_roles.py | 19 ++ cleave/preset_curation.py | 62 ++++- cleave/viz/controls.py | 8 +- cleave/viz/help_content.py | 3 +- cleave/viz/preset_curation_controls.py | 177 ++++++++++++- cleave/viz/row_semantics.py | 5 + cleave/viz/text_fit.py | 9 +- tests/cleave/test_preset_curation.py | 92 ++++++- tests/cleave/viz/test_controls.py | 24 ++ tests/cleave/viz/test_editor_mode.py | 2 + tests/cleave/viz/test_help_overlay.py | 17 +- .../viz/test_preset_curation_controls.py | 245 +++++++++++++++++- tests/cleave/viz/test_preset_switching.py | 34 +++ tests/cleave/viz/test_text_fit.py | 41 +++ 14 files changed, 709 insertions(+), 29 deletions(-) diff --git a/cleave/cue_roles.py b/cleave/cue_roles.py index 3bec726..abe9d33 100644 --- a/cleave/cue_roles.py +++ b/cleave/cue_roles.py @@ -11,6 +11,25 @@ CUE_ROLE_DIR = "roles" +CUE_ROLE_MARKER_LETTER: dict[CueRole, str] = { + "bed": "B", + "pulse": "P", + "lead": "L", + "accent": "A", +} + +CUE_ROLE_MARKER_HELP_ENTRIES: tuple[tuple[str, str], ...] = tuple( + (f"[R:{CUE_ROLE_MARKER_LETTER[role]}]", role) for role in CUE_ROLES +) + + +def ensure_role_dirs(preset_root: Path) -> None: + """Create ``preset_root/roles/`` and the four role subdirectories if missing.""" + roles = preset_root / CUE_ROLE_DIR + roles.mkdir(parents=True, exist_ok=True) + for role in CUE_ROLES: + (roles / role).mkdir(parents=True, exist_ok=True) + def role_pool_paths(preset_root: Path, role: CueRole) -> tuple[Path, ...]: """Sorted non-recursive ``*.milk`` under ``preset_root/roles//``. diff --git a/cleave/preset_curation.py b/cleave/preset_curation.py index d6dcc11..0824691 100644 --- a/cleave/preset_curation.py +++ b/cleave/preset_curation.py @@ -4,10 +4,16 @@ import shutil from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path -from cleave.cue_roles import CUE_ROLE_DIR +from cleave.cue_roles import ( + CUE_ROLE_DIR, + CUE_ROLE_MARKER_LETTER, + CUE_ROLES, + CueRole, + ensure_role_dirs, +) from cleave.viz.user_presets import copy_with_dedup, resolve_user_preset_dest FAVOURITES_DIR = "favourites" @@ -24,6 +30,10 @@ def blacklist_root(preset_root: Path) -> Path: return preset_root / BLACKLIST_DIR +def roles_root(preset_root: Path) -> Path: + return preset_root / CUE_ROLE_DIR + + def origin_sidecar_path(milk_path: Path) -> Path: """Return ``{milk_name}.origin`` beside a blacklisted milk file.""" return milk_path.parent / f"{milk_path.name}{ORIGIN_SIDECAR_SUFFIX}" @@ -68,12 +78,22 @@ def _milk_names_under(root: Path) -> set[str]: class PresetCurationIndex: favourites: set[str] blacklist: set[str] + roles: dict[str, set[CueRole]] = field(default_factory=dict) @classmethod def build(cls, preset_root: Path) -> PresetCurationIndex: + roles: dict[str, set[CueRole]] = {} + for role in CUE_ROLES: + role_dir = roles_root(preset_root) / role + if not role_dir.is_dir(): + continue + for path in role_dir.glob("*.milk"): + if path.is_file(): + roles.setdefault(path.name, set()).add(role) return cls( favourites=_milk_names_under(favourites_root(preset_root)), blacklist=_milk_names_under(blacklist_root(preset_root)), + roles=roles, ) def marker(self, name: str, *, user: bool = False) -> str: @@ -84,7 +104,17 @@ def marker(self, name: str, *, user: bool = False) -> str: letters += "B" if user: letters += "U" - return f" [{letters}]" if letters else "" + parts: list[str] = [] + if letters: + parts.append(f"[{letters}]") + role_set = self.roles.get(name) + if role_set: + for role in CUE_ROLES: + if role in role_set: + parts.append(f"[R:{CUE_ROLE_MARKER_LETTER[role]}]") + if not parts: + return "" + return " " + " ".join(parts) def mark_favourite(self, name: str) -> None: self.favourites.add(name) @@ -92,12 +122,23 @@ def mark_favourite(self, name: str) -> None: def mark_blacklisted(self, name: str) -> None: self.blacklist.add(name) + def mark_role(self, name: str, role: CueRole) -> None: + self.roles.setdefault(name, set()).add(role) + def unmark_favourite(self, name: str) -> None: self.favourites.discard(name) def unmark_blacklisted(self, name: str) -> None: self.blacklist.discard(name) + def unmark_role(self, name: str, role: CueRole) -> None: + role_set = self.roles.get(name) + if role_set is None: + return + role_set.discard(role) + if not role_set: + del self.roles[name] + def list_destination_subdirs(base: Path) -> tuple[str, ...]: """Sorted top-level subdirectory names under ``base``, excluding dot-dirs.""" @@ -152,6 +193,12 @@ def copy_to_favourites(src_milk: Path, dest_dir: Path) -> Path: return dest_milk +def copy_to_role(src_milk: Path, preset_root: Path, role: CueRole) -> Path: + """Copy ``src_milk`` and co-located textures into ``roles//``.""" + ensure_role_dirs(preset_root) + return copy_to_favourites(src_milk, roles_root(preset_root) / role) + + def move_to_blacklist( src_milk: Path, dest_dir: Path, @@ -185,6 +232,15 @@ def delete_favourite_milk(preset_root: Path, src: Path) -> Path | None: return curated +def delete_role_milk(preset_root: Path, src: Path, role: CueRole) -> Path | None: + """Delete the curated role-pool ``.milk`` for ``src``; leave textures alone.""" + curated = curated_milk_src(roles_root(preset_root) / role, src) + if curated is None: + return None + curated.unlink() + return curated + + def restore_from_blacklist(milk: Path, dest_dir: Path) -> Path: """Move a blacklisted milk to ``dest_dir`` and remove its ``.origin`` sidecar.""" dest_dir.mkdir(parents=True, exist_ok=True) diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index db56a2c..11ef9ab 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -511,7 +511,7 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: self._add_current_preset(slot) return True - if event.key in (pygame.K_f, pygame.K_b, pygame.K_r): + if event.key in (pygame.K_f, pygame.K_b, pygame.K_r, pygame.K_c): kind = self.focus_descriptor.kind slot = self.focus_descriptor.slot if slot is not None and kind in PRESET_FILE_ROW_KINDS: @@ -531,6 +531,8 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: from_user_preset=(kind == RowKind.TRACK_USER_PRESET_ITEM), user_preset_index=self.focus_descriptor.preset_index, ) + elif event.key == pygame.K_c: + self._preset_curation.prompt_cast(slot, src) else: self._preset_curation.prompt_restore(slot, src) return True @@ -664,7 +666,7 @@ def _handle_curation_keydown(self, event: pygame.event.Event) -> bool: ) return True - if event.key in (pygame.K_f, pygame.K_b, pygame.K_r): + if event.key in (pygame.K_f, pygame.K_b, pygame.K_r, pygame.K_c): view = self.build_view_state(paused=self.playback.paused) desc = self.focus_descriptor if not view.layout.contains_descriptor(desc): @@ -688,6 +690,8 @@ def _handle_curation_keydown(self, event: pygame.event.Event) -> bool: from_user_preset=(kind == RowKind.TRACK_USER_PRESET_ITEM), user_preset_index=self.focus_descriptor.preset_index, ) + elif event.key == pygame.K_c: + self._preset_curation.prompt_cast(slot, src) else: self._preset_curation.prompt_restore(slot, src) return True diff --git a/cleave/viz/help_content.py b/cleave/viz/help_content.py index 9230f49..e45e1c6 100644 --- a/cleave/viz/help_content.py +++ b/cleave/viz/help_content.py @@ -119,7 +119,8 @@ def layer_section( _PRESET_CURATION_SHORTCUTS = ( ("F", "favourite preset"), ("B", "blacklist preset"), - ("R", "remove favourite / restore blacklist"), + ("C", "cast preset (bed/pulse/lead/accent)"), + ("R", "remove favourite / restore blacklist / remove cast"), ) _PRESET_SECTION = HelpSection( diff --git a/cleave/viz/preset_curation_controls.py b/cleave/viz/preset_curation_controls.py index 3056187..c7c16b3 100644 --- a/cleave/viz/preset_curation_controls.py +++ b/cleave/viz/preset_curation_controls.py @@ -3,13 +3,17 @@ from __future__ import annotations from pathlib import Path +from typing import Literal +from cleave.cue_roles import CUE_ROLES, CueRole from cleave.preset_curation import ( PresetCurationIndex, blacklist_root, copy_to_favourites, + copy_to_role, curated_milk_src, delete_favourite_milk, + delete_role_milk, favourites_root, list_destination_subdirs, list_restore_destination_subdirs, @@ -18,6 +22,7 @@ resolve_blacklist_origin_dir, restore_from_blacklist, rewrite_user_preset_paths, + roles_root, scrub_user_preset_paths, ) from cleave.viz.live_layer_bindings import LiveLayerBindings @@ -27,6 +32,9 @@ _ROOT_DEST_LABEL = "(Root)" _CANCEL_LABEL = "Cancel" +_RestoreKind = Literal["favourite", "blacklist", "role"] +_RestoreMembership = tuple[_RestoreKind, CueRole | None] + def _path_under(path: Path, root: Path) -> bool: try: @@ -53,21 +61,112 @@ def __init__( self._layer_bindings = layer_bindings self._index = index + def prompt_cast(self, slot: str, src: Path) -> None: + """Copy or move a preset into a role pool (hotkey **c**).""" + relocating = src.name in self._index.roles + self._lock_preset(slot) + message = ( + f"Move cast: {src.name}?" + if relocating + else f"Cast preset: {src.name}?" + ) + dismiss = lambda: self._unlock_preset(slot) + options: list[ModalOption] = [ + ModalOption( + role, + lambda r=role: self._confirm_cast( + slot, src, r, relocating=relocating + ), + ) + for role in CUE_ROLES + ] + options.append(ModalOption(_CANCEL_LABEL, dismiss)) + self._modal.prompt_choice(message, options, on_dismiss=dismiss) + def prompt_restore(self, slot: str, src: Path) -> None: - """Remove a favourite or restore a blacklisted preset (hotkey **r**).""" - under_fav = _path_under(src, favourites_root(self._preset_root)) - under_bl = _path_under(src, blacklist_root(self._preset_root)) - if under_fav: - self._prompt_remove_favourite(slot, src) + """Remove from favourites / cast, or restore a blacklisted preset (hotkey **r**).""" + memberships = self._restore_memberships(src) + if not memberships: return - if under_bl: - self._prompt_restore_blacklist(slot, src) - return - if src.name in self._index.favourites: - self._prompt_remove_favourite(slot, src) + if len(memberships) == 1: + kind, role = memberships[0] + if kind == "favourite": + self._prompt_remove_favourite(slot, src) + elif kind == "blacklist": + self._prompt_restore_blacklist(slot, src) + else: + assert role is not None + self._prompt_remove_cast(slot, src, role) return - if src.name in self._index.blacklist: - self._prompt_restore_blacklist(slot, src) + self._prompt_restore_choice(slot, src, memberships) + + def _restore_memberships(self, src: Path) -> list[_RestoreMembership]: + under_fav = _path_under(src, favourites_root(self._preset_root)) + under_bl = _path_under(src, blacklist_root(self._preset_root)) + memberships: list[_RestoreMembership] = [] + if under_fav or src.name in self._index.favourites: + memberships.append(("favourite", None)) + if under_bl or src.name in self._index.blacklist: + memberships.append(("blacklist", None)) + for role in CUE_ROLES: + if role in self._roles_for_src(src): + memberships.append(("role", role)) + return memberships + + def _roles_for_src(self, src: Path) -> set[CueRole]: + roles = set(self._index.roles.get(src.name, ())) + root = roles_root(self._preset_root) + if not _path_under(src, root): + return roles + try: + rel = src.resolve().relative_to(root.resolve()) + except ValueError: + return roles + head = rel.parts[0] if rel.parts else "" + for role in CUE_ROLES: + if head == role: + roles.add(role) + break + return roles + + def _prompt_restore_choice( + self, + slot: str, + src: Path, + memberships: list[_RestoreMembership], + ) -> None: + self._lock_preset(slot) + dismiss = lambda: self._unlock_preset(slot) + options: list[ModalOption] = [] + for kind, role in memberships: + if kind == "favourite": + options.append( + ModalOption( + "Favourites", + lambda: self._confirm_remove_favourite(slot, src), + ) + ) + elif kind == "blacklist": + options.append( + ModalOption( + "Blacklist", + lambda: self._confirm_restore_blacklist(slot, src), + ) + ) + else: + assert role is not None + options.append( + ModalOption( + f"Cast ({role})", + lambda r=role: self._confirm_remove_cast(slot, src, r), + ) + ) + options.append(ModalOption(_CANCEL_LABEL, dismiss)) + self._modal.prompt_choice( + f"Remove from: {src.name}?", + options, + on_dismiss=dismiss, + ) def prompt_favourite(self, slot: str, src: Path) -> None: relocating = src.name in self._index.favourites @@ -173,6 +272,35 @@ def prompt_blacklist( options.append(ModalOption(_CANCEL_LABEL, dismiss)) self._modal.prompt_choice(message, options, on_dismiss=dismiss) + def _confirm_cast( + self, + slot: str, + src: Path, + role: CueRole, + *, + relocating: bool, + ) -> None: + try: + if relocating: + old_roles = set(self._index.roles.get(src.name, ())) + dest = copy_to_role(src, self._preset_root, role) + self._index.mark_role(dest.name, role) + for old_role in old_roles: + if old_role == role: + continue + deleted = delete_role_milk(self._preset_root, src, old_role) + if deleted is not None: + self._index.unmark_role(deleted.name, old_role) + self._rewrite_paths_after_relocate( + deleted.resolve(), dest.resolve() + ) + else: + dest = copy_to_role(src, self._preset_root, role) + self._index.mark_role(dest.name, role) + self._rebuild_timeline_role_rotations() + finally: + self._unlock_preset(slot) + def _confirm_favourite( self, slot: str, @@ -252,6 +380,24 @@ def _confirm_remove_favourite(self, slot: str, src: Path) -> None: finally: self._unlock_preset(slot) + def _prompt_remove_cast(self, slot: str, src: Path, role: CueRole) -> None: + self._lock_preset(slot) + self._modal.prompt_yes_no( + f"Remove cast ({role})?", + on_confirm=lambda: self._confirm_remove_cast(slot, src, role), + on_cancel=lambda: self._unlock_preset(slot), + ) + + def _confirm_remove_cast(self, slot: str, src: Path, role: CueRole) -> None: + try: + deleted = delete_role_milk(self._preset_root, src, role) + if deleted is not None: + self._index.unmark_role(deleted.name, role) + self._scrub_deleted_preset(deleted) + self._rebuild_timeline_role_rotations() + finally: + self._unlock_preset(slot) + def _scrub_deleted_preset(self, removed: Path) -> None: """Drop ``removed`` from layer playlists and user preset lists.""" for layer_slot, layer in self.session.layers.items(): @@ -264,6 +410,13 @@ def _scrub_deleted_preset(self, removed: Path) -> None: for affected_slot in affected: self._layer_bindings.on_preset_switching_change(affected_slot) + def _rebuild_timeline_role_rotations(self) -> None: + if self._layer_bindings is None: + return + for layer_slot, layer in self.session.layers.items(): + if layer.preset_switching == "timeline": + self._layer_bindings.on_preset_switching_change(layer_slot) + def _prompt_restore_blacklist(self, slot: str, src: Path) -> None: self._lock_preset(slot) self._modal.prompt_yes_no( diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index 954a321..179a0ac 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -17,6 +17,7 @@ RENDER_OVERLAY_ANIMATION_TYPE_HELP_ENTRIES, RENDER_OVERLAY_SLIDE_DIRECTION_HELP_ENTRIES, ) +from cleave.cue_roles import CUE_ROLE_MARKER_HELP_ENTRIES from cleave.timeline_presets import ( TIMELINE_PRESET_HELP_ENTRIES, TIMELINE_RESET_HELP_ENTRIES, @@ -212,7 +213,9 @@ class RowBehavior: help_description=( "Currently active Milkdrop preset for this layer.", "[F/B/U] indicates favourited/blacklisted/user-defined.", + "[R:X] indicates cast role.", ), + help_mode_entries=CUE_ROLE_MARKER_HELP_ENTRIES, repeatable=True, parent_group="track", ), @@ -244,7 +247,9 @@ class RowBehavior: help_description=( "Preset in the user-defined rotation set for this layer.", "[F/B] indicates favourited/blacklisted.", + "[R:X] indicates cast role.", ), + help_mode_entries=CUE_ROLE_MARKER_HELP_ENTRIES, ), RowKind.TRACK_USER_PRESET_ADD: RowBehavior( RowAffordance.ACTION, diff --git a/cleave/viz/text_fit.py b/cleave/viz/text_fit.py index 3e025ce..62c87cb 100644 --- a/cleave/viz/text_fit.py +++ b/cleave/viz/text_fit.py @@ -11,11 +11,14 @@ # Playlist counter and/or must-include curation markers after a filename or path. # Counter: " (N/TOTAL)". -# Markers: single bracket with letters in F then B then U order, e.g. " [FBU]". +# Markers: FBU bracket (e.g. " [FBU]") and/or role cast brackets (e.g. " [R:B]"). +# Combined suffixes: " [FBU]", " [FBU] [R:B]", " [R:B] [R:L]", " (2/5) [F] [R:B]". _META_SUFFIX = re.compile( - r"(?: \((\d+)/(\d+)\))(?: \[[FBU]+\])?$" + r"(?: \((\d+)/(\d+)\))(?: \[[FBU]+\])?(?: \[R:[BPLA]\])*$" r"|" - r"(?: \[[FBU]+\])$" + r"(?: \[[FBU]+\])(?: \[R:[BPLA]\])*$" + r"|" + r"(?: \[R:[BPLA]\])+$" ) diff --git a/tests/cleave/test_preset_curation.py b/tests/cleave/test_preset_curation.py index edb0432..f7f6c9a 100644 --- a/tests/cleave/test_preset_curation.py +++ b/tests/cleave/test_preset_curation.py @@ -6,6 +6,7 @@ from dataclasses import dataclass, field from pathlib import Path +from cleave.cue_roles import CUE_ROLE_DIR, CUE_ROLES, ensure_role_dirs from cleave.preset_curation import ( BLACKLIST_DIR, COLOCATED_TEXTURE_SUFFIXES, @@ -13,8 +14,10 @@ PresetCurationIndex, blacklist_root, copy_to_favourites, + copy_to_role, curated_milk_src, delete_favourite_milk, + delete_role_milk, favourites_root, find_milk_under, list_destination_subdirs, @@ -26,6 +29,7 @@ resolve_blacklist_origin_dir, restore_from_blacklist, rewrite_user_preset_paths, + roles_root, scrub_user_preset_paths, write_blacklist_origin, ) @@ -40,6 +44,18 @@ def test_favourites_and_blacklist_roots() -> None: preset_root = Path("/tmp/presets") assert favourites_root(preset_root) == preset_root / FAVOURITES_DIR assert blacklist_root(preset_root) == preset_root / BLACKLIST_DIR + assert roles_root(preset_root) == preset_root / CUE_ROLE_DIR + + +def test_ensure_role_dirs_creates_tree() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + ensure_role_dirs(root) + assert (root / CUE_ROLE_DIR).is_dir() + for role in CUE_ROLES: + assert (root / CUE_ROLE_DIR / role).is_dir() + ensure_role_dirs(root) + assert (root / CUE_ROLE_DIR).is_dir() def test_preset_curation_index_build_scans_recursively() -> None: @@ -50,11 +66,19 @@ def test_preset_curation_index_build_scans_recursively() -> None: _write(root / BLACKLIST_DIR / "reject.milk", "bl-top") _write(root / BLACKLIST_DIR / "pack" / "inner.milk", "bl-inner") _write(root / "pack" / "ignored.milk", "not-curated") + _write(root / CUE_ROLE_DIR / "bed" / "cast.milk", "bed") + _write(root / CUE_ROLE_DIR / "lead" / "cast.milk", "lead") + _write(root / CUE_ROLE_DIR / "pulse" / "pulse_only.milk", "pulse") + _write(root / CUE_ROLE_DIR / "bed" / "nested" / "ignored.milk", "nested") index = PresetCurationIndex.build(root) assert index.favourites == {"top.milk", "deep.milk"} assert index.blacklist == {"reject.milk", "inner.milk"} + assert index.roles == { + "cast.milk": {"bed", "lead"}, + "pulse_only.milk": {"pulse"}, + } def test_preset_curation_index_build_missing_trees() -> None: @@ -62,37 +86,56 @@ def test_preset_curation_index_build_missing_trees() -> None: index = PresetCurationIndex.build(Path(tmp)) assert index.favourites == set() assert index.blacklist == set() + assert index.roles == {} def test_preset_curation_index_marker() -> None: index = PresetCurationIndex( favourites={"fav.milk", "both.milk"}, blacklist={"bl.milk", "both.milk"}, + roles={ + "cast.milk": {"bed"}, + "multi.milk": {"accent", "bed", "lead"}, + "fav.milk": {"pulse"}, + }, ) - assert index.marker("fav.milk") == " [F]" + assert index.marker("fav.milk") == " [F] [R:P]" assert index.marker("bl.milk") == " [B]" assert index.marker("both.milk") == " [FB]" + assert index.marker("cast.milk") == " [R:B]" + assert index.marker("multi.milk") == " [R:B] [R:L] [R:A]" assert index.marker("plain.milk") == "" assert index.marker("plain.milk", user=True) == " [U]" - assert index.marker("fav.milk", user=True) == " [FU]" + assert index.marker("fav.milk", user=True) == " [FU] [R:P]" assert index.marker("bl.milk", user=True) == " [BU]" assert index.marker("both.milk", user=True) == " [FBU]" + assert index.marker("cast.milk", user=True) == " [U] [R:B]" def test_preset_curation_index_mark_updates_sets() -> None: index = PresetCurationIndex(favourites=set(), blacklist=set()) index.mark_favourite("a.milk") index.mark_blacklisted("b.milk") + index.mark_role("c.milk", "bed") + index.mark_role("c.milk", "lead") assert index.favourites == {"a.milk"} assert index.blacklist == {"b.milk"} + assert index.roles == {"c.milk": {"bed", "lead"}} assert index.marker("a.milk") == " [F]" assert index.marker("b.milk") == " [B]" + assert index.marker("c.milk") == " [R:B] [R:L]" index.unmark_favourite("a.milk") index.unmark_blacklisted("b.milk") + index.unmark_role("c.milk", "bed") assert index.favourites == set() assert index.blacklist == set() + assert index.roles == {"c.milk": {"lead"}} assert index.marker("a.milk") == "" assert index.marker("b.milk") == "" + assert index.marker("c.milk") == " [R:L]" + index.unmark_role("c.milk", "lead") + assert index.roles == {} + assert index.marker("c.milk") == "" def test_list_destination_subdirs_sorted_and_excludes_dot_dirs() -> None: @@ -345,6 +388,51 @@ def test_delete_favourite_milk_missing_returns_none() -> None: assert delete_favourite_milk(root, root / "missing.milk") is None +def test_copy_to_role_creates_dirs_and_copies_textures() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src_dir = root / "pack" + src_milk = src_dir / "preset.milk" + _write(src_milk, "milk") + _write(src_dir / "cover.jpg", "jpg-bytes") + + result = copy_to_role(src_milk, root, "bed") + + assert result == root / CUE_ROLE_DIR / "bed" / "preset.milk" + assert result.read_text(encoding="utf-8") == "milk" + assert (root / CUE_ROLE_DIR / "bed" / "cover.jpg").read_text( + encoding="utf-8" + ) == "jpg-bytes" + for role in CUE_ROLES: + assert (root / CUE_ROLE_DIR / role).is_dir() + assert src_milk.exists() + + +def test_delete_role_milk_leaves_textures() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + role_dir = root / CUE_ROLE_DIR / "pulse" + milk = role_dir / "preset.milk" + texture = role_dir / "tex.jpg" + _write(milk, "milk") + _write(texture, "jpg") + pack = root / "pack" / "preset.milk" + _write(pack, "other") + + deleted = delete_role_milk(root, pack, "pulse") + + assert deleted == milk + assert not milk.exists() + assert texture.exists() + assert pack.exists() + + +def test_delete_role_milk_missing_returns_none() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + assert delete_role_milk(root, root / "missing.milk", "accent") is None + + @dataclass class _LayerStub: user_presets: list[str] = field(default_factory=list) diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 0105283..eb058ca 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -4739,6 +4739,17 @@ def test_f_on_preset_file_prompts_favourite() -> None: mock_curation.prompt_favourite.assert_called_once_with("layer_1", current) +def test_c_on_preset_file_prompts_cast() -> None: + controls = _make_controls(("layer_1",)) + current = _focus_preset_file_row(controls) + mock_curation = MagicMock() + controls._preset_curation = mock_curation + + assert controls.handle_keydown(_keydown(pygame.K_c)) is True + + mock_curation.prompt_cast.assert_called_once_with("layer_1", current) + + def test_b_on_preset_file_prompts_blacklist() -> None: controls = _make_controls(("layer_1",)) current = _focus_preset_file_row(controls) @@ -4823,6 +4834,19 @@ def test_f_b_allowed_in_projectm_mode() -> None: mock_curation.prompt_favourite.assert_called_once_with("layer_1", current) +def test_c_on_preset_file_in_curation_mode_prompts_cast() -> None: + controls = _make_controls(("layer_1",)) + controls.session.settings.editor_mode = "preset_curation" + controls.session.settings.editor_mode_selection = "preset_curation" + current = _focus_preset_file_row(controls) + mock_curation = MagicMock() + controls._preset_curation = mock_curation + + assert controls.handle_keydown(_keydown(pygame.K_c)) is True + + mock_curation.prompt_cast.assert_called_once_with("layer_1", current) + + def test_f_b_ignored_on_non_preset_rows() -> None: controls = _make_controls(("layer_1",)) controls.focus_descriptor = RowDescriptor(RowKind.TRANSPORT) diff --git a/tests/cleave/viz/test_editor_mode.py b/tests/cleave/viz/test_editor_mode.py index 4c31302..abd397e 100644 --- a/tests/cleave/viz/test_editor_mode.py +++ b/tests/cleave/viz/test_editor_mode.py @@ -227,6 +227,8 @@ def test_curation_allowlisted_keys_still_work() -> None: controls._preset_curation = mock_curation controls.handle_keydown(keydown(pygame.K_f)) mock_curation.prompt_favourite.assert_called_once() + controls.handle_keydown(keydown(pygame.K_c)) + mock_curation.prompt_cast.assert_called_once() was_paused = controls.playback.paused controls.handle_keydown(keydown(pygame.K_SPACE)) diff --git a/tests/cleave/viz/test_help_overlay.py b/tests/cleave/viz/test_help_overlay.py index 458fbd4..30531fa 100644 --- a/tests/cleave/viz/test_help_overlay.py +++ b/tests/cleave/viz/test_help_overlay.py @@ -3,6 +3,7 @@ from __future__ import annotations from cleave.blend_modes import BLEND_MODE_HELP_ENTRIES, BLEND_MODES +from cleave.cue_roles import CUE_ROLE_MARKER_HELP_ENTRIES from cleave.config_schema import ( HIGHLIGHT_ROLLOFF_APPLY_MODE_HELP_ENTRIES, HIGHLIGHT_ROLLOFF_APPLY_MODES, @@ -184,13 +185,22 @@ def test_preset_file_help_titles() -> None: assert description.lines == ( "Currently active Milkdrop preset for this layer.", "[F/B/U] indicates favourited/blacklisted/user-defined.", + "[R:X] indicates cast role.", ) + assert description.entries == CUE_ROLE_MARKER_HELP_ENTRIES + assert dict(description.entries) == { + "[R:B]": "bed", + "[R:P]": "pulse", + "[R:L]": "lead", + "[R:A]": "accent", + } entries = dict(keyboard.entries) assert entries["Left/Right"] == "next/previous preset" assert entries["Ctrl + Left/Right"] == "next/previous large step" assert entries["F"] == "favourite preset" assert entries["B"] == "blacklist preset" - assert entries["R"] == "remove favourite / restore blacklist" + assert entries["C"] == "cast preset (bed/pulse/lead/accent)" + assert entries["R"] == "remove favourite / restore blacklist / remove cast" assert "Shift + +" not in entries @@ -435,12 +445,15 @@ def test_user_preset_item_help() -> None: assert description.lines == ( "Preset in the user-defined rotation set for this layer.", "[F/B] indicates favourited/blacklisted.", + "[R:X] indicates cast role.", ) + assert description.entries == CUE_ROLE_MARKER_HELP_ENTRIES entries = dict(keyboard.entries) assert entries["Delete"] == "remove preset" assert entries["F"] == "favourite preset" assert entries["B"] == "blacklist preset" - assert entries["R"] == "remove favourite / restore blacklist" + assert entries["C"] == "cast preset (bed/pulse/lead/accent)" + assert entries["R"] == "remove favourite / restore blacklist / remove cast" def test_user_preset_add_help() -> None: diff --git a/tests/cleave/viz/test_preset_curation_controls.py b/tests/cleave/viz/test_preset_curation_controls.py index dd14cf5..f6ee915 100644 --- a/tests/cleave/viz/test_preset_curation_controls.py +++ b/tests/cleave/viz/test_preset_curation_controls.py @@ -532,7 +532,7 @@ def test_confirm_restore_blacklist_without_origin_uses_choice_modal() -> None: assert unlocked == ["layer_1"] -def test_prompt_restore_both_markers_prefers_favourite() -> None: +def test_prompt_restore_both_markers_uses_choice_modal() -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) src = root / "pack" / "demo.milk" @@ -545,10 +545,11 @@ def test_prompt_restore_both_markers_prefers_favourite() -> None: view = modal.view_state() assert view is not None - assert view.message == "Remove favourite: demo.milk?" + assert view.message == "Remove from: demo.milk?" + assert view.options == ("Favourites", "Blacklist", "Cancel") -def test_prompt_restore_path_under_blacklist_wins_over_favourite_marker() -> None: +def test_prompt_restore_path_under_blacklist_with_favourite_uses_choice() -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) src = root / BLACKLIST_DIR / "demo.milk" @@ -560,7 +561,243 @@ def test_prompt_restore_path_under_blacklist_wins_over_favourite_marker() -> Non view = modal.view_state() assert view is not None - assert view.message == "Restore blacklisted preset: demo.milk?" + assert view.message == "Remove from: demo.milk?" + assert view.options == ("Favourites", "Blacklist", "Cancel") + + +def test_prompt_cast_not_yet_cast_uses_role_choice() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "pack" / "demo.milk" + _write(src, "milk") + locked: list[str] = [] + controller, _session, modal = _make_controller( + preset_root=root, + layer_bindings=lambda: noop_layer_bindings( + lock_preset_for_modal=lambda slot: locked.append(slot), + ), + ) + + controller.prompt_cast("layer_1", src) + + view = modal.view_state() + assert view is not None + assert view.message == "Cast preset: demo.milk?" + assert view.options == ("Bed", "Pulse", "Lead", "Accent", "Cancel") + assert locked == ["layer_1"] + + +def test_confirm_cast_copies_marks_and_rebuilds_timeline() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "pack" / "demo.milk" + _write(src, "milk") + switching_changes: list[str] = [] + unlocked: list[str] = [] + controller, session, modal = _make_controller( + preset_root=root, + layer_bindings=lambda: noop_layer_bindings( + on_preset_switching_change=lambda slot: switching_changes.append(slot), + unlock_preset_after_modal=lambda slot: unlocked.append(slot), + ), + ) + session.layers["layer_1"].preset_switching = "timeline" + + controller.prompt_cast("layer_1", src) + modal.handle_keydown(keydown(pygame.K_RETURN)) # bed + + assert (root / "roles" / "bed" / "demo.milk").exists() + assert src.exists() + assert controller._index.roles.get("demo.milk") == {"bed"} + assert controller._index.marker("demo.milk") == " [R:B]" + assert switching_changes == ["layer_1"] + assert unlocked == ["layer_1"] + + +def test_prompt_cast_already_cast_uses_move_wording() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "pack" / "demo.milk" + _write(src, "milk") + _write(root / "roles" / "bed" / "demo.milk", "cast") + controller, _session, modal = _make_controller(preset_root=root) + + controller.prompt_cast("layer_1", src) + + view = modal.view_state() + assert view is not None + assert view.message == "Move cast: demo.milk?" + assert view.options == ("Bed", "Pulse", "Lead", "Accent", "Cancel") + + +def test_confirm_cast_move_deletes_old_role_and_copies_new() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "pack" / "demo.milk" + _write(src, "milk") + old = root / "roles" / "bed" / "demo.milk" + _write(old, "cast") + switching_changes: list[str] = [] + unlocked: list[str] = [] + controller, session, modal = _make_controller( + preset_root=root, + layer_bindings=lambda: noop_layer_bindings( + on_preset_switching_change=lambda slot: switching_changes.append(slot), + unlock_preset_after_modal=lambda slot: unlocked.append(slot), + ), + ) + session.layers["layer_1"].preset_switching = "timeline" + + controller.prompt_cast("layer_1", src) + modal.handle_keydown(keydown(pygame.K_RIGHT)) # pulse + modal.handle_keydown(keydown(pygame.K_RETURN)) + + assert not old.exists() + assert (root / "roles" / "pulse" / "demo.milk").exists() + assert controller._index.roles.get("demo.milk") == {"pulse"} + assert switching_changes == ["layer_1"] + assert unlocked == ["layer_1"] + + +def test_prompt_restore_role_only_uses_yes_no() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "pack" / "demo.milk" + _write(src, "milk") + _write(root / "roles" / "lead" / "demo.milk", "cast") + locked: list[str] = [] + controller, _session, modal = _make_controller( + preset_root=root, + layer_bindings=lambda: noop_layer_bindings( + lock_preset_for_modal=lambda slot: locked.append(slot), + ), + ) + + controller.prompt_restore("layer_1", src) + + view = modal.view_state() + assert view is not None + assert view.message == "Remove cast (lead)?" + assert view.options == ("Yes", "No") + assert locked == ["layer_1"] + + +def test_confirm_restore_removes_role_cast() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "pack" / "demo.milk" + _write(src, "milk") + cast = root / "roles" / "bed" / "demo.milk" + _write(cast, "cast") + switching_changes: list[str] = [] + unlocked: list[str] = [] + controller, session, modal = _make_controller( + preset_root=root, + layer_bindings=lambda: noop_layer_bindings( + on_preset_switching_change=lambda slot: switching_changes.append(slot), + unlock_preset_after_modal=lambda slot: unlocked.append(slot), + ), + ) + session.layers["layer_1"].preset_switching = "timeline" + + controller.prompt_restore("layer_1", src) + modal.handle_keydown(keydown(pygame.K_RETURN)) + + assert not cast.exists() + assert "demo.milk" not in controller._index.roles + assert switching_changes == ["layer_1"] + assert unlocked == ["layer_1"] + + +def test_prompt_restore_favourite_and_role_uses_choice_modal() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "pack" / "demo.milk" + _write(src, "milk") + _write(root / FAVOURITES_DIR / "demo.milk", "fav") + _write(root / "roles" / "bed" / "demo.milk", "cast") + _write(root / "roles" / "lead" / "demo.milk", "cast") + controller, _session, modal = _make_controller(preset_root=root) + + controller.prompt_restore("layer_1", src) + + view = modal.view_state() + assert view is not None + assert view.message == "Remove from: demo.milk?" + assert view.options == ( + "Favourites", + "Cast (Bed)", + "Cast (Lead)", + "Cancel", + ) + + +def test_confirm_restore_choice_removes_only_selected_set() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "pack" / "demo.milk" + _write(src, "milk") + fav = root / FAVOURITES_DIR / "demo.milk" + cast = root / "roles" / "bed" / "demo.milk" + _write(fav, "fav") + _write(cast, "cast") + switching_changes: list[str] = [] + unlocked: list[str] = [] + controller, session, modal = _make_controller( + preset_root=root, + layer_bindings=lambda: noop_layer_bindings( + on_preset_switching_change=lambda slot: switching_changes.append(slot), + unlock_preset_after_modal=lambda slot: unlocked.append(slot), + ), + ) + session.layers["layer_1"].preset_switching = "timeline" + + controller.prompt_restore("layer_1", src) + modal.handle_keydown(keydown(pygame.K_RIGHT)) # cast (bed) + modal.handle_keydown(keydown(pygame.K_RETURN)) + + assert fav.exists() + assert not cast.exists() + assert "demo.milk" in controller._index.favourites + assert "demo.milk" not in controller._index.roles + assert switching_changes == ["layer_1"] + assert unlocked == ["layer_1"] + + +def test_cast_rebuild_skips_non_timeline_slots() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "pack" / "demo.milk" + _write(src, "milk") + switching_changes: list[str] = [] + controller, session, modal = _make_controller( + preset_root=root, + layer_bindings=lambda: noop_layer_bindings( + on_preset_switching_change=lambda slot: switching_changes.append(slot), + ), + ) + session.layers["layer_1"].preset_switching = "projectm" + + controller.prompt_cast("layer_1", src) + modal.handle_keydown(keydown(pygame.K_RETURN)) # bed + + assert (root / "roles" / "bed" / "demo.milk").exists() + assert switching_changes == [] + + +def test_prompt_restore_path_under_roles_uses_remove_cast() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + src = root / "roles" / "accent" / "demo.milk" + _write(src, "cast") + controller, _session, modal = _make_controller(preset_root=root) + + controller.prompt_restore("layer_1", src) + + view = modal.view_state() + assert view is not None + assert view.message == "Remove cast (accent)?" + assert view.options == ("Yes", "No") def test_prompt_restore_neither_marker_is_noop() -> None: diff --git a/tests/cleave/viz/test_preset_switching.py b/tests/cleave/viz/test_preset_switching.py index 8db7c8f..44593c6 100644 --- a/tests/cleave/viz/test_preset_switching.py +++ b/tests/cleave/viz/test_preset_switching.py @@ -2,6 +2,7 @@ from __future__ import annotations +import tempfile from pathlib import Path from unittest.mock import MagicMock, call, patch @@ -826,3 +827,36 @@ def _pools(preset_root: Path, role: str): expected = layer.preset_rotation.path_for(3) assert layer.pm.load_preset.call_args_list[-1] == call(expected, smooth=False) assert layer.timeline_switch_count == 3 + + +def test_apply_preset_switching_rebuilds_role_rotations_from_disk() -> None: + """Cast add/remove updates on-disk pools; switching change rebuilds role_rotations.""" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + pack = root / "pack" + pack.mkdir() + main = pack / "main.milk" + main.write_text("main", encoding="utf-8") + layer = _stem_layer(paths=(main,), index=0) + layer.playlist = PresetPlaylist(current_dir=pack, paths=(main,), index=0) + + apply_preset_switching( + layer, mode="timeline", rotation_set="directory", preset_root=root + ) + assert layer.role_rotations == {} + + cast = root / "roles" / "bed" / "cast.milk" + cast.parent.mkdir(parents=True) + cast.write_text("cast", encoding="utf-8") + + apply_preset_switching( + layer, mode="timeline", rotation_set="directory", preset_root=root + ) + assert "bed" in layer.role_rotations + assert layer.role_rotations["bed"].paths == (cast,) + + cast.unlink() + apply_preset_switching( + layer, mode="timeline", rotation_set="directory", preset_root=root + ) + assert layer.role_rotations == {} diff --git a/tests/cleave/viz/test_text_fit.py b/tests/cleave/viz/test_text_fit.py index 01f6d7e..36cb768 100644 --- a/tests/cleave/viz/test_text_fit.py +++ b/tests/cleave/viz/test_text_fit.py @@ -102,6 +102,47 @@ def test_fit_counter_label_preserves_full_fbu_marker() -> None: assert font.size(fitted)[0] <= width +def test_fit_counter_label_preserves_role_marker() -> None: + font = overlay_font() + label = "very-long-cast-preset-filename-that-needs-truncation.milk [R:B]" + suffix = " [R:B]" + width = font.size(suffix)[0] + font.size("very-long-cast…")[0] + fitted = fit_counter_label_to_width(font, label, width) + assert fitted.endswith(suffix) + assert fitted.startswith("very") + assert font.size(fitted)[0] <= width + + +def test_fit_counter_label_preserves_favourite_and_role_markers() -> None: + font = overlay_font() + label = "long-name-without-much-room-left.milk [F] [R:L]" + suffix = " [F] [R:L]" + width = font.size(suffix)[0] + font.size("long-name…")[0] + fitted = fit_counter_label_to_width(font, label, width) + assert fitted.endswith(suffix) + assert font.size(fitted)[0] <= width + + +def test_fit_counter_label_preserves_counter_fav_and_role_markers() -> None: + font = overlay_font() + label = "long-name-without-much-room-left.milk (2/5) [FB] [R:B] [R:A]" + suffix = " (2/5) [FB] [R:B] [R:A]" + width = font.size(suffix)[0] + font.size("long-name…")[0] + fitted = fit_counter_label_to_width(font, label, width) + assert fitted.endswith(suffix) + assert font.size(fitted)[0] <= width + + +def test_fit_counter_label_preserves_multiple_role_markers_only() -> None: + font = overlay_font() + label = "very-long-cast-preset-filename-that-needs-truncation.milk [R:B] [R:L]" + suffix = " [R:B] [R:L]" + width = font.size(suffix)[0] + font.size("very-long-cast…")[0] + fitted = fit_counter_label_to_width(font, label, width) + assert fitted.endswith(suffix) + assert font.size(fitted)[0] <= width + + def test_fit_counter_label_marker_only_user_preset() -> None: font = overlay_font() label = "very-long-user-preset-filename-that-needs-truncation.milk [B]" From 68645a2c4ccf553936d977d84200f13212a90a56 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Wed, 29 Jul 2026 21:45:33 +0100 Subject: [PATCH 06/12] Make the 0th cue at 0.00 selectable --- cleave/cue_roles.py | 13 ++++- cleave/timeline.py | 56 ++++++++++++++++++++-- cleave/timeline_presets/emit.py | 18 +++---- cleave/viz/row_semantics.py | 4 +- cleave/viz/timeline_controls.py | 9 ++-- cleave/viz/timeline_overlay.py | 16 ++++++- tests/cleave/test_timeline.py | 37 +++++++++++++- tests/cleave/test_timeline_presets.py | 33 ++++++++++--- tests/cleave/viz/test_controls.py | 6 ++- tests/cleave/viz/test_help_overlay.py | 12 ++--- tests/cleave/viz/test_timeline_controls.py | 53 ++++++++++++++++++++ tests/cleave/viz/test_timeline_overlay.py | 19 ++++++++ 12 files changed, 239 insertions(+), 37 deletions(-) diff --git a/cleave/cue_roles.py b/cleave/cue_roles.py index abe9d33..1cd6cec 100644 --- a/cleave/cue_roles.py +++ b/cleave/cue_roles.py @@ -18,8 +18,19 @@ "accent": "A", } +CUE_ROLE_HELP_BLURB: dict[CueRole, str] = { + "bed": "calm dark foundation", + "pulse": "beat-reactive mid-ground", + "lead": "busy bright hero", + "accent": "short bright hits", +} + CUE_ROLE_MARKER_HELP_ENTRIES: tuple[tuple[str, str], ...] = tuple( - (f"[R:{CUE_ROLE_MARKER_LETTER[role]}]", role) for role in CUE_ROLES + ( + f"[R:{CUE_ROLE_MARKER_LETTER[role]}]", + f"{role} ({CUE_ROLE_HELP_BLURB[role]})", + ) + for role in CUE_ROLES ) diff --git a/cleave/timeline.py b/cleave/timeline.py index 0735b16..35ee48c 100644 --- a/cleave/timeline.py +++ b/cleave/timeline.py @@ -79,9 +79,45 @@ def cue_editable_for_blend_role(cue: SlotCue) -> bool: return float(cue.level) > LEVEL_EPS +def opening_baseline_editable(lane: TimelineLane) -> bool: + """True when the opening period is on via ``baseline`` with no cue at ``t=0``. + + Generative Apply stores the first on-level in ``baseline``, so there is no + stored cue to select for cast/blend until one is materialized at ``0.0``. + """ + return ( + lane.baseline is not None + and float(lane.baseline) > LEVEL_EPS + and not any(cue.t == 0.0 for cue in lane.cues) + ) + + +def opening_cue(lane: TimelineLane) -> SlotCue | None: + """Synthetic on-cue at ``t=0`` for an editable opening baseline, else None.""" + if not opening_baseline_editable(lane): + return None + return SlotCue(t=0.0, level=float(lane.baseline)) + + +def cue_at_time(lane: TimelineLane, cue_t: float) -> SlotCue | None: + """Stored cue at ``cue_t``, or the synthetic opening cue when ``cue_t`` is 0.""" + for cue in lane.cues: + if cue.t == cue_t: + return cue + if cue_t == 0.0: + return opening_cue(lane) + return None + + def navigable_cue_times(lane: TimelineLane) -> list[float]: - """Cue times eligible for ``,`` / ``.`` selection (level above off).""" - return [cue.t for cue in lane.cues if cue_editable_for_blend_role(cue)] + """Cue times eligible for ``,`` / ``.`` selection (level above off). + + Includes ``0.0`` when the opening period is on via baseline only. + """ + times = [cue.t for cue in lane.cues if cue_editable_for_blend_role(cue)] + if opening_baseline_editable(lane): + times.insert(0, 0.0) + return times def canonicalize( @@ -475,7 +511,21 @@ def update_lane_cue( blend: BlendMode | None, role: CueRole | None, ) -> TimelineLane: - """Update ``blend`` / ``role`` on the cue at ``cue_t`` without changing level.""" + """Update ``blend`` / ``role`` on the cue at ``cue_t`` without changing level. + + When ``cue_t`` is ``0.0`` and the opening period is only a baseline on-level, + materialize a real cue at ``0.0`` (baseline becomes ``0.0``) so cast/blend + persist and preset-switch rises can read the role. + """ + if cue_t == 0.0 and opening_baseline_editable(lane): + level = float(lane.baseline) + return TimelineLane( + baseline=0.0, + cues=canonicalize( + 0.0, + [SlotCue(t=0.0, level=level, blend=blend, role=role), *lane.cues], + ), + ) updated = [ replace(cue, blend=blend, role=role) if cue.t == cue_t else cue for cue in lane.cues diff --git a/cleave/timeline_presets/emit.py b/cleave/timeline_presets/emit.py index 0bd355f..2a2fde4 100644 --- a/cleave/timeline_presets/emit.py +++ b/cleave/timeline_presets/emit.py @@ -19,25 +19,25 @@ def cues_from_states( slots: Sequence[str], states: Sequence[tuple[float, Mapping[str, float]]], ) -> dict[str, TimelineLane]: - """Build per-slot lanes: baseline from the first state, then level transitions. + """Build per-slot lanes from level states. - Absent slots in a state mapping are treated as level ``0.0``. + Baseline is always ``0.0``. The first state's levels are emitted as cues at + ``t=0`` (so an opening on-section is selectable for cast/blend). Later + states use their own times. Absent slots in a state mapping are ``0.0``. """ slot_list = list(slots) if not states: return {slot: empty_lane() for slot in slot_list} - _t0, first_levels = states[0] - baselines = { - slot: float(first_levels.get(slot, 0.0)) for slot in slot_list - } + baselines = {slot: 0.0 for slot in slot_list} cues_by_slot: dict[str, list[SlotCue]] = {slot: [] for slot in slot_list} - prev = {slot: baselines[slot] for slot in slot_list} - for t, levels in states[1:]: + prev = {slot: 0.0 for slot in slot_list} + for index, (t, levels) in enumerate(states): + cue_t = 0.0 if index == 0 else float(t) for slot in slot_list: now = float(levels.get(slot, 0.0)) if not levels_equal(now, prev[slot]): - cues_by_slot[slot].append(SlotCue(t=float(t), level=now)) + cues_by_slot[slot].append(SlotCue(t=cue_t, level=now)) prev[slot] = now return { slot: TimelineLane( diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index 179a0ac..42eb580 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -213,7 +213,7 @@ class RowBehavior: help_description=( "Currently active Milkdrop preset for this layer.", "[F/B/U] indicates favourited/blacklisted/user-defined.", - "[R:X] indicates cast role.", + "[R:X] indicates the chosen cast role.", ), help_mode_entries=CUE_ROLE_MARKER_HELP_ENTRIES, repeatable=True, @@ -247,7 +247,7 @@ class RowBehavior: help_description=( "Preset in the user-defined rotation set for this layer.", "[F/B] indicates favourited/blacklisted.", - "[R:X] indicates cast role.", + "[R:X] indicates the chosen cast role.", ), help_mode_entries=CUE_ROLE_MARKER_HELP_ENTRIES, ), diff --git a/cleave/viz/timeline_controls.py b/cleave/viz/timeline_controls.py index 27622d4..bd88c16 100644 --- a/cleave/viz/timeline_controls.py +++ b/cleave/viz/timeline_controls.py @@ -12,6 +12,7 @@ LEVEL_EPS, SlotCue, canonicalize, + cue_at_time, cue_editable_for_blend_role, empty_lane, navigable_cue_times, @@ -259,10 +260,10 @@ def _selected_cue(self) -> tuple[str, SlotCue] | None: if selected is None: return None lane = tl.lanes.get(slot) or empty_lane() - for cue in lane.cues: - if cue.t == selected: - return slot, cue - return None + cue = cue_at_time(lane, selected) + if cue is None: + return None + return slot, cue def _cycle_selected_cue_blend(self, *, forward: bool) -> None: if not self._cue_edits_allowed(): diff --git a/cleave/viz/timeline_overlay.py b/cleave/viz/timeline_overlay.py index e0dcf85..b51631e 100644 --- a/cleave/viz/timeline_overlay.py +++ b/cleave/viz/timeline_overlay.py @@ -19,6 +19,7 @@ lane_level_breakpoints, lane_tick_times, levels_equal, + opening_cue, stem_abbreviation, ) from cleave.viz.material_icons import visibility_icon_slot_width @@ -310,11 +311,19 @@ def bar_level_breakpoints_for_row( def bar_cues_for_row(state: TimelineViewState, slot: str) -> list[SlotCue]: - """Cues whose ticks are drawn for one timeline row.""" + """Cues whose ticks are drawn for one timeline row. + + Includes a synthetic ``t=0`` cue when the opening period is on via baseline + only, so the first section can be selected for cast/blend. + """ duration = state.duration_sec lane = _lane_for_view(state, slot) if not (state.recording and slot in state.record_baseline): - return [cue for cue in lane.cues if 0.0 <= cue.t <= duration] + cues = [cue for cue in lane.cues if 0.0 <= cue.t <= duration] + synthetic = opening_cue(lane) + if synthetic is not None and duration >= 0.0: + cues = [synthetic, *cues] + return cues record_start = state.record_slot_start_sec.get(slot, state.record_start_sec) if record_start is None: @@ -335,6 +344,9 @@ def bar_cues_for_row(state: TimelineViewState, slot: str) -> list[SlotCue]: by_t = {cue.t: cue for cue in committed} for cue in live: by_t[cue.t] = cue + synthetic = opening_cue(lane) + if synthetic is not None and 0.0 not in by_t and duration >= 0.0: + by_t[0.0] = synthetic return [by_t[t] for t in sorted(by_t)] diff --git a/tests/cleave/test_timeline.py b/tests/cleave/test_timeline.py index fc874ac..09948d7 100644 --- a/tests/cleave/test_timeline.py +++ b/tests/cleave/test_timeline.py @@ -208,7 +208,12 @@ def test_canonicalize_off_with_dead_metadata_still_level_only() -> None: def test_cue_editable_and_navigable_times() -> None: - from cleave.timeline import cue_editable_for_blend_role, navigable_cue_times + from cleave.timeline import ( + cue_editable_for_blend_role, + navigable_cue_times, + opening_baseline_editable, + opening_cue, + ) on = SlotCue(t=1.0, level=0.5) mid = SlotCue(t=2.0, level=1.0) @@ -218,6 +223,36 @@ def test_cue_editable_and_navigable_times() -> None: assert not cue_editable_for_blend_role(off) lane = TimelineLane(baseline=0.0, cues=[on, mid, off]) assert navigable_cue_times(lane) == [1.0, 2.0] + assert not opening_baseline_editable(lane) + assert opening_cue(lane) is None + + opening_on = TimelineLane(baseline=0.5, cues=[SlotCue(t=10.0, level=0.0)]) + assert opening_baseline_editable(opening_on) + assert opening_cue(opening_on) == SlotCue(t=0.0, level=0.5) + assert navigable_cue_times(opening_on) == [0.0] + + already_at_zero = TimelineLane( + baseline=0.0, + cues=[SlotCue(t=0.0, level=0.5), SlotCue(t=10.0, level=0.0)], + ) + assert not opening_baseline_editable(already_at_zero) + assert navigable_cue_times(already_at_zero) == [0.0] + + +def test_update_lane_cue_materializes_opening_baseline() -> None: + from cleave.timeline import update_lane_cue + + lane = TimelineLane( + baseline=0.5, + cues=[SlotCue(t=10.0, level=0.0), SlotCue(t=20.0, level=1.0)], + ) + updated = update_lane_cue(lane, 0.0, blend="add", role="lead") + assert updated.baseline == 0.0 + assert updated.cues[0] == SlotCue(t=0.0, level=0.5, blend="add", role="lead") + assert updated.cues[1:] == [ + SlotCue(t=10.0, level=0.0), + SlotCue(t=20.0, level=1.0), + ] def test_lane_blend_at_holds_and_reverts() -> None: diff --git a/tests/cleave/test_timeline_presets.py b/tests/cleave/test_timeline_presets.py index f973c10..663e5d1 100644 --- a/tests/cleave/test_timeline_presets.py +++ b/tests/cleave/test_timeline_presets.py @@ -7,7 +7,7 @@ import pytest -from cleave.timeline import LEVEL_EPS, TimelineLane, empty_lane, lane_level_at +from cleave.timeline import LEVEL_EPS, SlotCue, TimelineLane, empty_lane, lane_level_at from cleave.timeline_presets import ( MIN_SWITCH_GAP_BARS, MIN_SWITCH_GAP_SEC, @@ -286,8 +286,8 @@ def test_preset_invariants(builder, n: int, duration_sec: float) -> None: _assert_cues_on_bars(lanes, thinned if thinned else bars) _assert_min_gaps(lanes, bars, duration_sec) if n == 1: - assert lanes[slots[0]].baseline == 1.0 - assert lanes[slots[0]].cues == [] + assert lanes[slots[0]].baseline == 0.0 + assert lanes[slots[0]].cues == [SlotCue(t=0.0, level=1.0)] @pytest.mark.parametrize("builder", ALL_BUILDERS) @@ -313,9 +313,19 @@ def test_short_duration_still_never_zero() -> None: slots = _slots(4) for builder in ALL_BUILDERS: lanes, bars = _build(builder, slots, 4.0, random.Random(7)) - assert all(not lane.cues for lane in lanes.values()) + # Opening-only: cues only at t=0 (no mid-song transitions). + assert all( + all(cue.t == 0.0 for cue in lane.cues) for lane in lanes.values() + ) _assert_never_zero(lanes, slots, 4.0) - assert sum(1 for lane in lanes.values() if lane.baseline) >= 1 + assert ( + sum( + 1 + for lane in lanes.values() + if lane_level_at(lane, 0.0, inherit=0.0) > LEVEL_EPS + ) + >= 1 + ) _assert_cues_on_bars(lanes, bars) @@ -371,7 +381,14 @@ def test_voice_leading_mostly_smooth(builder) -> None: def test_breathing_starts_with_one_layer() -> None: slots = _slots(5) lanes, _bars = _build(build_breathing_cues, slots, 120.0, random.Random(11)) - assert sum(1 for lane in lanes.values() if lane.baseline) == 1 + assert ( + sum( + 1 + for lane in lanes.values() + if lane_level_at(lane, 0.0, inherit=0.0) > LEVEL_EPS + ) + == 1 + ) def test_pulse_rotates_singles() -> None: @@ -404,7 +421,9 @@ def test_empty_bar_times_returns_opening_only() -> None: slots = _slots(4) for builder in ALL_BUILDERS: lanes = builder(slots, 60.0, random.Random(1), bar_times=()) - assert all(not lane.cues for lane in lanes.values()) + assert all( + all(cue.t == 0.0 for cue in lane.cues) for lane in lanes.values() + ) _assert_never_zero(lanes, slots, 60.0) diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index eb058ca..97e698c 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -24,7 +24,7 @@ preset_filename_display, scan_preset_playlist, ) -from cleave.timeline import SlotCue, TimelineLane, canonicalize +from cleave.timeline import SlotCue, TimelineLane, canonicalize, lane_level_at from cleave.project import load_manifest, write_manifest from cleave.viz.focus_nav import MainFocus, TimelineFocus from cleave.viz.key_repeat import mod_shift @@ -1664,7 +1664,9 @@ def test_timeline_presets_breathing_clears_and_applies() -> None: assert lanes != prior assert set(lanes) == set(controls.session.layer_z_order) assert all(lane.baseline is not None for lane in lanes.values()) - assert any(lane.baseline for lane in lanes.values()) + assert any( + lane_level_at(lane, 0.0, inherit=0.0) > 0.0 for lane in lanes.values() + ) def test_timeline_presets_arc_clears_and_applies() -> None: diff --git a/tests/cleave/viz/test_help_overlay.py b/tests/cleave/viz/test_help_overlay.py index 30531fa..8b6d4a0 100644 --- a/tests/cleave/viz/test_help_overlay.py +++ b/tests/cleave/viz/test_help_overlay.py @@ -185,14 +185,14 @@ def test_preset_file_help_titles() -> None: assert description.lines == ( "Currently active Milkdrop preset for this layer.", "[F/B/U] indicates favourited/blacklisted/user-defined.", - "[R:X] indicates cast role.", + "[R:X] indicates the chosen cast role.", ) assert description.entries == CUE_ROLE_MARKER_HELP_ENTRIES assert dict(description.entries) == { - "[R:B]": "bed", - "[R:P]": "pulse", - "[R:L]": "lead", - "[R:A]": "accent", + "[R:B]": "bed (calm dark foundation)", + "[R:P]": "pulse (beat-reactive mid-ground)", + "[R:L]": "lead (busy bright hero)", + "[R:A]": "accent (short bright hits)", } entries = dict(keyboard.entries) assert entries["Left/Right"] == "next/previous preset" @@ -445,7 +445,7 @@ def test_user_preset_item_help() -> None: assert description.lines == ( "Preset in the user-defined rotation set for this layer.", "[F/B] indicates favourited/blacklisted.", - "[R:X] indicates cast role.", + "[R:X] indicates the chosen cast role.", ) assert description.entries == CUE_ROLE_MARKER_HELP_ENTRIES entries = dict(keyboard.entries) diff --git a/tests/cleave/viz/test_timeline_controls.py b/tests/cleave/viz/test_timeline_controls.py index 7ac84ab..72cf500 100644 --- a/tests/cleave/viz/test_timeline_controls.py +++ b/tests/cleave/viz/test_timeline_controls.py @@ -1128,6 +1128,59 @@ def segments() -> list[tuple[float, float]]: assert expected[0][0] == 0.0 +def test_comma_period_selects_opening_baseline_cue() -> None: + lanes = { + "layer_1": TimelineLane( + baseline=0.5, + cues=canonicalize( + 0.5, + [ + SlotCue(t=10.0, level=0.0), + SlotCue(t=20.0, level=1.0), + ], + ), + ), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=0.0, + ) + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t["layer_1"] == 0.0 + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t["layer_1"] == 20.0 + controls.handle_keydown(keydown(pygame.K_COMMA)) + assert session.timeline.selected_cue_t["layer_1"] == 0.0 + + +def test_c_casts_opening_baseline_materializing_cue_at_zero() -> None: + from cleave.cue_roles import CUE_ROLES + + lanes = { + "layer_1": TimelineLane( + baseline=0.5, + cues=canonicalize( + 0.5, + [ + SlotCue(t=10.0, level=0.0), + SlotCue(t=20.0, level=1.0), + ], + ), + ), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=0.0, + ) + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t["layer_1"] == 0.0 + controls.handle_keydown(keydown(pygame.K_c)) + lane = session.timeline.lanes["layer_1"] + assert lane.baseline == 0.0 + assert lane.cues[0] == SlotCue(t=0.0, level=0.5, role=CUE_ROLES[0]) + assert session.timeline.selected_cue_t["layer_1"] == 0.0 + + def test_comma_period_select_nearest_then_step_cues() -> None: # Off at 10 is skipped; navigable on cues are 4 and 16. lanes = { diff --git a/tests/cleave/viz/test_timeline_overlay.py b/tests/cleave/viz/test_timeline_overlay.py index c85618f..afcf166 100644 --- a/tests/cleave/viz/test_timeline_overlay.py +++ b/tests/cleave/viz/test_timeline_overlay.py @@ -993,6 +993,25 @@ def test_role_glyph_not_drawn_on_off_cue() -> None: assert not overlay._cache.last_glyph_rects +def test_bar_cues_include_synthetic_opening_baseline() -> None: + from cleave.viz.timeline_overlay import bar_cues_for_row + + state = _view_state( + layer_z_order=["layer_1"], + defaults={"layer_1": 1.0}, + lanes={ + "layer_1": TimelineLane( + baseline=0.5, + cues=[SlotCue(t=10.0, level=0.0), SlotCue(t=20.0, level=1.0)], + ) + }, + duration_sec=100.0, + ) + cues = bar_cues_for_row(state, "layer_1") + assert cues[0] == SlotCue(t=0.0, level=0.5) + assert [cue.t for cue in cues] == [0.0, 10.0, 20.0] + + def test_selected_cue_highlight_drawn_on_static_panel() -> None: pygame.init() overlay = TimelineOverlay() From ce2d106112bcd02bd5d6e471f7bfadf236eeac7c Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Wed, 29 Jul 2026 22:45:57 +0100 Subject: [PATCH 07/12] Add user controlled opacity at cue points --- .cursor/rules/live-tuning-ui.mdc | 2 +- cleave/timeline.py | 42 +++++-- cleave/viz/app.py | 6 +- cleave/viz/help_content.py | 8 +- cleave/viz/key_repeat.py | 9 +- cleave/viz/timeline_controls.py | 78 +++++++++++- cleave/viz/timeline_overlay.py | 8 +- docs/improved-timeline-presets.md | 6 +- docs/todos.md | 7 +- tests/cleave/test_timeline.py | 30 +++++ tests/cleave/viz/test_key_repeat.py | 13 ++ tests/cleave/viz/test_timeline_controls.py | 140 ++++++++++++++++++++- tests/cleave/viz/test_timeline_overlay.py | 4 +- 13 files changed, 319 insertions(+), 34 deletions(-) diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index 4ec8ac0..477f33d 100644 --- a/.cursor/rules/live-tuning-ui.mdc +++ b/.cursor/rules/live-tuning-ui.mdc @@ -56,7 +56,7 @@ Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always ## Timeline panel -Separate bottom overlay ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py), [cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)). Open with **t** (opens strip and enters submenu on row 0) or **Right** on **Render: TIMELINE** (strip only; **Down** enters submenu), including when `timeline.enabled` is false. **Left** on that header or **t** when the strip is open closes it and returns focus to **Render: TIMELINE**. Strip open does not hide the main overlay or route keys until `submenu_focused` (**t** from the main tree sets this immediately; **Down** from the header when the strip is already open). When the strip is open, **Up**/**Down** use one focus ring ([cleave/viz/focus_nav.py](cleave/viz/focus_nav.py)): main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, with uniform modulo wrap. **Down** from the last timeline row wraps to **Settings**; **Up** from **Settings** wraps to the last timeline row. **Up** at timeline row 0 returns focus to **Render: TIMELINE** without closing the strip. **Up**/**Down** always route through the main tuning controls; other timeline keys route to the timeline strip when `submenu_focused`. **Esc** or **t** while in the submenu closes the strip and returns focus to **Render: TIMELINE**. Rows follow `layer_z_order`; labels use stem abbreviations (D/B/V/O). Each track owns a lane (`TimelineLane`: explicit `baseline` plus ordered `SlotCue(t, level, blend?, role?)` transitions in [cleave/timeline.py](cleave/timeline.py)); `level` is required; optional `blend` is held state (inherits the layer static blend when `None`); optional `role` (`bed` / `pulse` / `lead` / `accent`) is an on-transition cast into `preset_root/roles//`. Edits (`punch_lane`, snap, presets, cue authoring) are lane-local. Each row shows a monitor eye beside the label, the cue bar, and a committed-timeline eye at the far right: **left** = monitor/output (`effective_layer_enabled`; gold `OVERRIDE_BG` when stem is in `TimelineRuntime.override_slots` or when recording and the row is armed). Armed rows during record use `record_baseline` + per-slot `record_buffer` toggles only, not committed lane cues. Record start baseline is not drawn on the bar; only transition cues in that slot's `record_buffer` show ticks. **right** = committed lane level at playhead (`timeline_committed_level`; eye alpha follows the level; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **a** arms a row (`ARMED_BG` red fill, distinct from override eye). **Space** pause snapshots current output into `monitor` and sets `preview_active` (resume clears both); while recording and playing, **Space** stops the take and pauses instead. Num keys **1**-**8** (main row and numpad) toggle layer visibility while paused (preview via `monitor` or override via `override_visible`; adds stem to override when needed), write record buffer when recording (armed only), toggle `override_visible` while playing for stems in `override_slots` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_slots` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip uses **Shift+Left** / **Shift+Right** for 2s transport seek (not solo). **`,`** / **`.`** select the previous / next on cue (`level > LEVEL_EPS`, including mid-on level changes) on the focused lane; pure off cues are skipped. First press (or when selection is missing / on an off) picks the navigable cue nearest the playhead. **b** / **Shift+B** cycle the selected on cue's blend through inherit (`None`) then `BLEND_MODES`; **c** / **Shift+C** cycle its role (cast) through `None` then `CUE_ROLES`; both no-op on offs. Cast and blend are authored for the next on / visible period (like preset switching at on-transition); `canonicalize` strips `blend` and `role` from off cues. Cue selection and blend/role edits are refused while the timeline is locked or recording. Selection is session-only on `TimelineRuntime.selected_cue_t` (keyed by slot; per-track memory). The selected tick, flash, and badge readout draw only for the focused timeline row; other slots keep their remembered `selected_cue_t` and restore the settled yellow highlight when focus returns (flash restarts only when `,` / `.` changes the cue on that track). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed lanes in range (playback keeps running). **Ctrl+Space** starts record the same way; while recording it stops the take and pauses (no preview). `preview_active` / `monitor` / `override_slots` / `override_visible` / `selected_cue_t` are session-only on `TimelineRuntime`. Per-lane state persists under root `timeline.lanes` in YAML (written last in snapshots). The strip draws each lane as a variable-height level bar from `lane_level_breakpoints` (bottom-filled polygons between breakpoints; remainder dark so a level-0 lane still reads as a full-height bed; colour lerps by mean segment level). Fade groups shape the breakpoint slopes; cue ticks and the bar grid are unchanged. The selected cue's tick draws full row height in `HIGHLIGHT` at `SELECTED_CUE_TICK_WIDTH` (thinner than the flash width, thicker than normal ticks) and live-patches a 3s thick HIGHLIGHT/`ARMED_BG` (yellow/red) blink at `SELECTED_CUE_FLASH_TICK_WIDTH` via `selected_cue_flash_start_ms` (same cadence as arm flash); after the flash expires it settles on the thinner yellow tick; on cues with a role draw a bold single-letter glyph (`B` / `P` / `L` / `A`) at the bottom of the level bar, offset left of off edges and right of on edges (inside the enabled segment) via per-pixel RGB XOR (pygame 2.6 has no `BLEND_XOR`), painted last after bars, ticks, markers, and the playhead (never on off cues); the badge strip shows a selected-cue readout (`[mm:ss] lvl 1.00 blend add cast lead`, with `-` for unset blend/role). +Separate bottom overlay ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py), [cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)). Open with **t** (opens strip and enters submenu on row 0) or **Right** on **Render: TIMELINE** (strip only; **Down** enters submenu), including when `timeline.enabled` is false. **Left** on that header or **t** when the strip is open closes it and returns focus to **Render: TIMELINE**. Strip open does not hide the main overlay or route keys until `submenu_focused` (**t** from the main tree sets this immediately; **Down** from the header when the strip is already open). When the strip is open, **Up**/**Down** use one focus ring ([cleave/viz/focus_nav.py](cleave/viz/focus_nav.py)): main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, with uniform modulo wrap. **Down** from the last timeline row wraps to **Settings**; **Up** from **Settings** wraps to the last timeline row. **Up** at timeline row 0 returns focus to **Render: TIMELINE** without closing the strip. **Up**/**Down** always route through the main tuning controls; other timeline keys route to the timeline strip when `submenu_focused`. **Esc** or **t** while in the submenu closes the strip and returns focus to **Render: TIMELINE**. Rows follow `layer_z_order`; labels use stem abbreviations (D/B/V/O). Each track owns a lane (`TimelineLane`: explicit `baseline` plus ordered `SlotCue(t, level, blend?, role?)` transitions in [cleave/timeline.py](cleave/timeline.py)); `level` is required; optional `blend` is held state (inherits the layer static blend when `None`); optional `role` (`bed` / `pulse` / `lead` / `accent`) is an on-transition cast into `preset_root/roles//`. Edits (`punch_lane`, snap, presets, cue authoring) are lane-local. Each row shows a monitor eye beside the label, the cue bar, and a committed-timeline eye at the far right: **left** = monitor/output (`effective_layer_enabled`; gold `OVERRIDE_BG` when stem is in `TimelineRuntime.override_slots` or when recording and the row is armed). Armed rows during record use `record_baseline` + per-slot `record_buffer` toggles only, not committed lane cues. Record start baseline is not drawn on the bar; only transition cues in that slot's `record_buffer` show ticks. **right** = committed lane level at playhead (`timeline_committed_level`; eye alpha follows the level; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **a** arms a row (`ARMED_BG` red fill, distinct from override eye). **Space** pause snapshots current output into `monitor` and sets `preview_active` (resume clears both); while recording and playing, **Space** stops the take and pauses instead. Num keys **1**-**8** (main row and numpad) toggle layer visibility while paused (preview via `monitor` or override via `override_visible`; adds stem to override when needed), write record buffer when recording (armed only), toggle `override_visible` while playing for stems in `override_slots` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_slots` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip uses **Shift+Left** / **Shift+Right** for 2s transport seek (not solo). **`,`** / **`.`** select the previous / next on cue (`level > LEVEL_EPS`, including mid-on level changes) on the focused lane; pure off cues are skipped. First press (or when selection is missing / on an off) picks the navigable cue nearest the playhead. **Shift+,** / **Shift+.** nudge the selected on cue's timeline opacity by -/+ 1% (`LEVEL_STEP_SMALL`); **Ctrl+,** / **Ctrl+.** by -/+ 10% (`LEVEL_STEP_LARGE`); floored at 10% (`LEVEL_EDIT_MIN`) so nudging cannot erase the cue; Ctrl wins when both modifiers are held. Timeline opacity multiplies into the layer opacity fader (`fbo.opacity`); the YAML field remains `level`. Generative Apply may still quantise to `LEVEL_QUANTUM`. **b** / **Shift+B** cycle the selected on cue's blend through inherit (`None`) then `BLEND_MODES`; **c** / **Shift+C** cycle its role (cast) through `None` then `CUE_ROLES`; both no-op on offs. Cast and blend are authored for the next on / visible period (like preset switching at on-transition); `canonicalize` strips `blend` and `role` from off cues. Cue selection, opacity nudges, and blend/role edits are refused while the timeline is locked or recording. Selection is session-only on `TimelineRuntime.selected_cue_t` (keyed by slot; per-track memory). The selected tick, flash, and badge readout draw only for the focused timeline row; other slots keep their remembered `selected_cue_t` and restore the settled yellow highlight when focus returns (flash restarts only when `,` / `.` changes the cue on that track). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed lanes in range (playback keeps running). **Ctrl+Space** starts record the same way; while recording it stops the take and pauses (no preview). `preview_active` / `monitor` / `override_slots` / `override_visible` / `selected_cue_t` are session-only on `TimelineRuntime`. Per-lane state persists under root `timeline.lanes` in YAML (written last in snapshots). The strip draws each lane as a variable-height level bar from `lane_level_breakpoints` (bottom-filled polygons between breakpoints; remainder dark so a level-0 lane still reads as a full-height bed; colour lerps by mean segment level). Fade groups shape the breakpoint slopes; cue ticks and the bar grid are unchanged. The selected cue's tick draws full row height in `HIGHLIGHT` at `SELECTED_CUE_TICK_WIDTH` (thinner than the flash width, thicker than normal ticks) and live-patches a 3s thick HIGHLIGHT/`ARMED_BG` (yellow/red) blink at `SELECTED_CUE_FLASH_TICK_WIDTH` via `selected_cue_flash_start_ms` (same cadence as arm flash); after the flash expires it settles on the thinner yellow tick; on cues with a role draw a bold single-letter glyph (`B` / `P` / `L` / `A`) at the bottom of the level bar, offset left of off edges and right of on edges (inside the enabled segment) via per-pixel RGB XOR (pygame 2.6 has no `BLEND_XOR`), painted last after bars, ticks, markers, and the playhead (never on off cues); the badge strip shows a selected-cue readout (`[mm:ss] opacity 100% blend add cast lead`, with `-` for unset blend/role). ## Header rows section diff --git a/cleave/timeline.py b/cleave/timeline.py index 35ee48c..97d0a91 100644 --- a/cleave/timeline.py +++ b/cleave/timeline.py @@ -16,6 +16,10 @@ RECORD_DEBOUNCE_SEC = 0.08 SONG_MARKER_FADE_MATCH_EPS = 1e-3 LEVEL_QUANTUM = 0.25 +# Manual strip nudges (percent of full); generative Apply still uses LEVEL_QUANTUM. +LEVEL_STEP_SMALL = 0.01 +LEVEL_STEP_LARGE = 0.10 +LEVEL_EDIT_MIN = 0.10 LEVEL_EPS = 1e-6 _STEM_SOURCE_ABBREVIATIONS = { @@ -510,26 +514,44 @@ def update_lane_cue( *, blend: BlendMode | None, role: CueRole | None, + level: float | None = None, ) -> TimelineLane: - """Update ``blend`` / ``role`` on the cue at ``cue_t`` without changing level. + """Update ``blend`` / ``role`` / optional ``level`` on the cue at ``cue_t``. - When ``cue_t`` is ``0.0`` and the opening period is only a baseline on-level, - materialize a real cue at ``0.0`` (baseline becomes ``0.0``) so cast/blend - persist and preset-switch rises can read the role. + When ``cue_t`` is ``0.0`` and the opening period is only a baseline on-level: + level-only edits (``blend`` and ``role`` both ``None``) update ``baseline`` + in place; otherwise materialize a real cue at ``0.0`` (baseline becomes + ``0.0``) so cast/blend persist and preset-switch rises can read the role. """ if cue_t == 0.0 and opening_baseline_editable(lane): - level = float(lane.baseline) + new_level = ( + float(lane.baseline) if level is None else clamp_level(level) + ) + if blend is None and role is None: + if level is None: + return copy_lane(lane) + baseline = 0.0 if new_level <= LEVEL_EPS else new_level + return TimelineLane( + baseline=baseline, + cues=canonicalize(baseline, list(lane.cues)), + ) return TimelineLane( baseline=0.0, cues=canonicalize( 0.0, - [SlotCue(t=0.0, level=level, blend=blend, role=role), *lane.cues], + [ + SlotCue(t=0.0, level=new_level, blend=blend, role=role), + *lane.cues, + ], ), ) - updated = [ - replace(cue, blend=blend, role=role) if cue.t == cue_t else cue - for cue in lane.cues - ] + updated: list[SlotCue] = [] + for cue in lane.cues: + if cue.t != cue_t: + updated.append(cue) + continue + new_level = cue.level if level is None else clamp_level(level) + updated.append(replace(cue, level=new_level, blend=blend, role=role)) return TimelineLane( baseline=lane.baseline, cues=canonicalize(lane.baseline, updated), diff --git a/cleave/viz/app.py b/cleave/viz/app.py index a13073b..31fb0bc 100644 --- a/cleave/viz/app.py +++ b/cleave/viz/app.py @@ -653,7 +653,11 @@ def on_progress(message: str) -> None: self._overlay_dt = clock.tick() / 1000.0 rt.controls.tick(self._overlay_dt) - if rt.controls.key_repeat_armed: + rt.timeline_controls.tick(self._overlay_dt) + if ( + rt.controls.key_repeat_armed + or rt.timeline_controls.key_repeat_armed + ): rt.overlay.notify_input() pm_fps_governor.observe(measured_fps) diff --git a/cleave/viz/help_content.py b/cleave/viz/help_content.py index e45e1c6..fba0aec 100644 --- a/cleave/viz/help_content.py +++ b/cleave/viz/help_content.py @@ -225,9 +225,11 @@ def timeline_strip_section( if not recording: entries.extend( ( - (", / .", "select previous / next on cue"), - ("B / Shift+B", "cycle on-cue blend"), - ("C / Shift+C", "cycle on-cue cast"), + (", / .", "select previous / next cue"), + ("Shift + , / .", "nudge cue opacity -/+ 1%"), + ("Ctrl + , / .", "nudge cue opacity -/+ 10%"), + ("B / Shift+B", "cycle cue blend"), + ("C / Shift+C", "cycle cue cast"), ) ) diff --git a/cleave/viz/key_repeat.py b/cleave/viz/key_repeat.py index d73b3d0..0cf341b 100644 --- a/cleave/viz/key_repeat.py +++ b/cleave/viz/key_repeat.py @@ -13,7 +13,14 @@ ACCEL_AFTER_SEC = 1.0 _REPEAT_KEYS = frozenset( - {pygame.K_LEFT, pygame.K_RIGHT, pygame.K_UP, pygame.K_DOWN} + { + pygame.K_LEFT, + pygame.K_RIGHT, + pygame.K_UP, + pygame.K_DOWN, + pygame.K_COMMA, + pygame.K_PERIOD, + } ) diff --git a/cleave/viz/timeline_controls.py b/cleave/viz/timeline_controls.py index bd88c16..82f857d 100644 --- a/cleave/viz/timeline_controls.py +++ b/cleave/viz/timeline_controls.py @@ -9,12 +9,16 @@ from cleave.blend_modes import BLEND_MODES, BlendMode from cleave.cue_roles import CUE_ROLES, CueRole from cleave.timeline import ( + LEVEL_EDIT_MIN, LEVEL_EPS, + LEVEL_STEP_LARGE, + LEVEL_STEP_SMALL, SlotCue, canonicalize, cue_at_time, cue_editable_for_blend_role, empty_lane, + levels_equal, navigable_cue_times, should_accept_toggle, snap_placement_time, @@ -22,7 +26,7 @@ ) from cleave.viz.controls import SEEK_LONG, SEEK_SHORT, SEEK_TINY from cleave.viz.session import TuningSession -from cleave.viz.key_repeat import mod_ctrl, mod_shift +from cleave.viz.key_repeat import KeyRepeatController, mod_ctrl, mod_shift from cleave.viz.layer_visibility import ( armed_recording_level, build_record_punch_cues, @@ -79,6 +83,7 @@ def __init__( self._on_seek = on_seek self._on_notification = on_notification self._last_toggle_t: dict[str, float] = {} + self._key_repeat = KeyRepeatController() def _snap_placement(self, t_sec: float) -> float: return snap_placement_time( @@ -179,11 +184,37 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: return True if event.key == pygame.K_COMMA: - self._step_selected_cue(forward=False) + if mod_ctrl(event.mod) or mod_shift(event.mod): + self._nudge_selected_cue_level( + forward=False, large=mod_ctrl(event.mod) + ) + if self._cue_edits_allowed(): + self._key_repeat.on_keydown( + event.key, + event.mod, + on_repeat=lambda key, mod: self._nudge_selected_cue_level( + forward=False, large=mod_ctrl(mod) + ), + ) + else: + self._step_selected_cue(forward=False) return True if event.key == pygame.K_PERIOD: - self._step_selected_cue(forward=True) + if mod_ctrl(event.mod) or mod_shift(event.mod): + self._nudge_selected_cue_level( + forward=True, large=mod_ctrl(event.mod) + ) + if self._cue_edits_allowed(): + self._key_repeat.on_keydown( + event.key, + event.mod, + on_repeat=lambda key, mod: self._nudge_selected_cue_level( + forward=True, large=mod_ctrl(mod) + ), + ) + else: + self._step_selected_cue(forward=True) return True if event.key == pygame.K_b: @@ -197,7 +228,15 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: return True def handle_keyup(self, event: pygame.event.Event) -> None: - del event + if event.type == pygame.KEYUP: + self._key_repeat.on_keyup(event.key) + + @property + def key_repeat_armed(self) -> bool: + return self._key_repeat.is_armed + + def tick(self, dt_sec: float) -> None: + self._key_repeat.tick(dt_sec) def stop_recording(self) -> None: """Stop an in-progress timeline take without closing the panel.""" @@ -265,6 +304,30 @@ def _selected_cue(self) -> tuple[str, SlotCue] | None: return None return slot, cue + def _nudge_selected_cue_level(self, *, forward: bool, large: bool) -> None: + """Adjust selected on-cue timeline opacity (multiplies into layer opacity).""" + if not self._cue_edits_allowed(): + return + selected = self._selected_cue() + if selected is None: + return + slot, cue = selected + if not cue_editable_for_blend_role(cue): + return + # Integer percent math matches the layer opacity fader (1% / 10%). + step_pct = int(round( + (LEVEL_STEP_LARGE if large else LEVEL_STEP_SMALL) * 100.0 + )) + min_pct = int(round(LEVEL_EDIT_MIN * 100.0)) + pct = int(round(float(cue.level) * 100.0)) + new_pct = max(min_pct, min(100, pct + (step_pct if forward else -step_pct))) + new_level = new_pct / 100.0 + if levels_equal(new_level, cue.level): + return + self._apply_selected_cue_update( + slot, cue.t, blend=cue.blend, role=cue.role, level=new_level + ) + def _cycle_selected_cue_blend(self, *, forward: bool) -> None: if not self._cue_edits_allowed(): return @@ -312,12 +375,15 @@ def _apply_selected_cue_update( *, blend: BlendMode | None, role: CueRole | None, + level: float | None = None, ) -> None: tl = self.session.timeline lane = tl.lanes.get(slot) or empty_lane() - updated = update_lane_cue(lane, cue_t, blend=blend, role=role) + updated = update_lane_cue( + lane, cue_t, blend=blend, role=role, level=level + ) tl.lanes[slot] = updated - if not any(cue.t == cue_t for cue in updated.cues): + if cue_t not in navigable_cue_times(updated): tl.selected_cue_t.pop(slot, None) tl.selected_cue_flash_start_ms = None diff --git a/cleave/viz/timeline_overlay.py b/cleave/viz/timeline_overlay.py index b51631e..907d1e7 100644 --- a/cleave/viz/timeline_overlay.py +++ b/cleave/viz/timeline_overlay.py @@ -356,10 +356,14 @@ def bar_tick_times_for_row(state: TimelineViewState, slot: str) -> list[float]: def selected_cue_readout_text(cue: SlotCue) -> str: - """Badge-strip text for a selected cue's time, level, blend, and cast.""" + """Badge-strip text for a selected cue's time, opacity, blend, and cast.""" blend = cue.blend if cue.blend is not None else "-" cast = cue.role if cue.role is not None else "-" - return f"[{format_mmss(cue.t)}] lvl {cue.level:.2f} blend {blend} cast {cast}" + opacity_pct = int(round(float(cue.level) * 100.0)) + return ( + f"[{format_mmss(cue.t)}] opacity {opacity_pct}% " + f"blend {blend} cast {cast}" + ) def _selected_cue_for_focus(state: TimelineViewState) -> SlotCue | None: diff --git a/docs/improved-timeline-presets.md b/docs/improved-timeline-presets.md index 3c58a5e..2c56459 100644 --- a/docs/improved-timeline-presets.md +++ b/docs/improved-timeline-presets.md @@ -34,7 +34,7 @@ Gaps the ideas below target: **Deferred** (still part of the mix vision, not implemented): -- Manual level authoring in the strip (record toggles still write `0.0` / `1.0`). +- Record toggles still write `0.0` / `1.0` (partial timeline opacity is Apply or strip nudge). - Automatic blend/role assignment from the generative arranger (Idea 3). ### Intent @@ -54,13 +54,13 @@ Gaps the ideas below target: 6. Strip: polygon fill from breakpoints; committed eye alpha follows level. 7. Blend: held like level via `lane_blend_at`; applied per frame in `apply_layer_visibility` with layer static fallback. 8. Role: event property on on-transitions; seek-stable per-role pools in [cleave/viz/preset_switching.py](../cleave/viz/preset_switching.py); empty pool falls back to the main rotation. -9. Strip authoring: `,` / `.` select on cues (`level > 0`, including mid-on changes; offs skipped); `b` / `c` cycle blend and cast on those only; selected tick highlight, role glyphs on on cues, and badge readout. +9. Strip authoring: `,` / `.` select on cues (`level > 0`, including mid-on changes; offs skipped); `Shift` / `Ctrl` + `,` / `.` nudge selected cue timeline opacity by 1% / 10% (floor 10% so the cue is not erased; multiplies into the layer opacity fader; YAML field stays `level`); `b` / `c` cycle blend and cast on those only; selected tick highlight, role glyphs on on cues, and badge readout (`opacity N%`). Overlaps [roadmap.md](roadmap.md) richer cue types; automatic blend/role assignment from stem content and fingerprints remains Idea 3. ### User effort -None for 0/1 lanes. Partial levels come from generative Apply (crescendo). Blend and role are authorable on selected on cues in the strip; place milk files under `preset_root/roles//` for casting. +None for 0/1 lanes. Partial timeline opacity comes from generative Apply (crescendo / conductor) or manual `Shift`/`Ctrl` + `,`/`.` on a selected on cue. Blend and role are authorable on selected on cues in the strip; place milk files under `preset_root/roles//` for casting. --- diff --git a/docs/todos.md b/docs/todos.md index d00312a..a35a6fc 100644 --- a/docs/todos.md +++ b/docs/todos.md @@ -15,11 +15,10 @@ Outstanding bugs and issues. ## Timeline follow-ups -Rich cue levels are shipped (see [improved-timeline-presets.md](improved-timeline-presets.md) Idea 1). Remaining mix-cue work: +Rich cue levels, per-cue blend/role, and manual timeline-opacity nudges are shipped (see [improved-timeline-presets.md](improved-timeline-presets.md) Idea 1). Remaining mix-cue work: -- **Per-cue blend mode.** Carry an optional blend on `SlotCue` and write `LayerFbo.blend_mode` from the active cue so the arranger can switch compositing per transition ([cleave/blend_modes.py](../cleave/blend_modes.py)). -- **Per-cue preset role.** Optional role on the cue for content-aware casting once reactivity fingerprints exist (Idea 3). -- **Manual level authoring.** Strip and record path still toggle `0.0` / `1.0`; add a way to set partial levels by hand without Apply. +- **Record still writes 0/1.** Armed record toggles punch full on/off only; partial opacities stay Apply or `Shift`/`Ctrl` + `,`/`.` on a selected cue. +- **Automatic blend/role assignment** from the generative arranger once reactivity fingerprints exist (Idea 3). --- diff --git a/tests/cleave/test_timeline.py b/tests/cleave/test_timeline.py index 09948d7..4e73959 100644 --- a/tests/cleave/test_timeline.py +++ b/tests/cleave/test_timeline.py @@ -255,6 +255,36 @@ def test_update_lane_cue_materializes_opening_baseline() -> None: ] +def test_update_lane_cue_level_only_keeps_opening_baseline() -> None: + from cleave.timeline import update_lane_cue + + lane = TimelineLane( + baseline=0.5, + cues=[SlotCue(t=10.0, level=0.0), SlotCue(t=20.0, level=1.0)], + ) + updated = update_lane_cue(lane, 0.0, blend=None, role=None, level=0.75) + assert updated.baseline == 0.75 + assert updated.cues == [ + SlotCue(t=10.0, level=0.0), + SlotCue(t=20.0, level=1.0), + ] + + +def test_update_lane_cue_changes_stored_cue_level() -> None: + from cleave.timeline import update_lane_cue + + lane = TimelineLane( + baseline=0.0, + cues=[SlotCue(t=5.0, level=1.0, blend="add", role="lead")], + ) + updated = update_lane_cue( + lane, 5.0, blend="add", role="lead", level=0.25 + ) + assert updated.cues == [ + SlotCue(t=5.0, level=0.25, blend="add", role="lead"), + ] + + def test_lane_blend_at_holds_and_reverts() -> None: lane = TimelineLane( baseline=0.0, diff --git a/tests/cleave/viz/test_key_repeat.py b/tests/cleave/viz/test_key_repeat.py index d8d67fd..de205d5 100644 --- a/tests/cleave/viz/test_key_repeat.py +++ b/tests/cleave/viz/test_key_repeat.py @@ -103,6 +103,19 @@ def test_up_down_keys_arm_repeat() -> None: assert repeats == [(key, 0)] +def test_comma_period_keys_arm_repeat() -> None: + for key in (pygame.K_COMMA, pygame.K_PERIOD): + controller = KeyRepeatController() + repeats: list[tuple[int, int]] = [] + controller.on_keydown( + key, + pygame.KMOD_SHIFT, + on_repeat=lambda k, mod: repeats.append((k, mod)), + ) + controller.tick(INITIAL_DELAY_SEC) + assert repeats == [(key, pygame.KMOD_SHIFT)] + + def test_navigation_repeat_stays_at_constant_interval() -> None: controller = KeyRepeatController() repeats: list[tuple[int, int]] = [] diff --git a/tests/cleave/viz/test_timeline_controls.py b/tests/cleave/viz/test_timeline_controls.py index 72cf500..7f5e6a2 100644 --- a/tests/cleave/viz/test_timeline_controls.py +++ b/tests/cleave/viz/test_timeline_controls.py @@ -13,6 +13,7 @@ from cleave.viz.controls import SEEK_LONG, SEEK_SHORT, SEEK_TINY, TuningControls from cleave.viz.session import LayerRuntime, TuningSession from cleave.viz.layer_visibility import armed_recording_level, effective_layer_enabled +from cleave.viz.key_repeat import INITIAL_DELAY_SEC, SLOW_INTERVAL_SEC from cleave.viz.timeline_controls import TimelineControls from tests.support.viz import keydown, make_playlist, stub_playback_state @@ -1363,6 +1364,139 @@ def test_b_and_c_noop_on_off_cue() -> None: assert on_cue.role == CUE_ROLES[0] +def test_shift_comma_period_nudges_selected_cue_opacity() -> None: + lanes = { + "layer_1": TimelineLane( + baseline=0.0, + cues=canonicalize( + 0.0, + [SlotCue(t=5.0, level=1.0)], + ), + ), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=5.0, + ) + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t["layer_1"] == 5.0 + + controls.handle_keydown(keydown(pygame.K_COMMA, mod=pygame.KMOD_SHIFT)) + assert session.timeline.lanes["layer_1"].cues[0].level == 0.99 + + controls.handle_keydown(keydown(pygame.K_COMMA, mod=pygame.KMOD_CTRL)) + assert session.timeline.lanes["layer_1"].cues[0].level == 0.89 + + controls.handle_keydown(keydown(pygame.K_PERIOD, mod=pygame.KMOD_SHIFT)) + assert session.timeline.lanes["layer_1"].cues[0].level == 0.90 + + # Ctrl wins over Shift when both are held. + controls.handle_keydown( + keydown(pygame.K_PERIOD, mod=pygame.KMOD_CTRL | pygame.KMOD_SHIFT) + ) + assert session.timeline.lanes["layer_1"].cues[0].level == 1.0 + + +def test_opacity_nudge_key_repeat_after_delay() -> None: + lanes = { + "layer_1": TimelineLane( + baseline=0.0, + cues=canonicalize( + 0.0, + [SlotCue(t=5.0, level=1.0)], + ), + ), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=5.0, + ) + controls.handle_keydown(keydown(pygame.K_PERIOD)) + controls.handle_keydown(keydown(pygame.K_COMMA, mod=pygame.KMOD_SHIFT)) + assert session.timeline.lanes["layer_1"].cues[0].level == 0.99 + assert controls.key_repeat_armed + + controls.tick(INITIAL_DELAY_SEC - 0.01) + assert session.timeline.lanes["layer_1"].cues[0].level == 0.99 + + controls.tick(0.01) + assert session.timeline.lanes["layer_1"].cues[0].level == 0.98 + + controls.tick(SLOW_INTERVAL_SEC) + assert session.timeline.lanes["layer_1"].cues[0].level == 0.97 + + controls.handle_keyup( + pygame.event.Event(pygame.KEYUP, key=pygame.K_COMMA, mod=0) + ) + assert not controls.key_repeat_armed + controls.tick(INITIAL_DELAY_SEC + 1.0) + assert session.timeline.lanes["layer_1"].cues[0].level == 0.97 + + +def test_bare_comma_does_not_arm_key_repeat() -> None: + lanes = { + "layer_1": _lane(False, (5.0, True), (10.0, True)), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=5.0, + ) + controls.handle_keydown(keydown(pygame.K_PERIOD)) + controls.handle_keydown(keydown(pygame.K_COMMA)) + assert not controls.key_repeat_armed + controls.tick(INITIAL_DELAY_SEC + 1.0) + assert session.timeline.selected_cue_t["layer_1"] == 5.0 + + +def test_nudge_cue_opacity_floors_at_ten_percent() -> None: + lanes = { + "layer_1": TimelineLane( + baseline=0.0, + cues=canonicalize( + 0.0, + [SlotCue(t=5.0, level=0.12)], + ), + ), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=5.0, + ) + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t["layer_1"] == 5.0 + controls.handle_keydown(keydown(pygame.K_COMMA, mod=pygame.KMOD_CTRL)) + assert session.timeline.lanes["layer_1"].cues[0].level == 0.10 + assert session.timeline.selected_cue_t["layer_1"] == 5.0 + # Already at floor: further down nudges are no-ops. + controls.handle_keydown(keydown(pygame.K_COMMA, mod=pygame.KMOD_SHIFT)) + assert session.timeline.lanes["layer_1"].cues[0].level == 0.10 + + +def test_shift_nudge_opening_baseline_opacity() -> None: + lanes = { + "layer_1": TimelineLane( + baseline=0.5, + cues=canonicalize( + 0.5, + [ + SlotCue(t=10.0, level=0.0), + SlotCue(t=20.0, level=1.0), + ], + ), + ), + } + controls, session, _, _, _, _ = _make_timeline_controls( + lanes=lanes, + position_sec=0.0, + ) + controls.handle_keydown(keydown(pygame.K_PERIOD)) + assert session.timeline.selected_cue_t["layer_1"] == 0.0 + controls.handle_keydown(keydown(pygame.K_PERIOD, mod=pygame.KMOD_SHIFT)) + assert session.timeline.lanes["layer_1"].baseline == 0.51 + assert session.timeline.selected_cue_t["layer_1"] == 0.0 + assert not any(cue.t == 0.0 for cue in session.timeline.lanes["layer_1"].cues) + + def test_cue_edit_keys_refused_when_locked() -> None: lanes = { "layer_1": _lane(False, (5.0, True)), @@ -1376,8 +1510,10 @@ def test_cue_edit_keys_refused_when_locked() -> None: assert session.timeline.selected_cue_t == {} controls.handle_keydown(keydown(pygame.K_b)) controls.handle_keydown(keydown(pygame.K_c)) + controls.handle_keydown(keydown(pygame.K_COMMA, mod=pygame.KMOD_SHIFT)) assert session.timeline.lanes["layer_1"].cues[0].blend is None assert session.timeline.lanes["layer_1"].cues[0].role is None + assert session.timeline.lanes["layer_1"].cues[0].level == 1.0 def test_cue_edit_keys_refused_when_recording() -> None: @@ -1394,5 +1530,7 @@ def test_cue_edit_keys_refused_when_recording() -> None: assert session.timeline.selected_cue_t == {} controls.handle_keydown(keydown(pygame.K_b)) controls.handle_keydown(keydown(pygame.K_c)) + controls.handle_keydown(keydown(pygame.K_PERIOD, mod=pygame.KMOD_CTRL)) assert session.timeline.lanes["layer_1"].cues[0].blend is None - assert session.timeline.lanes["layer_1"].cues[0].role is None \ No newline at end of file + assert session.timeline.lanes["layer_1"].cues[0].role is None + assert session.timeline.lanes["layer_1"].cues[0].level == 1.0 \ No newline at end of file diff --git a/tests/cleave/viz/test_timeline_overlay.py b/tests/cleave/viz/test_timeline_overlay.py index afcf166..ac2be43 100644 --- a/tests/cleave/viz/test_timeline_overlay.py +++ b/tests/cleave/viz/test_timeline_overlay.py @@ -937,10 +937,10 @@ def test_bar_without_high_water_mark_behaves_as_before() -> None: def test_selected_cue_readout_text_format() -> None: assert selected_cue_readout_text( SlotCue(t=65.0, level=1.0, blend="add", role="lead") - ) == "[01:05] lvl 1.00 blend add cast lead" + ) == "[01:05] opacity 100% blend add cast lead" assert selected_cue_readout_text( SlotCue(t=0.0, level=0.25, blend=None, role=None) - ) == "[00:00] lvl 0.25 blend - cast -" + ) == "[00:00] opacity 25% blend - cast -" def test_draw_with_selected_cue_and_role_does_not_crash() -> None: From 0f52dc762d1159cffa109eaaaeccf55854c0c316 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Wed, 29 Jul 2026 23:01:20 +0100 Subject: [PATCH 08/12] Improve selected cue display --- .cursor/rules/live-tuning-ui.mdc | 2 +- cleave/viz/timeline_overlay.py | 55 ++++++++++++++++++----- tests/cleave/viz/test_timeline_overlay.py | 4 +- 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index 477f33d..c90bb30 100644 --- a/.cursor/rules/live-tuning-ui.mdc +++ b/.cursor/rules/live-tuning-ui.mdc @@ -56,7 +56,7 @@ Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always ## Timeline panel -Separate bottom overlay ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py), [cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)). Open with **t** (opens strip and enters submenu on row 0) or **Right** on **Render: TIMELINE** (strip only; **Down** enters submenu), including when `timeline.enabled` is false. **Left** on that header or **t** when the strip is open closes it and returns focus to **Render: TIMELINE**. Strip open does not hide the main overlay or route keys until `submenu_focused` (**t** from the main tree sets this immediately; **Down** from the header when the strip is already open). When the strip is open, **Up**/**Down** use one focus ring ([cleave/viz/focus_nav.py](cleave/viz/focus_nav.py)): main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, with uniform modulo wrap. **Down** from the last timeline row wraps to **Settings**; **Up** from **Settings** wraps to the last timeline row. **Up** at timeline row 0 returns focus to **Render: TIMELINE** without closing the strip. **Up**/**Down** always route through the main tuning controls; other timeline keys route to the timeline strip when `submenu_focused`. **Esc** or **t** while in the submenu closes the strip and returns focus to **Render: TIMELINE**. Rows follow `layer_z_order`; labels use stem abbreviations (D/B/V/O). Each track owns a lane (`TimelineLane`: explicit `baseline` plus ordered `SlotCue(t, level, blend?, role?)` transitions in [cleave/timeline.py](cleave/timeline.py)); `level` is required; optional `blend` is held state (inherits the layer static blend when `None`); optional `role` (`bed` / `pulse` / `lead` / `accent`) is an on-transition cast into `preset_root/roles//`. Edits (`punch_lane`, snap, presets, cue authoring) are lane-local. Each row shows a monitor eye beside the label, the cue bar, and a committed-timeline eye at the far right: **left** = monitor/output (`effective_layer_enabled`; gold `OVERRIDE_BG` when stem is in `TimelineRuntime.override_slots` or when recording and the row is armed). Armed rows during record use `record_baseline` + per-slot `record_buffer` toggles only, not committed lane cues. Record start baseline is not drawn on the bar; only transition cues in that slot's `record_buffer` show ticks. **right** = committed lane level at playhead (`timeline_committed_level`; eye alpha follows the level; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **a** arms a row (`ARMED_BG` red fill, distinct from override eye). **Space** pause snapshots current output into `monitor` and sets `preview_active` (resume clears both); while recording and playing, **Space** stops the take and pauses instead. Num keys **1**-**8** (main row and numpad) toggle layer visibility while paused (preview via `monitor` or override via `override_visible`; adds stem to override when needed), write record buffer when recording (armed only), toggle `override_visible` while playing for stems in `override_slots` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_slots` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip uses **Shift+Left** / **Shift+Right** for 2s transport seek (not solo). **`,`** / **`.`** select the previous / next on cue (`level > LEVEL_EPS`, including mid-on level changes) on the focused lane; pure off cues are skipped. First press (or when selection is missing / on an off) picks the navigable cue nearest the playhead. **Shift+,** / **Shift+.** nudge the selected on cue's timeline opacity by -/+ 1% (`LEVEL_STEP_SMALL`); **Ctrl+,** / **Ctrl+.** by -/+ 10% (`LEVEL_STEP_LARGE`); floored at 10% (`LEVEL_EDIT_MIN`) so nudging cannot erase the cue; Ctrl wins when both modifiers are held. Timeline opacity multiplies into the layer opacity fader (`fbo.opacity`); the YAML field remains `level`. Generative Apply may still quantise to `LEVEL_QUANTUM`. **b** / **Shift+B** cycle the selected on cue's blend through inherit (`None`) then `BLEND_MODES`; **c** / **Shift+C** cycle its role (cast) through `None` then `CUE_ROLES`; both no-op on offs. Cast and blend are authored for the next on / visible period (like preset switching at on-transition); `canonicalize` strips `blend` and `role` from off cues. Cue selection, opacity nudges, and blend/role edits are refused while the timeline is locked or recording. Selection is session-only on `TimelineRuntime.selected_cue_t` (keyed by slot; per-track memory). The selected tick, flash, and badge readout draw only for the focused timeline row; other slots keep their remembered `selected_cue_t` and restore the settled yellow highlight when focus returns (flash restarts only when `,` / `.` changes the cue on that track). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed lanes in range (playback keeps running). **Ctrl+Space** starts record the same way; while recording it stops the take and pauses (no preview). `preview_active` / `monitor` / `override_slots` / `override_visible` / `selected_cue_t` are session-only on `TimelineRuntime`. Per-lane state persists under root `timeline.lanes` in YAML (written last in snapshots). The strip draws each lane as a variable-height level bar from `lane_level_breakpoints` (bottom-filled polygons between breakpoints; remainder dark so a level-0 lane still reads as a full-height bed; colour lerps by mean segment level). Fade groups shape the breakpoint slopes; cue ticks and the bar grid are unchanged. The selected cue's tick draws full row height in `HIGHLIGHT` at `SELECTED_CUE_TICK_WIDTH` (thinner than the flash width, thicker than normal ticks) and live-patches a 3s thick HIGHLIGHT/`ARMED_BG` (yellow/red) blink at `SELECTED_CUE_FLASH_TICK_WIDTH` via `selected_cue_flash_start_ms` (same cadence as arm flash); after the flash expires it settles on the thinner yellow tick; on cues with a role draw a bold single-letter glyph (`B` / `P` / `L` / `A`) at the bottom of the level bar, offset left of off edges and right of on edges (inside the enabled segment) via per-pixel RGB XOR (pygame 2.6 has no `BLEND_XOR`), painted last after bars, ticks, markers, and the playhead (never on off cues); the badge strip shows a selected-cue readout (`[mm:ss] opacity 100% blend add cast lead`, with `-` for unset blend/role). +Separate bottom overlay ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py), [cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)). Open with **t** (opens strip and enters submenu on row 0) or **Right** on **Render: TIMELINE** (strip only; **Down** enters submenu), including when `timeline.enabled` is false. **Left** on that header or **t** when the strip is open closes it and returns focus to **Render: TIMELINE**. Strip open does not hide the main overlay or route keys until `submenu_focused` (**t** from the main tree sets this immediately; **Down** from the header when the strip is already open). When the strip is open, **Up**/**Down** use one focus ring ([cleave/viz/focus_nav.py](cleave/viz/focus_nav.py)): main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, with uniform modulo wrap. **Down** from the last timeline row wraps to **Settings**; **Up** from **Settings** wraps to the last timeline row. **Up** at timeline row 0 returns focus to **Render: TIMELINE** without closing the strip. **Up**/**Down** always route through the main tuning controls; other timeline keys route to the timeline strip when `submenu_focused`. **Esc** or **t** while in the submenu closes the strip and returns focus to **Render: TIMELINE**. Rows follow `layer_z_order`; labels use stem abbreviations (D/B/V/O). Each track owns a lane (`TimelineLane`: explicit `baseline` plus ordered `SlotCue(t, level, blend?, role?)` transitions in [cleave/timeline.py](cleave/timeline.py)); `level` is required; optional `blend` is held state (inherits the layer static blend when `None`); optional `role` (`bed` / `pulse` / `lead` / `accent`) is an on-transition cast into `preset_root/roles//`. Edits (`punch_lane`, snap, presets, cue authoring) are lane-local. Each row shows a monitor eye beside the label, the cue bar, and a committed-timeline eye at the far right: **left** = monitor/output (`effective_layer_enabled`; gold `OVERRIDE_BG` when stem is in `TimelineRuntime.override_slots` or when recording and the row is armed). Armed rows during record use `record_baseline` + per-slot `record_buffer` toggles only, not committed lane cues. Record start baseline is not drawn on the bar; only transition cues in that slot's `record_buffer` show ticks. **right** = committed lane level at playhead (`timeline_committed_level`; eye alpha follows the level; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **a** arms a row (`ARMED_BG` red fill, distinct from override eye). **Space** pause snapshots current output into `monitor` and sets `preview_active` (resume clears both); while recording and playing, **Space** stops the take and pauses instead. Num keys **1**-**8** (main row and numpad) toggle layer visibility while paused (preview via `monitor` or override via `override_visible`; adds stem to override when needed), write record buffer when recording (armed only), toggle `override_visible` while playing for stems in `override_slots` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_slots` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip uses **Shift+Left** / **Shift+Right** for 2s transport seek (not solo). **`,`** / **`.`** select the previous / next on cue (`level > LEVEL_EPS`, including mid-on level changes) on the focused lane; pure off cues are skipped. First press (or when selection is missing / on an off) picks the navigable cue nearest the playhead. **Shift+,** / **Shift+.** nudge the selected on cue's timeline opacity by -/+ 1% (`LEVEL_STEP_SMALL`); **Ctrl+,** / **Ctrl+.** by -/+ 10% (`LEVEL_STEP_LARGE`); floored at 10% (`LEVEL_EDIT_MIN`) so nudging cannot erase the cue; Ctrl wins when both modifiers are held. Timeline opacity multiplies into the layer opacity fader (`fbo.opacity`); the YAML field remains `level`. Generative Apply may still quantise to `LEVEL_QUANTUM`. **b** / **Shift+B** cycle the selected on cue's blend through inherit (`None`) then `BLEND_MODES`; **c** / **Shift+C** cycle its role (cast) through `None` then `CUE_ROLES`; both no-op on offs. Cast and blend are authored for the next on / visible period (like preset switching at on-transition); `canonicalize` strips `blend` and `role` from off cues. Cue selection, opacity nudges, and blend/role edits are refused while the timeline is locked or recording. Selection is session-only on `TimelineRuntime.selected_cue_t` (keyed by slot; per-track memory). The selected tick, flash, and badge readout draw only for the focused timeline row; other slots keep their remembered `selected_cue_t` and restore the settled yellow highlight when focus returns (flash restarts only when `,` / `.` changes the cue on that track). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed lanes in range (playback keeps running). **Ctrl+Space** starts record the same way; while recording it stops the take and pauses (no preview). `preview_active` / `monitor` / `override_slots` / `override_visible` / `selected_cue_t` are session-only on `TimelineRuntime`. Per-lane state persists under root `timeline.lanes` in YAML (written last in snapshots). The strip draws each lane as a variable-height level bar from `lane_level_breakpoints` (bottom-filled polygons between breakpoints; remainder dark so a level-0 lane still reads as a full-height bed; colour lerps by mean segment level). Fade groups shape the breakpoint slopes; cue ticks and the bar grid are unchanged. The selected cue's tick draws full row height in `HIGHLIGHT` at `SELECTED_CUE_TICK_WIDTH` (thinner than the flash width, thicker than normal ticks) and live-patches a 3s thick HIGHLIGHT/`ARMED_BG` (yellow/red) blink at `SELECTED_CUE_FLASH_TICK_WIDTH` via `selected_cue_flash_start_ms` (same cadence as arm flash); after the flash expires it settles on the thinner yellow tick; on cues with a role draw a bold single-letter glyph (`B` / `P` / `L` / `A`) at the bottom of the level bar, offset left of off edges and right of on edges (inside the enabled segment) via per-pixel RGB XOR (pygame 2.6 has no `BLEND_XOR`), painted last after bars, ticks, markers, and the playhead (never on off cues); the badge strip shows a selected-cue readout (`[mm:ss] opacity: 100% blend: add cast: lead`, LABEL prefixes and VALUE for time/values; `-` for unset blend/role). ## Header rows section diff --git a/cleave/viz/timeline_overlay.py b/cleave/viz/timeline_overlay.py index 907d1e7..b1470a4 100644 --- a/cleave/viz/timeline_overlay.py +++ b/cleave/viz/timeline_overlay.py @@ -355,15 +355,45 @@ def bar_tick_times_for_row(state: TimelineViewState, slot: str) -> list[float]: return [cue.t for cue in bar_cues_for_row(state, slot)] -def selected_cue_readout_text(cue: SlotCue) -> str: - """Badge-strip text for a selected cue's time, opacity, blend, and cast.""" +def selected_cue_readout_segments( + cue: SlotCue, +) -> list[tuple[str, tuple[int, int, int]]]: + """Label/value segments for the selected-cue badge readout.""" blend = cue.blend if cue.blend is not None else "-" cast = cue.role if cue.role is not None else "-" opacity_pct = int(round(float(cue.level) * 100.0)) - return ( - f"[{format_mmss(cue.t)}] opacity {opacity_pct}% " - f"blend {blend} cast {cast}" - ) + return [ + (f"[{format_mmss(cue.t)}] ", VALUE), + ("opacity: ", LABEL), + (f"{opacity_pct}% ", VALUE), + ("blend: ", LABEL), + (f"{blend} ", VALUE), + ("cast: ", LABEL), + (str(cast), VALUE), + ] + + +def selected_cue_readout_text(cue: SlotCue) -> str: + """Plain-text form of the selected-cue badge readout.""" + return "".join(text for text, _ in selected_cue_readout_segments(cue)) + + +def render_selected_cue_readout( + font: pygame.font.Font, cue: SlotCue +) -> pygame.Surface: + """Badge-strip surface: LABEL prefixes, VALUE for time and field values.""" + surfs = [ + font.render(text, True, color) + for text, color in selected_cue_readout_segments(cue) + ] + width = sum(surf.get_width() for surf in surfs) + height = max((surf.get_height() for surf in surfs), default=0) + out = pygame.Surface((width, height), pygame.SRCALPHA) + x = 0 + for surf in surfs: + out.blit(surf, (x, 0)) + x += surf.get_width() + return out def _selected_cue_for_focus(state: TimelineViewState) -> SlotCue | None: @@ -1366,7 +1396,7 @@ def _draw_header_badges_on_surface( recording: bool, *, y_offset: int = 0, - cue_readout: str | None = None, + cue_readout: pygame.Surface | None = None, ) -> tuple[int, int, int, int]: time_surf = font.render(transport_time_text(position_sec), True, VALUE) time_w = time_surf.get_width() + REC_BADGE_PAD_X * 2 @@ -1385,14 +1415,13 @@ def _draw_header_badges_on_surface( badge_y = y_offset readout_w = 0 - if cue_readout: - readout_surf = font.render(cue_readout, True, VALUE) - readout_w = readout_surf.get_width() + REC_BADGE_PAD_X * 2 + if cue_readout is not None: + readout_w = cue_readout.get_width() + REC_BADGE_PAD_X * 2 pygame.draw.rect( surface, BACKGROUND, (0, badge_y, readout_w, badge_h) ) surface.blit( - readout_surf, + cue_readout, (REC_BADGE_PAD_X, badge_y + REC_BADGE_PAD_Y), ) @@ -1470,7 +1499,9 @@ def _patch_live_overlay( selected_cue = _selected_cue_for_focus(state) cue_readout = ( - selected_cue_readout_text(selected_cue) if selected_cue is not None else None + render_selected_cue_readout(font, selected_cue) + if selected_cue is not None + else None ) badge_rect = self._draw_header_badges_on_surface( upload, diff --git a/tests/cleave/viz/test_timeline_overlay.py b/tests/cleave/viz/test_timeline_overlay.py index ac2be43..8fd4a3e 100644 --- a/tests/cleave/viz/test_timeline_overlay.py +++ b/tests/cleave/viz/test_timeline_overlay.py @@ -937,10 +937,10 @@ def test_bar_without_high_water_mark_behaves_as_before() -> None: def test_selected_cue_readout_text_format() -> None: assert selected_cue_readout_text( SlotCue(t=65.0, level=1.0, blend="add", role="lead") - ) == "[01:05] opacity 100% blend add cast lead" + ) == "[01:05] opacity: 100% blend: add cast: lead" assert selected_cue_readout_text( SlotCue(t=0.0, level=0.25, blend=None, role=None) - ) == "[00:00] opacity 25% blend - cast -" + ) == "[00:00] opacity: 25% blend: - cast: -" def test_draw_with_selected_cue_and_role_does_not_crash() -> None: From 7457d428055129bb31a3c7207f628135eba9a2e3 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Thu, 30 Jul 2026 23:48:31 +0100 Subject: [PATCH 09/12] Fix visual limiter huge performance hit --- .cursor/rules/live-tuning-ui.mdc | 2 +- cleave/gl_post_process.py | 94 +++++++++++++++++++ cleave/viz/controls.py | 8 -- cleave/viz/help_content.py | 3 +- cleave/viz/row_fields.py | 13 +-- cleave/viz/row_sections.py | 4 +- cleave/viz/row_semantics.py | 7 +- cleave/viz/session.py | 1 - cleave/viz/tuning_view_state.py | 4 - cleave/viz/visual_limiter.py | 51 ++-------- docs/improved-timeline-presets.md | 2 +- tests/cleave/test_gl_post_process.py | 72 ++++++++++++++ tests/cleave/viz/test_controls.py | 41 +++++++- tests/cleave/viz/test_help_overlay.py | 7 ++ tests/cleave/viz/test_row_fields.py | 22 +++++ tests/cleave/viz/test_view_state_structure.py | 36 +++---- tests/cleave/viz/test_visual_limiter.py | 35 +++++++ 17 files changed, 308 insertions(+), 94 deletions(-) diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index c90bb30..33dcafe 100644 --- a/.cursor/rules/live-tuning-ui.mdc +++ b/.cursor/rules/live-tuning-ui.mdc @@ -52,7 +52,7 @@ Below overlay, **Render: POST FX** (`RowKind.RENDER_POST_FX_HEADER`) is always p **Section lock** (overlay, post-FX, timeline, and layer tracks share one mechanism in [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py)): persisted `locked` on `RenderOverlayConfig` / `RenderPostFxConfig` / `TimelineConfig` and the matching runtimes. **l** on `RENDER_OVERLAY_HEADER`, `RENDER_POST_FX_HEADER`, `RENDER_TIMELINE_HEADER` (or `TRACK_HEADER`) toggles it and draws a red `LOCK_ICON` on the header. When locked: the header still expands/opens and expandable child headers stay navigable, but value and action children are drawn in `LOCKED` and skipped in navigation (`section_locked(state, desc)` with `row_navigable_when_section_locked` / `row_blocked_by_section_lock`); **Ctrl** enable/disable is refused while solo stays allowed. `section_locked` accepts either a `TuningViewState` (tracks / `render_timeline`) or a `TuningSession` (layers / `timeline`). In preset curation mode, `section_locked` always returns false (lock icon, `LOCKED` coloring, navigation skip, and mutation blocks are all ignored) so layer browse and **f** / **b** / **r** stay available. -Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always present. This row is a **panel anchor** (not an expandable section): the timeline strip is hosted separately ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)), not as `RowLayout` children. Eye semantics match post-FX (no solo in v1). **Ctrl+Right** / **Ctrl+Left** sets `session.timeline.enabled`; **Right** opens the timeline strip without entering the submenu; **Left** closes it. **t** toggles the strip: when closed, opens and enters the submenu on row 0; when open, closes and returns focus to this header. Expand arrow reflects `session.timeline.panel_open` (▼ when open). Disable closes the strip; **Right** / **t** can still open it while disabled (same expand-while-disabled semantics as layer and other render headers). State: `RenderTimelineBlock` / `TimelineRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). `enabled` and `locked` persist via config snapshot; staged timeline preset character / crescendo / density / conductor persist under `timeline.preset`; visual limiter knobs persist under `timeline.limiter`; `panel_open` is UI-only. When the strip is open, main-panel children under **Render: TIMELINE** are: song markers (expandable; marker items and **snap to song markers** when expanded), beat / bar grid (expandable; placement snap, bar grid, bar phase, snap to grid), timeline fades (expandable; song markers and standard cues each with enabled/disabled and fade in/out duration when enabled), timeline preset (expandable; character, crescendo, density, conductor, and **apply timeline preset** when expanded), visual limiter (expandable; Ctrl enable/disable with enabled/disabled value; threshold and release when expanded and enabled), and reset timeline. **snap to song markers** is a green action row inside the song markers section (last row after marker items; Enter opens proximity then layer-scope choice modals). **snap to grid** is a green action row under beat / bar grid (Enter opens BEATS / BARS / CANCEL). When the timeline is locked, those children are blocked and skipped in navigation (expandable headers stay navigable), and the strip stays openable and seekable (Seek Left/Right); Space still toggles transport play/pause (session-only preview), but arm, record, override, number-key visibility, and Ctrl+Space record stay blocked ([cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)); preset/snap/phase controllers also refuse while locked. **l** on the timeline header is refused while `timeline.recording` is true. +Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always present. This row is a **panel anchor** (not an expandable section): the timeline strip is hosted separately ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py)), not as `RowLayout` children. Eye semantics match post-FX (no solo in v1). **Ctrl+Right** / **Ctrl+Left** sets `session.timeline.enabled`; **Right** opens the timeline strip without entering the submenu; **Left** closes it. **t** toggles the strip: when closed, opens and enters the submenu on row 0; when open, closes and returns focus to this header. Expand arrow reflects `session.timeline.panel_open` (▼ when open). Disable closes the strip; **Right** / **t** can still open it while disabled (same expand-while-disabled semantics as layer and other render headers). State: `RenderTimelineBlock` / `TimelineRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). `enabled` and `locked` persist via config snapshot; staged timeline preset character / crescendo / density / conductor persist under `timeline.preset`; visual limiter knobs persist under `timeline.limiter`; `panel_open` is UI-only. When the strip is open, main-panel children under **Render: TIMELINE** are: song markers (expandable; marker items and **snap to song markers** when expanded), beat / bar grid (expandable; placement snap, bar grid, bar phase, snap to grid), timeline fades (expandable; song markers and standard cues each with enabled/disabled and fade in/out duration when enabled), timeline preset (expandable; character, crescendo, density, conductor, and **apply timeline preset** when expanded), visual limiter (expandable; **Right** enables and expands to threshold and release; **Left** disables and collapses; expand follows `timeline.limiter.enabled`; header value shows enabled/disabled), and reset timeline. **snap to song markers** is a green action row inside the song markers section (last row after marker items; Enter opens proximity then layer-scope choice modals). **snap to grid** is a green action row under beat / bar grid (Enter opens BEATS / BARS / CANCEL). When the timeline is locked, those children are blocked and skipped in navigation (expandable headers stay navigable), and the strip stays openable and seekable (Seek Left/Right); Space still toggles transport play/pause (session-only preview), but arm, record, override, number-key visibility, and Ctrl+Space record stay blocked ([cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)); preset/snap/phase controllers also refuse while locked. **l** on the timeline header is refused while `timeline.recording` is true. ## Timeline panel diff --git a/cleave/gl_post_process.py b/cleave/gl_post_process.py index e71e8a2..1c26f36 100644 --- a/cleave/gl_post_process.py +++ b/cleave/gl_post_process.py @@ -220,6 +220,23 @@ } """ +_LUMA_DOWNSAMPLE_FRAG = """ +#version 330 +uniform sampler2D image; +uniform bool hdr; +in vec2 uv; +out vec4 fragColor; + +void main() { + vec3 rgb = texture(image, uv).rgb; + if (hdr) { + rgb = clamp(rgb, 0.0, 1.0); + } + float luma = dot(rgb, vec3(0.2126, 0.7152, 0.0722)); + fragColor = vec4(luma, 0.0, 0.0, 1.0); +} +""" + _CHROMA_BOOST_FRAG = """ #version 330 uniform sampler2D image; @@ -364,6 +381,16 @@ def _prepare_fixed_function_gl() -> None: glBindVertexArray(0) +@dataclass +class _LumaGridBuffers: + tex: moderngl.Texture + fbo: moderngl.Framebuffer + + def release(self) -> None: + self.fbo.release() + self.tex.release() + + @dataclass class _PingPongBuffers: copy_tex: moderngl.Texture @@ -396,6 +423,8 @@ def __init__(self, color_format: GlColorFormat = RGBA8) -> None: self._grit_prog: moderngl.Program | None = None self._highlight_rolloff_prog: moderngl.Program | None = None self._chroma_boost_prog: moderngl.Program | None = None + self._luma_downsample_prog: moderngl.Program | None = None + self._luma_grid_buffers: dict[tuple[int, int], _LumaGridBuffers] = {} self._buffers: dict[tuple[int, int], _PingPongBuffers] = {} self._external_textures: dict[tuple[int, int, int], moderngl.Texture] = {} self._dest_fbos: dict[tuple[int, int, int], moderngl.Framebuffer] = {} @@ -449,6 +478,10 @@ def init(self) -> None: vertex_shader=_QUAD_VERT, fragment_shader=_CHROMA_BOOST_FRAG, ) + self._luma_downsample_prog = self._ctx.program( + vertex_shader=_QUAD_VERT, + fragment_shader=_LUMA_DOWNSAMPLE_FRAG, + ) # Binary float32 quad: (x, y, u, v) per vertex covering NDC [-1,1] x [-1,1]. self._quad_buffer = self._ctx.buffer( np.array( @@ -466,6 +499,28 @@ def _ensure_init(self) -> None: if self._ctx is None: self.init() + def _ensure_luma_grid_buffers( + self, grid_width: int, grid_height: int + ) -> _LumaGridBuffers: + self._ensure_init() + assert self._ctx is not None + key = (grid_width, grid_height) + cached = self._luma_grid_buffers.get(key) + if cached is not None: + return cached + tex = self._ctx.texture( + key, + 1, + dtype="f4", + ) + tex.filter = (moderngl.LINEAR, moderngl.LINEAR) + tex.repeat_x = False + tex.repeat_y = False + fbo = self._ctx.framebuffer(color_attachments=[tex]) + buffers = _LumaGridBuffers(tex=tex, fbo=fbo) + self._luma_grid_buffers[key] = buffers + return buffers + def _ensure_buffers(self, width: int, height: int) -> _PingPongBuffers: self._ensure_init() assert self._ctx is not None @@ -560,6 +615,41 @@ def _draw_quad( program[name].value = value self._quad_vao_for(program).render(moderngl.TRIANGLE_STRIP) + def read_luma_grid( + self, + texture_id: int, + width: int, + height: int, + *, + grid_width: int = 32, + grid_height: int = 18, + ) -> np.ndarray: + """Downsample *texture_id* to a luma grid via one GPU pass and readback.""" + empty = np.zeros((grid_height, grid_width), dtype=np.float32) + if texture_id == 0 or width <= 0 or height <= 0: + return empty + + self._ensure_init() + assert self._luma_downsample_prog is not None + + saved = _save_gl_state() + try: + src = self._external_layer_texture(texture_id, width, height) + grid_buffers = self._ensure_luma_grid_buffers(grid_width, grid_height) + self._draw_quad( + self._luma_downsample_prog, + grid_buffers.fbo, + texture=src, + extra_uniforms={"hdr": self._color_format is RGBA16F}, + ) + raw = grid_buffers.fbo.read(components=1, alignment=1, dtype="f4") + finally: + _restore_gl_state(saved) + _prepare_fixed_function_gl() + + grid = np.frombuffer(raw, dtype=np.float32).reshape((grid_height, grid_width)) + return np.array(grid, dtype=np.float32) + def apply_bloom( self, texture_id: int, @@ -795,6 +885,9 @@ def apply_chroma_boost( return texture_id def destroy(self) -> None: + for buffers in self._luma_grid_buffers.values(): + buffers.release() + self._luma_grid_buffers.clear() self._release_format_dependent_buffers() for vao in self._quad_vaos.values(): vao.release() @@ -811,3 +904,4 @@ def destroy(self) -> None: self._grit_prog = None self._highlight_rolloff_prog = None self._chroma_boost_prog = None + self._luma_downsample_prog = None diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index 11ef9ab..d52341d 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -1322,19 +1322,11 @@ def _set_timeline_presets_expanded(self, expanded: bool) -> None: return tl.timeline_presets_expanded = expanded - def _set_visual_limiter_expanded(self, expanded: bool) -> None: - tl = self.session.timeline - if tl.visual_limiter_expanded == expanded: - return - tl.visual_limiter_expanded = expanded - def _set_visual_limiter_enabled(self, enabled: bool) -> None: lim = self.session.timeline.limiter if lim.enabled == enabled: return lim.enabled = enabled - # Match render-header disable: collapse when off; open knobs when on. - self.session.timeline.visual_limiter_expanded = enabled def _set_beat(self, slot: str, value: float) -> None: layer = self.session.layers[slot] diff --git a/cleave/viz/help_content.py b/cleave/viz/help_content.py index fba0aec..dbe98c2 100644 --- a/cleave/viz/help_content.py +++ b/cleave/viz/help_content.py @@ -319,9 +319,10 @@ def sections_for( if behavior.affordance == RowAffordance.EXPAND: if behavior.is_sub_header: + entries = behavior.help_entries primary = HelpSection( behavior.help_title or "Edit", - (("Left/Right", "expand/collapse"),), + entries if entries is not None else (("Left/Right", "expand/collapse"),), ) elif behavior.can_enter_move_mode: primary = layer_section( diff --git a/cleave/viz/row_fields.py b/cleave/viz/row_fields.py index 349478a..41db011 100644 --- a/cleave/viz/row_fields.py +++ b/cleave/viz/row_fields.py @@ -287,16 +287,13 @@ def _apply_visual_limiter_header( controls: TuningControls, desc: RowDescriptor, forward: bool, - ctrl: bool, + _ctrl: bool, _shift: bool, ) -> None: - if ctrl: - if ( - controls.session.timeline.locked - and row_behavior(desc.kind).can_enable_disable - ): - return - controls._set_visual_limiter_enabled(forward) + if ( + controls.session.timeline.locked + and row_behavior(desc.kind).can_enable_disable + ): return apply_expand_toggle(controls, desc.kind, desc.slot, forward) diff --git a/cleave/viz/row_sections.py b/cleave/viz/row_sections.py index 265ecf3..1b6b572 100644 --- a/cleave/viz/row_sections.py +++ b/cleave/viz/row_sections.py @@ -130,11 +130,11 @@ def _timeline_presets_expanded(state: TuningViewState, _slot: str | None) -> boo def _toggle_visual_limiter( controls: TuningControls, _slot: str | None, forward: bool ) -> None: - controls._set_visual_limiter_expanded(forward) + controls._set_visual_limiter_enabled(forward) def _visual_limiter_expanded(state: TuningViewState, _slot: str | None) -> bool: - return state.render_timeline.visual_limiter_expanded + return state.render_timeline.limiter.enabled def _open_timeline_panel(controls: TuningControls, forward: bool) -> None: diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index 42eb580..feb5662 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -828,13 +828,10 @@ class RowBehavior: is_sub_header=True, can_enable_disable=True, help_title="Visual limiter", - help_entries=( - ("Left/Right", "collapse / expand"), - ("Ctrl + Left/Right", "disable / enable"), - ), + help_entries=(("Left/Right", "disable / enable"),), help_description=( "Duck busy stacked layers using post-composite busyness.", - "When enabled and expanded, threshold and release are shown below.", + "When enabled, threshold and release rows appear below.", ), ), RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD: RowBehavior( diff --git a/cleave/viz/session.py b/cleave/viz/session.py index e807778..6be962c 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -214,7 +214,6 @@ class TimelineRuntime: placement_snap: TimelinePlacementSnap = DEFAULT_TIMELINE_PLACEMENT_SNAP fades_expanded: bool = False timeline_presets_expanded: bool = False - visual_limiter_expanded: bool = False timeline_preset_kind: str = DEFAULT_TIMELINE_PRESET_KIND timeline_preset_crescendo: CrescendoTarget | None = None timeline_preset_density: TimelinePresetDensity = DEFAULT_TIMELINE_PRESET_DENSITY diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index f576a34..283bc01 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -184,7 +184,6 @@ class RenderTimelineBlock: placement_snap: str = "beat" fades_expanded: bool = False timeline_presets_expanded: bool = False - visual_limiter_expanded: bool = False timeline_preset_kind: str = "breathing" timeline_preset_crescendo: CrescendoTarget | None = None timeline_preset_density: TimelinePresetDensity = DEFAULT_TIMELINE_PRESET_DENSITY @@ -381,7 +380,6 @@ def view_state_structure_signature( "beat_bar_grid_expanded": tl.beat_bar_grid_expanded, "fades_expanded": tl.fades_expanded, "timeline_presets_expanded": tl.timeline_presets_expanded, - "visual_limiter_expanded": tl.visual_limiter_expanded, "song_marker_fades_enabled": tl.song_marker_fades.enabled, "standard_cue_fades_enabled": tl.standard_cue_fades.enabled, "visual_limiter_enabled": tl.limiter.enabled, @@ -550,7 +548,6 @@ def _build_structure( placement_snap=tl.placement_snap, fades_expanded=tl.fades_expanded, timeline_presets_expanded=tl.timeline_presets_expanded, - visual_limiter_expanded=tl.visual_limiter_expanded, timeline_preset_kind=tl.timeline_preset_kind, timeline_preset_crescendo=tl.timeline_preset_crescendo, timeline_preset_density=tl.timeline_preset_density, @@ -738,7 +735,6 @@ def build( placement_snap=tl.placement_snap, fades_expanded=tl.fades_expanded, timeline_presets_expanded=tl.timeline_presets_expanded, - visual_limiter_expanded=tl.visual_limiter_expanded, timeline_preset_kind=tl.timeline_preset_kind, timeline_preset_crescendo=tl.timeline_preset_crescendo, timeline_preset_density=tl.timeline_preset_density, diff --git a/cleave/viz/visual_limiter.py b/cleave/viz/visual_limiter.py index 2703317..1ce7c83 100644 --- a/cleave/viz/visual_limiter.py +++ b/cleave/viz/visual_limiter.py @@ -13,14 +13,6 @@ from typing import TYPE_CHECKING import numpy as np -from OpenGL.GL import ( - GL_FLOAT, - GL_FRAMEBUFFER, - GL_RGBA, - GL_UNSIGNED_BYTE, - glBindFramebuffer, - glReadPixels, -) from cleave.config_schema import ( DEFAULT_VISUAL_LIMITER_RELEASE, @@ -29,13 +21,13 @@ visual_limiter_threshold_off, ) from cleave.cue_roles import CueRole -from cleave.gl_color_format import RGBA16F from cleave.timeline import LEVEL_EPS, empty_lane, lane_role_at from cleave.viz.editor_mode_controls import is_preset_curation_mode from cleave.viz.layer_visibility import timeline_levels_apply if TYPE_CHECKING: from cleave.gl_compositor import GlCompositor + from cleave.gl_post_process import GlPostProcess from cleave.viz.app import VisualizerCore from cleave.viz.layer import StemLayer from cleave.viz.session import TuningSession, VisualLimiterRuntime @@ -53,10 +45,6 @@ GRID_WIDTH = 32 GRID_HEIGHT = 18 -_LUMA_R = 0.2126 -_LUMA_G = 0.7152 -_LUMA_B = 0.0722 - # Lower rank is ducked first: bed < accent < pulse < lead. _ROLE_RANK: dict[CueRole, int] = { "bed": 0, @@ -274,38 +262,19 @@ def update_limiter_state( def read_luma_grid( compositor: GlCompositor, + post_process: GlPostProcess, *, grid_width: int = GRID_WIDTH, grid_height: int = GRID_HEIGHT, ) -> np.ndarray: """Downsampled display-referred luma grid from the content FBO.""" - width = compositor.content_width - height = compositor.content_height - if width <= 0 or height <= 0: - return np.zeros((grid_height, grid_width), dtype=np.float32) - - xs = np.linspace(0, width - 1, grid_width, dtype=np.int32) - ys = np.linspace(0, height - 1, grid_height, dtype=np.int32) - hdr = compositor.color_format is RGBA16F - glBindFramebuffer(GL_FRAMEBUFFER, compositor.content_fbo_id) - - grid = np.empty((grid_height, grid_width), dtype=np.float32) - for row_i, y in enumerate(ys): - if hdr: - raw = glReadPixels(0, int(y), width, 1, GL_RGBA, GL_FLOAT) - rgba = np.frombuffer(raw, dtype=np.float32).reshape(width, 4) - rgb = np.clip(rgba[:, :3], 0.0, 1.0) - else: - raw = glReadPixels(0, int(y), width, 1, GL_RGBA, GL_UNSIGNED_BYTE) - rgba = np.frombuffer(raw, dtype=np.uint8).reshape(width, 4) - rgb = rgba[:, :3].astype(np.float32) / 255.0 - samples = rgb[xs] - grid[row_i] = ( - _LUMA_R * samples[:, 0] - + _LUMA_G * samples[:, 1] - + _LUMA_B * samples[:, 2] - ) - return grid + return post_process.read_luma_grid( + compositor.content_texture_id, + compositor.content_width, + compositor.content_height, + grid_width=grid_width, + grid_height=grid_height, + ) def metrics_from_grid( @@ -358,7 +327,7 @@ def observe_frame_busyness( if width <= 0 or height <= 0: return reset_if_seek(state, t_sec) - luma_grid = read_luma_grid(core.compositor) + luma_grid = read_luma_grid(core.compositor, core.post_process) mean_luma, mean_abs_delta = metrics_from_grid(state, luma_grid) hot = collect_hot_layers(session, core.layers_by_slot, t_sec) update_limiter_state( diff --git a/docs/improved-timeline-presets.md b/docs/improved-timeline-presets.md index 2c56459..238359a 100644 --- a/docs/improved-timeline-presets.md +++ b/docs/improved-timeline-presets.md @@ -229,7 +229,7 @@ Render: TIMELINE └─ release ``` -Left/Right expands/collapses (like other timeline subsections). Ctrl+Left/Right disables/enables (same eye semantics as render section headers); the header value shows enabled/disabled. Threshold and release appear when the section is expanded and enabled. Locked with the timeline section. +**Right** enables and expands (threshold and release visible). **Left** disables and collapses. Expand follows `timeline.limiter.enabled` (no separate flag). Header value shows enabled/disabled. Locked with the timeline section. | Knob | YAML | Default | Range / display | | --- | --- | --- | --- | diff --git a/tests/cleave/test_gl_post_process.py b/tests/cleave/test_gl_post_process.py index ae7afc1..2ce2924 100644 --- a/tests/cleave/test_gl_post_process.py +++ b/tests/cleave/test_gl_post_process.py @@ -5,6 +5,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch +import numpy as np import pytest from OpenGL.GL import ( GL_COLOR_WRITEMASK, @@ -21,6 +22,7 @@ _restore_gl_state, _save_gl_state, ) +from cleave.gl_color_format import RGBA16F def test_gl_framebuffer_binding_is_valid_getintegerv_pname() -> None: @@ -288,6 +290,76 @@ def _framebuffer(*, color_attachments: list[object]) -> MagicMock: return ctx, vaos +def test_read_luma_grid_returns_zeros_without_gpu() -> None: + post = GlPostProcess() + grid = post.read_luma_grid(0, 0, 0, grid_width=4, grid_height=3) + assert grid.shape == (3, 4) + assert np.all(grid == 0.0) + + +def test_read_luma_grid_draws_once_and_reads_fbo() -> None: + ctx, _vaos = _mock_gl_post_process_ctx() + post = GlPostProcess() + grid_w, grid_h = 8, 6 + expected = np.linspace(0.0, 1.0, grid_w * grid_h, dtype=np.float32) + grid_fbo = MagicMock(name="grid_fbo") + grid_fbo.use = MagicMock() + grid_fbo.read.return_value = expected.tobytes() + grid_tex = MagicMock(name="grid_tex") + grid_buffers = SimpleNamespace(tex=grid_tex, fbo=grid_fbo) + + with patch("cleave.gl_post_process.moderngl.create_context", return_value=ctx): + with patch("cleave.gl_post_process._save_gl_state", return_value=MagicMock()): + with patch("cleave.gl_post_process._restore_gl_state") as restore: + with patch( + "cleave.gl_post_process._prepare_fixed_function_gl" + ) as prepare: + with patch.object( + post, + "_ensure_luma_grid_buffers", + return_value=grid_buffers, + ): + with patch.object(post, "_draw_quad") as mock_draw: + grid = post.read_luma_grid( + texture_id=7, + width=64, + height=36, + grid_width=grid_w, + grid_height=grid_h, + ) + + assert mock_draw.call_count == 1 + assert mock_draw.call_args.kwargs["extra_uniforms"]["hdr"] is False + grid_fbo.read.assert_called_once_with(components=1, alignment=1, dtype="f4") + assert grid.shape == (grid_h, grid_w) + assert grid.ravel() == pytest.approx(expected) + restore.assert_called_once() + prepare.assert_called_once() + + +def test_read_luma_grid_sets_hdr_uniform_for_rgba16f() -> None: + ctx, _vaos = _mock_gl_post_process_ctx() + post = GlPostProcess(color_format=RGBA16F) + grid_fbo = MagicMock(name="grid_fbo") + grid_fbo.use = MagicMock() + grid_fbo.read.return_value = np.zeros(4, dtype=np.float32).tobytes() + grid_buffers = SimpleNamespace(tex=MagicMock(), fbo=grid_fbo) + + with patch("cleave.gl_post_process.moderngl.create_context", return_value=ctx): + with patch("cleave.gl_post_process._save_gl_state", return_value=MagicMock()): + with patch("cleave.gl_post_process._restore_gl_state"): + with patch("cleave.gl_post_process._prepare_fixed_function_gl"): + with patch.object( + post, + "_ensure_luma_grid_buffers", + return_value=grid_buffers, + ): + with patch.object(post, "_draw_quad") as mock_draw: + post.read_luma_grid(7, 64, 36, grid_width=2, grid_height=2) + + assert mock_draw.call_args.kwargs["extra_uniforms"]["hdr"] is True + + def test_apply_bloom_caches_vao_per_program() -> None: """Bloom must not reassign VAO.program; one VAO per shader instead.""" ctx, vaos = _mock_gl_post_process_ctx() diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 97e698c..9b7a12a 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -2414,6 +2414,12 @@ def test_render_timeline_down_enters_submenu() -> None: limiter_header_row = view.layout.find_by_kind( RowKind.TIMELINE_VISUAL_LIMITER_HEADER ) + limiter_threshold_row = view.layout.find_by_kind( + RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD + ) + limiter_release_row = view.layout.find_by_kind( + RowKind.TIMELINE_VISUAL_LIMITER_RELEASE + ) reset_row = view.layout.find_by_kind(RowKind.TIMELINE_RESET) controls.focus_descriptor = _desc(view, header_row) controls.session.timeline.focus_row = 2 @@ -2458,6 +2464,14 @@ def test_render_timeline_down_enters_submenu() -> None: assert controls.focus_descriptor == _desc(view, limiter_header_row) assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) + assert controls.focus_descriptor == _desc(view, limiter_threshold_row) + assert not isinstance(controls.focus_cursor, TimelineFocus) + + controls.handle_keydown(_keydown(pygame.K_DOWN)) + assert controls.focus_descriptor == _desc(view, limiter_release_row) + assert not isinstance(controls.focus_cursor, TimelineFocus) + controls.handle_keydown(_keydown(pygame.K_DOWN)) assert controls.focus_descriptor == _desc(view, reset_row) assert not isinstance(controls.focus_cursor, TimelineFocus) @@ -2468,6 +2482,31 @@ def test_render_timeline_down_enters_submenu() -> None: assert controls.focus_descriptor == _desc(view, header_row) +def test_visual_limiter_left_right_couples_enabled_and_children() -> None: + controls = _make_controls(timeline_enabled=True) + controls.session.timeline.panel_open = True + view = controls.build_view_state(paused=False) + limiter_header = view.layout.find_by_kind( + RowKind.TIMELINE_VISUAL_LIMITER_HEADER + ) + threshold_row = RowDescriptor(RowKind.TIMELINE_VISUAL_LIMITER_THRESHOLD) + + assert controls.session.timeline.limiter.enabled is True + assert threshold_row in view.layout.rows + + controls.focus_descriptor = _desc(view, limiter_header) + controls.handle_keydown(_keydown(pygame.K_LEFT)) + + assert controls.session.timeline.limiter.enabled is False + view_disabled = controls.build_view_state(paused=False) + assert threshold_row not in view_disabled.layout.rows + + controls.handle_keydown(_keydown(pygame.K_RIGHT)) + assert controls.session.timeline.limiter.enabled is True + view_enabled = controls.build_view_state(paused=False) + assert threshold_row in view_enabled.layout.rows + + def test_render_timeline_down_enters_submenu_and_routes_keys() -> None: controls = _make_controls(timeline_enabled=True) controls.session.timeline.panel_open = True @@ -2477,7 +2516,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 - for _ in range(11): + for _ in range(13): controls.handle_keydown(_keydown(pygame.K_DOWN)) assert isinstance(controls.focus_cursor, TimelineFocus) assert controls.session.timeline.focus_row == 0 diff --git a/tests/cleave/viz/test_help_overlay.py b/tests/cleave/viz/test_help_overlay.py index 8b6d4a0..a6fa27a 100644 --- a/tests/cleave/viz/test_help_overlay.py +++ b/tests/cleave/viz/test_help_overlay.py @@ -397,6 +397,13 @@ def test_render_timeline_help_has_no_solo() -> None: assert dict(section.entries)["Left/Right"] == "expand/collapse" +def test_visual_limiter_header_help_disable_enable() -> None: + section = _keyboard_section( + sections_for(RowKind.TIMELINE_VISUAL_LIMITER_HEADER) + ) + assert dict(section.entries) == {"Left/Right": "disable / enable"} + + def test_render_overlay_sub_header_help_expand_collapse() -> None: for row_kind in ( RowKind.RENDER_OVERLAY_TITLE_HEADER, diff --git a/tests/cleave/viz/test_row_fields.py b/tests/cleave/viz/test_row_fields.py index b0962ce..b6ea11e 100644 --- a/tests/cleave/viz/test_row_fields.py +++ b/tests/cleave/viz/test_row_fields.py @@ -393,6 +393,28 @@ def test_full_line_delete_layer_prefix() -> None: assert row_panel_label(RowKind.LAYER_MANAGEMENT_ADD) == "Add Layer" +def test_apply_field_horizontal_visual_limiter_couples_enabled() -> None: + controls = _make_controls(timeline_enabled=True) + controls.session.timeline.limiter.enabled = True + desc = RowDescriptor(RowKind.TIMELINE_VISUAL_LIMITER_HEADER) + + apply_field_horizontal(controls, desc, False, False) + assert controls.session.timeline.limiter.enabled is False + + apply_field_horizontal(controls, desc, True, False) + assert controls.session.timeline.limiter.enabled is True + + +def test_apply_field_horizontal_visual_limiter_blocked_when_timeline_locked() -> None: + controls = _make_controls(timeline_enabled=True) + controls.session.timeline.limiter.enabled = True + controls.session.timeline.locked = True + desc = RowDescriptor(RowKind.TIMELINE_VISUAL_LIMITER_HEADER) + + apply_field_horizontal(controls, desc, False, False) + assert controls.session.timeline.limiter.enabled is True + + def test_apply_field_horizontal_transport_seeks() -> None: from cleave.viz.controls import SEEK_LONG, SEEK_SHORT, SEEK_TINY diff --git a/tests/cleave/viz/test_view_state_structure.py b/tests/cleave/viz/test_view_state_structure.py index f14ef1b..636edc4 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -201,8 +201,8 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert fades_header in view_open.layout.rows assert song_marker_fades not in view_open.layout.rows assert limiter_header in view_open.layout.rows - assert limiter_threshold not in view_open.layout.rows - assert limiter_release not in view_open.layout.rows + assert limiter_threshold in view_open.layout.rows + assert limiter_release in view_open.layout.rows assert markers_header in view_open.layout.rows markers_idx = view_open.layout.rows.index(markers_header) beat_bar_idx = view_open.layout.rows.index(beat_bar_header) @@ -214,7 +214,9 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert fades_idx == beat_bar_idx + 1 assert presets_header_idx == fades_idx + 1 assert limiter_header_idx == presets_header_idx + 1 - assert reset_idx == limiter_header_idx + 1 + assert view_open.layout.rows.index(limiter_threshold) == limiter_header_idx + 1 + assert view_open.layout.rows.index(limiter_release) == limiter_header_idx + 2 + assert reset_idx == limiter_header_idx + 3 session.song_markers.expanded = True view_markers_expanded = builder.build(paused=False) @@ -243,7 +245,9 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert standard_cue_fades not in view_beat_expanded.layout.rows assert view_beat_expanded.layout.rows.index(presets_header) == fades_idx + 1 assert view_beat_expanded.layout.rows.index(limiter_header) == fades_idx + 2 - assert view_beat_expanded.layout.rows.index(reset) == fades_idx + 3 + assert view_beat_expanded.layout.rows.index(limiter_threshold) == fades_idx + 3 + assert view_beat_expanded.layout.rows.index(limiter_release) == fades_idx + 4 + assert view_beat_expanded.layout.rows.index(reset) == fades_idx + 5 session.timeline.fades_expanded = True view_fades_expanded = builder.build(paused=False) @@ -290,7 +294,13 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert view_presets_expanded.layout.rows.index(limiter_header) == ( presets_header_idx + 6 ) - assert view_presets_expanded.layout.rows.index(reset) == presets_header_idx + 7 + assert view_presets_expanded.layout.rows.index(limiter_threshold) == ( + presets_header_idx + 7 + ) + assert view_presets_expanded.layout.rows.index(limiter_release) == ( + presets_header_idx + 8 + ) + assert view_presets_expanded.layout.rows.index(reset) == presets_header_idx + 9 session.timeline.timeline_presets_expanded = False view_presets_collapsed = builder.build(paused=False) @@ -609,7 +619,6 @@ def test_structure_signature_invalidates_on_visual_limiter_enabled() -> None: controls = _make_controls(("layer_1",)) session = controls.session config_save = controls._config_save - session.timeline.visual_limiter_expanded = True session.timeline.limiter.enabled = True sig_before = view_state_structure_signature( session, config_save, notification_active=False @@ -621,21 +630,6 @@ def test_structure_signature_invalidates_on_visual_limiter_enabled() -> None: assert sig_before != sig_after -def test_structure_signature_invalidates_on_visual_limiter_expanded() -> None: - controls = _make_controls(("layer_1",)) - session = controls.session - config_save = controls._config_save - session.timeline.visual_limiter_expanded = False - sig_before = view_state_structure_signature( - session, config_save, notification_active=False - ) - session.timeline.visual_limiter_expanded = True - sig_after = view_state_structure_signature( - session, config_save, notification_active=False - ) - assert sig_before != sig_after - - def test_structure_signature_invalidates_on_latency_compensation_expanded() -> None: controls = _make_controls(("layer_1",)) session = controls.session diff --git a/tests/cleave/viz/test_visual_limiter.py b/tests/cleave/viz/test_visual_limiter.py index c31a6ea..a85aa15 100644 --- a/tests/cleave/viz/test_visual_limiter.py +++ b/tests/cleave/viz/test_visual_limiter.py @@ -18,6 +18,8 @@ from cleave.viz.visual_limiter import ( ATTACK_SEC, DUCK_GAIN, + GRID_HEIGHT, + GRID_WIDTH, RELEASE_RAMP_SEC, RELEASE_SEC, THRESHOLD_OFF, @@ -28,7 +30,9 @@ apply_visual_limiter_gains, busyness, collect_hot_layers, + metrics_from_grid, pick_victim, + read_luma_grid, role_rank, reset_if_seek, update_limiter_state, @@ -369,3 +373,34 @@ def test_update_limiter_state_uses_session_params() -> None: params=params, ) assert state.gain_for("layer_1") == pytest.approx(DUCK_GAIN) + + +def test_read_luma_grid_delegates_to_post_process() -> None: + compositor = MagicMock(content_texture_id=9, content_width=1280, content_height=720) + post_process = MagicMock() + expected = np.full((GRID_HEIGHT, GRID_WIDTH), 0.42, dtype=np.float32) + post_process.read_luma_grid.return_value = expected + + grid = read_luma_grid(compositor, post_process) + + post_process.read_luma_grid.assert_called_once_with( + 9, + 1280, + 720, + grid_width=GRID_WIDTH, + grid_height=GRID_HEIGHT, + ) + assert grid is expected + + +def test_metrics_from_grid_tracks_mean_and_delta() -> None: + state = VisualLimiterState() + grid_a = np.full((2, 2), 0.2, dtype=np.float32) + mean_luma, mean_delta = metrics_from_grid(state, grid_a) + assert mean_luma == pytest.approx(0.2) + assert mean_delta == pytest.approx(0.0) + + grid_b = np.full((2, 2), 0.4, dtype=np.float32) + mean_luma, mean_delta = metrics_from_grid(state, grid_b) + assert mean_luma == pytest.approx(0.4) + assert mean_delta == pytest.approx(0.2) From 6c9ecae455cfa70462ad452bb9479fa937414a1a Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Fri, 31 Jul 2026 01:18:06 +0100 Subject: [PATCH 10/12] Add cast-roles as a rotation set --- cleave/config.py | 8 + cleave/config_schema.py | 81 ++++++- cleave/viz/controls.py | 75 +++++- cleave/viz/help_content.py | 5 +- cleave/viz/layer.py | 1 + cleave/viz/layer_pipeline.py | 2 + cleave/viz/layer_visibility.py | 4 + cleave/viz/panel_notification.py | 36 ++- cleave/viz/preset_seed_controls.py | 3 + cleave/viz/preset_switching.py | 227 ++++++++++++++---- cleave/viz/row_fields.py | 58 ++++- cleave/viz/row_layout.py | 9 +- cleave/viz/row_sections.py | 16 ++ cleave/viz/row_semantics.py | 22 ++ cleave/viz/session.py | 10 + cleave/viz/theme.py | 3 +- cleave/viz/timeline_controls.py | 9 + cleave/viz/timeline_overlay.py | 42 +++- cleave/viz/tuning_panel_draw.py | 13 +- cleave/viz/tuning_view_state.py | 31 ++- cleave/viz/wiring.py | 8 + tests/cleave/test_preset_switching_config.py | 42 ++++ tests/cleave/viz/test_app.py | 5 +- tests/cleave/viz/test_controls.py | 96 ++++++++ tests/cleave/viz/test_help_overlay.py | 29 +++ tests/cleave/viz/test_overlay.py | 24 ++ tests/cleave/viz/test_panel_notification.py | 46 ++++ tests/cleave/viz/test_preset_switching.py | 158 +++++++++--- tests/cleave/viz/test_row_fields.py | 2 +- tests/cleave/viz/test_row_sections.py | 10 + tests/cleave/viz/test_row_semantics.py | 6 + tests/cleave/viz/test_timeline_controls.py | 26 ++ tests/cleave/viz/test_timeline_overlay.py | 16 +- tests/cleave/viz/test_view_state_structure.py | 72 ++++++ 34 files changed, 1071 insertions(+), 124 deletions(-) create mode 100644 tests/cleave/viz/test_panel_notification.py diff --git a/cleave/config.py b/cleave/config.py index 6e7b5b4..c72491f 100644 --- a/cleave/config.py +++ b/cleave/config.py @@ -16,6 +16,7 @@ _YAML_DUMP_WIDTH = 2**31 - 1 from cleave.blend_modes import BlendMode +from cleave.cue_roles import CueRole from cleave.effects.constants import clamp_effect_pct from cleave.extract import StemSource from cleave.config_schema import ( @@ -24,6 +25,8 @@ BEAT_SENSITIVITY_MIN, DEFAULT_BEAT_SENSITIVITY, DEFAULT_LAYER_Z_ORDER, + DEFAULT_CAST_ROLES_DEFAULT_ROLE, + DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR, DEFAULT_PRESET_SWITCHING, DEFAULT_PRESET_SWITCHING_ROTATION_SET, DEFAULT_PRESET_SWITCHING_SHUFFLE, @@ -36,6 +39,7 @@ DEFAULT_EASTER_EGG, DEFAULT_PRESET_START_CLEAN, DEFAULT_PRESET_ROOT, + CastRolesTimelineBehaviour, PresetSwitchingMode, PresetSwitchingRotationSet, DEFAULT_RENDER_OVERLAY_BACKGROUND_COLOUR, @@ -140,6 +144,10 @@ class LayerConfig: locked: bool = False preset_switching: PresetSwitchingMode = DEFAULT_PRESET_SWITCHING preset_switching_rotation_set: PresetSwitchingRotationSet = DEFAULT_PRESET_SWITCHING_ROTATION_SET + cast_roles_timeline_behaviour: CastRolesTimelineBehaviour = ( + DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR + ) + cast_roles_default_role: CueRole = DEFAULT_CAST_ROLES_DEFAULT_ROLE preset_switching_shuffle: bool = DEFAULT_PRESET_SWITCHING_SHUFFLE preset_switching_shuffle_salt: int = DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT preset_duration: float = DEFAULT_PRESET_DURATION diff --git a/cleave/config_schema.py b/cleave/config_schema.py index 65c6f31..6f67283 100644 --- a/cleave/config_schema.py +++ b/cleave/config_schema.py @@ -10,7 +10,7 @@ from typing import Any, Callable, Literal, TypeVar from cleave.blend_modes import BLEND_MODES, BlendMode -from cleave.cue_roles import CUE_ROLES, CueRole +from cleave.cue_roles import CUE_ROLE_HELP_BLURB, CUE_ROLES, CueRole from cleave.effects.constants import clamp_effect_pct from cleave.effects.registry import validate_effect_entry from cleave.extract import STEM_SOURCES, StemSource @@ -42,7 +42,8 @@ BEAT_SENSITIVITY_MAX = 5.0 PresetSwitchingMode = Literal["none", "projectm", "timeline"] -PresetSwitchingRotationSet = Literal["directory", "user_defined"] +PresetSwitchingRotationSet = Literal["directory", "user_defined", "cast_roles"] +CastRolesTimelineBehaviour = Literal["hold_current", "on_transition"] PRESET_SWITCHING_MODES: tuple[PresetSwitchingMode, ...] = ( "none", "projectm", @@ -59,6 +60,7 @@ PRESET_SWITCHING_ROTATION_SETS: tuple[PresetSwitchingRotationSet, ...] = ( "directory", "user_defined", + "cast_roles", ) PRESET_SWITCHING_ROTATION_SET_HELP_ENTRIES: tuple[ tuple[PresetSwitchingRotationSet, str], ... @@ -68,9 +70,35 @@ "user_defined", "rotate through a fixed list of presets defined in config.", ), + ( + "cast_roles", + "rotate through Milkdrop presets in roles// pools keyed by cue cast.", + ), +) +CAST_ROLES_TIMELINE_BEHAVIOURS: tuple[CastRolesTimelineBehaviour, ...] = ( + "hold_current", + "on_transition", +) +CAST_ROLES_TIMELINE_BEHAVIOUR_HELP_ENTRIES: tuple[ + tuple[CastRolesTimelineBehaviour, str], ... +] = ( + ( + "hold_current", + "only change preset when a new cue cast marker is encountered.", + ), + ( + "on_transition", + "change to a new preset in the last specified role.", + ), +) +CAST_ROLES_DEFAULT_ROLE_HELP_ENTRIES: tuple[tuple[CueRole, str], ...] = tuple( + (role, f"{role} ({CUE_ROLE_HELP_BLURB[role]}).") + for role in CUE_ROLES ) DEFAULT_PRESET_SWITCHING: PresetSwitchingMode = "none" DEFAULT_PRESET_SWITCHING_ROTATION_SET: PresetSwitchingRotationSet = "directory" +DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR: CastRolesTimelineBehaviour = "on_transition" +DEFAULT_CAST_ROLES_DEFAULT_ROLE: CueRole = "bed" DEFAULT_PRESET_SWITCHING_SHUFFLE = False DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT = 0 DEFAULT_PRESET_DURATION = 30.0 @@ -159,6 +187,8 @@ def new_layer_config(slot: str, preset: Path, preset_root: Path) -> Any: locked=False, preset_switching=DEFAULT_PRESET_SWITCHING, preset_switching_rotation_set=DEFAULT_PRESET_SWITCHING_ROTATION_SET, + cast_roles_timeline_behaviour=DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR, + cast_roles_default_role=DEFAULT_CAST_ROLES_DEFAULT_ROLE, preset_switching_presets=[], ) @@ -484,6 +514,16 @@ def _parse_preset_switching_rotation_set(raw: Any, label: str) -> PresetSwitchin return rotation_set +def _parse_cast_roles_timeline_behaviour( + raw: Any, label: str +) -> CastRolesTimelineBehaviour: + behaviour = str(raw) + if behaviour not in CAST_ROLES_TIMELINE_BEHAVIOURS: + allowed = ", ".join(CAST_ROLES_TIMELINE_BEHAVIOURS) + raise ValueError(f"{label} must be one of: {allowed}") + return behaviour # type: ignore[return-value] + + def hard_cut_enabled_display(enabled: bool) -> str: return "enabled" if enabled else "disabled" @@ -604,9 +644,23 @@ def preset_switching_rotation_set_display( ) -> str: if rotation_set == "user_defined": return "user-defined" + if rotation_set == "cast_roles": + return "cast roles" return "directory" +def cast_roles_timeline_behaviour_display( + behaviour: CastRolesTimelineBehaviour, +) -> str: + if behaviour == "hold_current": + return "hold current" + return "on transition" + + +def cast_roles_default_role_display(role: CueRole) -> str: + return role + + @dataclass(frozen=True) class FieldDescriptor: """Leaf field: YAML key, default, parse/dump, and persistence source.""" @@ -1773,6 +1827,19 @@ def parse_layers_section(data: dict[str, Any], ctx: ParseCtx) -> dict[str, Any]: layer_raw.get("preset_switching_rotation_set", DEFAULT_PRESET_SWITCHING_ROTATION_SET), f"layers.{slot}.preset_switching_rotation_set", ) + cast_roles_timeline_behaviour = _parse_cast_roles_timeline_behaviour( + layer_raw.get( + "cast_roles_timeline_behaviour", + DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR, + ), + f"layers.{slot}.cast_roles_timeline_behaviour", + ) + cast_roles_default_role = validate_cue_role( + layer_raw.get( + "cast_roles_default_role", DEFAULT_CAST_ROLES_DEFAULT_ROLE + ), + path=f"layers.{slot}.cast_roles_default_role", + ) preset_switching_shuffle = bool( layer_raw.get( "preset_switching_shuffle", DEFAULT_PRESET_SWITCHING_SHUFFLE @@ -1821,6 +1888,8 @@ def parse_layers_section(data: dict[str, Any], ctx: ParseCtx) -> dict[str, Any]: locked=bool(layer_raw.get("locked", False)), preset_switching=preset_switching, preset_switching_rotation_set=preset_switching_rotation_set, + cast_roles_timeline_behaviour=cast_roles_timeline_behaviour, + cast_roles_default_role=cast_roles_default_role, preset_switching_shuffle=preset_switching_shuffle, preset_switching_shuffle_salt=preset_switching_shuffle_salt, preset_duration=preset_duration, @@ -1856,6 +1925,8 @@ def persist_layers(ctx: PersistCtx) -> dict[str, dict[str, Any]]: locked = runtime.locked preset_switching = runtime.preset_switching preset_switching_rotation_set = runtime.preset_switching_rotation_set + cast_roles_timeline_behaviour = runtime.cast_roles_timeline_behaviour + cast_roles_default_role = runtime.cast_roles_default_role preset_switching_shuffle = runtime.preset_switching_shuffle preset_switching_shuffle_salt = runtime.preset_switching_shuffle_salt preset_duration = runtime.preset_duration @@ -1877,6 +1948,8 @@ def persist_layers(ctx: PersistCtx) -> dict[str, dict[str, Any]]: locked = layer_cfg.locked preset_switching = layer_cfg.preset_switching preset_switching_rotation_set = layer_cfg.preset_switching_rotation_set + cast_roles_timeline_behaviour = layer_cfg.cast_roles_timeline_behaviour + cast_roles_default_role = layer_cfg.cast_roles_default_role preset_switching_shuffle = layer_cfg.preset_switching_shuffle preset_switching_shuffle_salt = layer_cfg.preset_switching_shuffle_salt preset_duration = layer_cfg.preset_duration @@ -1912,6 +1985,10 @@ def persist_layers(ctx: PersistCtx) -> dict[str, dict[str, Any]]: layer_out["preset_switching"] = preset_switching if preset_switching_rotation_set != DEFAULT_PRESET_SWITCHING_ROTATION_SET: layer_out["preset_switching_rotation_set"] = preset_switching_rotation_set + if cast_roles_timeline_behaviour != DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR: + layer_out["cast_roles_timeline_behaviour"] = cast_roles_timeline_behaviour + if cast_roles_default_role != DEFAULT_CAST_ROLES_DEFAULT_ROLE: + layer_out["cast_roles_default_role"] = cast_roles_default_role if preset_switching_shuffle != DEFAULT_PRESET_SWITCHING_SHUFFLE: layer_out["preset_switching_shuffle"] = preset_switching_shuffle if preset_switching_shuffle_salt != DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT: diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index d52341d..b77a92b 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -11,8 +11,13 @@ from cleave.config import CleaveConfig, clamp_beat_sensitivity, clamp_effect_pct from cleave.config_schema import clamp_easter_egg -from cleave.config_schema import PRESET_SWITCHING_MODES, PRESET_SWITCHING_ROTATION_SETS +from cleave.config_schema import ( + CAST_ROLES_TIMELINE_BEHAVIOURS, + PRESET_SWITCHING_MODES, + PRESET_SWITCHING_ROTATION_SETS, +) from cleave.blend_modes import BLEND_MODES, BlendMode +from cleave.cue_roles import CUE_ROLES, role_pool_paths from cleave.extract import STEM_SOURCES from cleave.signals import Signals from cleave.song_markers import format_marker_time, place_marker @@ -714,6 +719,7 @@ def key_repeat_armed(self) -> bool: def tick(self, dt_sec: float) -> None: self._key_repeat.tick(dt_sec) self._notification_host.clear_expired() + self._sync_cast_roles_pool_notification() def build_view_state( self, @@ -722,10 +728,24 @@ def build_view_state( position_sec: float | None = None, fps: float | None = None, ) -> TuningViewState: + self._sync_cast_roles_pool_notification() return self._view_state.build( paused=paused, position_sec=position_sec, fps=fps ) + def _sync_cast_roles_pool_notification(self) -> None: + for slot in self.session.layer_z_order: + layer = self.session.layers[slot] + if layer.preset_switching_rotation_set != "cast_roles": + continue + role = layer.cast_roles_default_role + if not role_pool_paths(self.preset_root, role): + self._notification_host.set_persistent( + f"No presets in {role} roles folder" + ) + return + self._notification_host.set_persistent(None) + @property def focus_descriptor(self) -> RowDescriptor: return cursor_main_descriptor(self.focus_cursor) @@ -1082,6 +1102,11 @@ def _cycle_preset_switching(self, slot: str, *, forward: bool) -> None: layer.preset_switching = modes[(index + 1) % len(modes)] else: layer.preset_switching = modes[(index - 1) % len(modes)] + if ( + layer.preset_switching != "timeline" + and layer.preset_switching_rotation_set == "cast_roles" + ): + layer.preset_switching_rotation_set = "directory" if ( layer.preset_switching == "timeline" and not self.session.timeline.enabled @@ -1092,20 +1117,62 @@ def _cycle_preset_switching(self, slot: str, *, forward: bool) -> None: def _cycle_preset_switching_rotation_set(self, slot: str, *, forward: bool) -> None: layer = self.session.layers[slot] - rotation_sets = PRESET_SWITCHING_ROTATION_SETS + rotation_sets = tuple( + value + for value in PRESET_SWITCHING_ROTATION_SETS + if value != "cast_roles" or layer.preset_switching == "timeline" + ) try: index = rotation_sets.index(layer.preset_switching_rotation_set) except ValueError: index = 0 if forward: - layer.preset_switching_rotation_set = rotation_sets[(index + 1) % len(rotation_sets)] + layer.preset_switching_rotation_set = rotation_sets[ + (index + 1) % len(rotation_sets) + ] else: - layer.preset_switching_rotation_set = rotation_sets[(index - 1) % len(rotation_sets)] + layer.preset_switching_rotation_set = rotation_sets[ + (index - 1) % len(rotation_sets) + ] if layer.preset_switching_rotation_set == "user_defined": layer.user_presets_expanded = True if self._layer_bindings is not None: self._layer_bindings.on_preset_switching_change(slot) + def _cycle_cast_roles_timeline_behaviour( + self, slot: str, *, forward: bool + ) -> None: + layer = self.session.layers[slot] + options = CAST_ROLES_TIMELINE_BEHAVIOURS + try: + index = options.index(layer.cast_roles_timeline_behaviour) + except ValueError: + index = 0 + if forward: + layer.cast_roles_timeline_behaviour = options[ + (index + 1) % len(options) + ] + else: + layer.cast_roles_timeline_behaviour = options[ + (index - 1) % len(options) + ] + if self._layer_bindings is not None: + self._layer_bindings.on_preset_switching_change(slot) + + def _cycle_cast_roles_default_role(self, slot: str, *, forward: bool) -> None: + layer = self.session.layers[slot] + options = CUE_ROLES + try: + index = options.index(layer.cast_roles_default_role) + except ValueError: + index = 0 + if forward: + layer.cast_roles_default_role = options[(index + 1) % len(options)] + else: + layer.cast_roles_default_role = options[(index - 1) % len(options)] + if self._layer_bindings is not None: + self._layer_bindings.on_preset_switching_change(slot) + def _step_preset_duration( self, slot: str, *, forward: bool, ctrl: bool = False ) -> None: diff --git a/cleave/viz/help_content.py b/cleave/viz/help_content.py index dbe98c2..095aed9 100644 --- a/cleave/viz/help_content.py +++ b/cleave/viz/help_content.py @@ -229,7 +229,10 @@ def timeline_strip_section( ("Shift + , / .", "nudge cue opacity -/+ 1%"), ("Ctrl + , / .", "nudge cue opacity -/+ 10%"), ("B / Shift+B", "cycle cue blend"), - ("C / Shift+C", "cycle cue cast"), + ( + "C / Shift+C", + "cycle cue cast (requires cast roles rotation set)", + ), ) ) diff --git a/cleave/viz/layer.py b/cleave/viz/layer.py index 44add85..9ba2e9e 100644 --- a/cleave/viz/layer.py +++ b/cleave/viz/layer.py @@ -25,5 +25,6 @@ class StemLayer: limiter_gain: float = 1.0 preset_rotation: PresetRotation | None = None role_rotations: dict[CueRole, PresetRotation] = field(default_factory=dict) + last_cast_role: CueRole | None = None timeline_switch_count: int = 0 rotation_anchor: int = 0 diff --git a/cleave/viz/layer_pipeline.py b/cleave/viz/layer_pipeline.py index 7948ab1..b3ddf39 100644 --- a/cleave/viz/layer_pipeline.py +++ b/cleave/viz/layer_pipeline.py @@ -239,6 +239,8 @@ def build_single( hard_cut_sensitivity=layer_cfg.hard_cut_sensitivity, shuffle=layer_cfg.preset_switching_shuffle, shuffle_salt=layer_cfg.preset_switching_shuffle_salt, + cast_roles_default_role=layer_cfg.cast_roles_default_role, + cast_roles_timeline_behaviour=layer_cfg.cast_roles_timeline_behaviour, preset_root=preset_root, ) return layer diff --git a/cleave/viz/layer_visibility.py b/cleave/viz/layer_visibility.py index ca417a7..63c27be 100644 --- a/cleave/viz/layer_visibility.py +++ b/cleave/viz/layer_visibility.py @@ -357,4 +357,8 @@ def build_timeline_view_state( standard_cue_fades=_as_fade_group(tl.standard_cue_fades), selected_cue_t=dict(tl.selected_cue_t), selected_cue_flash_start_ms=tl.selected_cue_flash_start_ms, + slot_rotation_sets={ + slot: session.layers[slot].preset_switching_rotation_set + for slot in session.layer_z_order + }, ) diff --git a/cleave/viz/panel_notification.py b/cleave/viz/panel_notification.py index 41509da..5a836c9 100644 --- a/cleave/viz/panel_notification.py +++ b/cleave/viz/panel_notification.py @@ -3,30 +3,50 @@ from __future__ import annotations import time +from typing import NamedTuple NOTIFICATION_DURATION_SEC = 5.0 +class PanelNotificationActive(NamedTuple): + """Active notification lines for the tuning panel header.""" + + persistent_message: str | None + message: str | None + remaining_sec: float + + class PanelNotificationHost: - """Single-slot notification state with monotonic expiry.""" + """Notification state: optional persistent line plus one timed toast.""" def __init__(self) -> None: self._message: str | None = None self._deadline = 0.0 + self._persistent_message: str | None = None def show(self, message: str) -> None: now = time.monotonic() self._message = message self._deadline = now + NOTIFICATION_DURATION_SEC + def set_persistent(self, message: str | None) -> None: + self._persistent_message = message + def clear_expired(self) -> None: if self._message is not None and time.monotonic() >= self._deadline: self._message = None - def active(self) -> tuple[str | None, float]: - if self._message is None: - return None, 0.0 - remaining = max(0.0, self._deadline - time.monotonic()) - if remaining <= 0: - return None, 0.0 - return self._message, remaining + def active(self) -> PanelNotificationActive: + timed: str | None = None + remaining = 0.0 + if self._message is not None: + remaining = max(0.0, self._deadline - time.monotonic()) + if remaining > 0: + timed = self._message + else: + remaining = 0.0 + return PanelNotificationActive( + persistent_message=self._persistent_message, + message=timed, + remaining_sec=remaining, + ) diff --git a/cleave/viz/preset_seed_controls.py b/cleave/viz/preset_seed_controls.py index 7621fcb..fec918e 100644 --- a/cleave/viz/preset_seed_controls.py +++ b/cleave/viz/preset_seed_controls.py @@ -74,7 +74,10 @@ def _rebuild(self, slot: str, runtime: LayerRuntime) -> None: shuffle=runtime.preset_switching_shuffle, shuffle_salt=runtime.preset_switching_shuffle_salt, preset_start_clean=runtime.preset_start_clean, + cast_roles_default_role=runtime.cast_roles_default_role, + cast_roles_timeline_behaviour=runtime.cast_roles_timeline_behaviour, preset_root=self._preset_root, + session=self.session, ) return if self._on_preset_switching_change is not None: diff --git a/cleave/viz/preset_switching.py b/cleave/viz/preset_switching.py index a212f76..bf195de 100644 --- a/cleave/viz/preset_switching.py +++ b/cleave/viz/preset_switching.py @@ -6,6 +6,8 @@ from pathlib import Path from cleave.config_schema import ( + DEFAULT_CAST_ROLES_DEFAULT_ROLE, + DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR, DEFAULT_EASTER_EGG, DEFAULT_HARD_CUT_DURATION, DEFAULT_HARD_CUT_ENABLED, @@ -15,6 +17,7 @@ DEFAULT_PRESET_SWITCHING_SHUFFLE, DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT, DEFAULT_SOFT_CUT_DURATION, + CastRolesTimelineBehaviour, PresetSwitchingMode, PresetSwitchingRotationSet, ) @@ -62,6 +65,7 @@ def _apply_projectm_timing( def _clear_timeline_rotation(layer: StemLayer) -> None: layer.preset_rotation = None layer.role_rotations = {} + layer.last_cast_role = None layer.timeline_switch_count = 0 layer.rotation_anchor = 0 @@ -93,15 +97,39 @@ def _timeline_path_for_count( layer: StemLayer, *, count: int, +) -> Path | None: + """Seek-stable path for transition ``count`` from the main rotation only.""" + rotation = layer.preset_rotation + if rotation is None: + return None + return rotation.path_for(count) + + +def _cast_roles_path_for_count( + layer: StemLayer, + *, + count: int, lane, song_marker_times: Sequence[float], song_marker_fades: TimelineFadeGroup, standard_fades: TimelineFadeGroup, + behaviour: CastRolesTimelineBehaviour, + default_role: CueRole, ) -> Path | None: - """Seek-stable path for transition ``count`` (main or role pool).""" - rotation = layer.preset_rotation - if rotation is None or count < 1: - return None if rotation is None else rotation.path_for(count) + """Seek-stable path after ``count`` on-transitions for cast-roles rotation. + + ``on_transition``: every rise advances; null cue role uses ``last_cast_role`` + (initialised to ``default_role``). + ``hold_current``: only role-marked cues advance; null-role cues keep the + prior path (caller skips reload when unchanged). + Count 0 uses default-role pool entry 0. + """ + default_rotation = layer.role_rotations.get(default_role) or layer.preset_rotation + if default_rotation is None: + return None + if count < 1: + layer.last_cast_role = default_role + return default_rotation.path_for(0) cues = lane_on_transition_cues( lane, @@ -109,25 +137,28 @@ def _timeline_path_for_count( song_marker_fades=song_marker_fades, standard_fades=standard_fades, ) - if count > len(cues): - return rotation.path_for(count) - - crossed = cues[count - 1][1] - role = crossed.role - if role is None: - return rotation.path_for(count) - - role_rotation = layer.role_rotations.get(role) - if role_rotation is None: - return rotation.path_for(count) + last_role: CueRole = default_role + role_use_count: dict[CueRole, int] = {role: 0 for role in CUE_ROLES} + active_path: Path | None = default_rotation.path_for(0) + limit = min(count, len(cues)) + + for index in range(limit): + cue = cues[index][1] + if behaviour == "hold_current": + if cue.role is None: + continue + role = cue.role + else: + role = cue.role if cue.role is not None else last_role + last_role = role + role_rotation = layer.role_rotations.get(role) + if role_rotation is None: + continue + active_path = role_rotation.path_for(role_use_count[role]) + role_use_count[role] += 1 - earlier_same = sum( - 1 for _trigger, cue in cues[: count - 1] if cue.role == role - ) - path = role_rotation.path_for(earlier_same) - if path is None: - return rotation.path_for(count) - return path + layer.last_cast_role = last_role + return active_path def reapply_projectm_preset_switching( @@ -161,6 +192,8 @@ def reapply_projectm_preset_switching( hard_cut_enabled=runtime.hard_cut_enabled, hard_cut_duration=runtime.hard_cut_duration, hard_cut_sensitivity=runtime.hard_cut_sensitivity, + cast_roles_default_role=runtime.cast_roles_default_role, + cast_roles_timeline_behaviour=runtime.cast_roles_timeline_behaviour, preset_root=preset_root, ) continue @@ -193,7 +226,12 @@ def apply_preset_switching( hard_cut_enabled: bool = DEFAULT_HARD_CUT_ENABLED, hard_cut_duration: float = DEFAULT_HARD_CUT_DURATION, hard_cut_sensitivity: float = DEFAULT_HARD_CUT_SENSITIVITY, + cast_roles_default_role: CueRole = DEFAULT_CAST_ROLES_DEFAULT_ROLE, + cast_roles_timeline_behaviour: CastRolesTimelineBehaviour = ( + DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR + ), on_empty: Callable[[], None] | None = None, + session=None, ) -> None: pm = layer.pm @@ -221,7 +259,10 @@ def apply_preset_switching( shuffle=shuffle, shuffle_salt=shuffle_salt, preset_start_clean=preset_start_clean, + cast_roles_default_role=cast_roles_default_role, + cast_roles_timeline_behaviour=cast_roles_timeline_behaviour, preset_root=preset_root, + session=session, ) return _apply_timeline_preset_switching( @@ -231,6 +272,7 @@ def apply_preset_switching( shuffle=shuffle, shuffle_salt=shuffle_salt, preset_start_clean=preset_start_clean, + cast_roles_default_role=cast_roles_default_role, preset_root=preset_root, on_empty=on_empty, ) @@ -248,8 +290,16 @@ def apply_preset_switching( hard_cut_sensitivity=hard_cut_sensitivity, ) + # cast_roles is timeline-only; projectM mode uses the browse directory. + effective_rotation: PresetSwitchingRotationSet = ( + "directory" if rotation_set == "cast_roles" else rotation_set + ) paths = _rotation_paths( - layer, rotation_set=rotation_set, user_presets=user_presets + layer, + rotation_set=effective_rotation, + user_presets=user_presets, + preset_root=preset_root, + cast_roles_default_role=cast_roles_default_role, ) if not paths: layer.auto_preset_path = None @@ -266,7 +316,7 @@ def apply_preset_switching( ) ordered = first_shuffle_bag_order(paths, seed=seed) playlist.add_presets(ordered, allow_duplicates=True) - elif rotation_set == "user_defined": + elif effective_rotation == "user_defined": playlist.add_presets(paths, allow_duplicates=True) else: playlist.add_path( @@ -286,9 +336,15 @@ def _rotation_paths( *, rotation_set: PresetSwitchingRotationSet, user_presets: list[str] | None, + preset_root: Path | None = None, + cast_roles_default_role: CueRole = DEFAULT_CAST_ROLES_DEFAULT_ROLE, ) -> list[Path]: if rotation_set == "user_defined": return [Path(path) for path in (user_presets or [])] + if rotation_set == "cast_roles": + if preset_root is None: + return [] + return list(role_pool_paths(preset_root, cast_roles_default_role)) return list(milk_files_in_dir(layer.playlist.current_dir)) @@ -311,6 +367,7 @@ def _apply_timeline_preset_switching( shuffle: bool, shuffle_salt: int, preset_start_clean: bool, + cast_roles_default_role: CueRole, preset_root: Path, on_empty: Callable[[], None] | None, ) -> None: @@ -320,7 +377,11 @@ def _apply_timeline_preset_switching( pm.set_preset_start_clean(preset_start_clean) paths = _rotation_paths( - layer, rotation_set=rotation_set, user_presets=user_presets + layer, + rotation_set=rotation_set, + user_presets=user_presets, + preset_root=preset_root, + cast_roles_default_role=cast_roles_default_role, ) if not paths: _clear_timeline_rotation(layer) @@ -332,6 +393,9 @@ def _apply_timeline_preset_switching( anchor = _anchor_index(layer, paths) layer.rotation_anchor = anchor layer.timeline_switch_count = 0 + layer.last_cast_role = ( + cast_roles_default_role if rotation_set == "cast_roles" else None + ) layer.preset_rotation = PresetRotation( paths=tuple(paths), shuffle=shuffle, @@ -340,12 +404,24 @@ def _apply_timeline_preset_switching( ), anchor=anchor, ) - layer.role_rotations = _build_role_rotations( - layer, - preset_root, - shuffle=shuffle, - shuffle_salt=shuffle_salt, - ) + if rotation_set == "cast_roles": + layer.role_rotations = _build_role_rotations( + layer, + preset_root, + shuffle=shuffle, + shuffle_salt=shuffle_salt, + ) + default_rotation = layer.role_rotations.get(cast_roles_default_role) + if default_rotation is not None: + layer.preset_rotation = PresetRotation( + paths=default_rotation.paths, + shuffle=shuffle, + seed=default_rotation.seed, + anchor=0, + ) + layer.rotation_anchor = 0 + else: + layer.role_rotations = {} path = layer.preset_rotation.path_for(0) if path is None: return @@ -361,6 +437,11 @@ def rebuild_timeline_preset_rotation_preserving_count( shuffle: bool = DEFAULT_PRESET_SWITCHING_SHUFFLE, shuffle_salt: int = DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT, preset_start_clean: bool = DEFAULT_PRESET_START_CLEAN, + cast_roles_default_role: CueRole = DEFAULT_CAST_ROLES_DEFAULT_ROLE, + cast_roles_timeline_behaviour: CastRolesTimelineBehaviour = ( + DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR + ), + session=None, ) -> None: """Rebuild timeline rotation; keep anchor and switch count. @@ -368,7 +449,11 @@ def rebuild_timeline_preset_rotation_preserving_count( active ``path_for(count)`` stays aligned with the playhead-derived count. """ paths = _rotation_paths( - layer, rotation_set=rotation_set, user_presets=user_presets + layer, + rotation_set=rotation_set, + user_presets=user_presets, + preset_root=preset_root, + cast_roles_default_role=cast_roles_default_role, ) if not paths: _clear_timeline_rotation(layer) @@ -385,13 +470,43 @@ def rebuild_timeline_preset_rotation_preserving_count( ), anchor=anchor, ) - layer.role_rotations = _build_role_rotations( - layer, - preset_root, - shuffle=shuffle, - shuffle_salt=shuffle_salt, - ) - path = layer.preset_rotation.path_for(count) + if rotation_set == "cast_roles": + layer.role_rotations = _build_role_rotations( + layer, + preset_root, + shuffle=shuffle, + shuffle_salt=shuffle_salt, + ) + default_rotation = layer.role_rotations.get(cast_roles_default_role) + if default_rotation is not None: + layer.preset_rotation = PresetRotation( + paths=default_rotation.paths, + shuffle=shuffle, + seed=default_rotation.seed, + anchor=0, + ) + else: + layer.role_rotations = {} + layer.last_cast_role = None + + path: Path | None + if rotation_set == "cast_roles" and session is not None: + song_marker_fades, standard_fades, song_marker_times = _timeline_fade_groups( + session + ) + lane = session.timeline.lanes.get(layer.slot) or empty_lane() + path = _cast_roles_path_for_count( + layer, + count=count, + lane=lane, + song_marker_times=song_marker_times, + song_marker_fades=song_marker_fades, + standard_fades=standard_fades, + behaviour=cast_roles_timeline_behaviour, + default_role=cast_roles_default_role, + ) + else: + path = layer.preset_rotation.path_for(count) if path is None: return _load_timeline_preset(layer, path, preset_start_clean=preset_start_clean) @@ -465,17 +580,27 @@ def advance_timeline_preset_switching( ) if count == layer.timeline_switch_count: continue - path = _timeline_path_for_count( - layer, - count=count, - lane=lane, - song_marker_times=song_marker_times, - song_marker_fades=song_marker_fades, - standard_fades=standard_fades, - ) + if runtime.preset_switching_rotation_set == "cast_roles": + path = _cast_roles_path_for_count( + layer, + count=count, + lane=lane, + song_marker_times=song_marker_times, + song_marker_fades=song_marker_fades, + standard_fades=standard_fades, + behaviour=runtime.cast_roles_timeline_behaviour, + default_role=runtime.cast_roles_default_role, + ) + else: + path = _timeline_path_for_count(layer, count=count) layer.timeline_switch_count = count if path is None: continue + if ( + layer.auto_preset_path is not None + and path.resolve() == layer.auto_preset_path + ): + continue _load_timeline_preset( layer, path, @@ -501,6 +626,8 @@ def reanchor_timeline_preset_after_browse( user_presets: list[str] | None = None, shuffle: bool = DEFAULT_PRESET_SWITCHING_SHUFFLE, shuffle_salt: int = DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT, + cast_roles_default_role: CueRole = DEFAULT_CAST_ROLES_DEFAULT_ROLE, + preset_root: Path | None = None, ) -> None: """Keep the browsed preset; next on-transition advances from the following entry.""" pm = layer.pm @@ -508,7 +635,11 @@ def reanchor_timeline_preset_after_browse( pm.set_hard_cut_enabled(False) paths = _rotation_paths( - layer, rotation_set=rotation_set, user_presets=user_presets + layer, + rotation_set=rotation_set, + user_presets=user_presets, + preset_root=preset_root, + cast_roles_default_role=cast_roles_default_role, ) if not paths: _clear_timeline_rotation(layer) diff --git a/cleave/viz/row_fields.py b/cleave/viz/row_fields.py index 41db011..f86248c 100644 --- a/cleave/viz/row_fields.py +++ b/cleave/viz/row_fields.py @@ -17,6 +17,8 @@ TIMELINE_FADE_DURATION_STEP, VISUAL_LIMITER_RELEASE_STEP, VISUAL_LIMITER_THRESHOLD_STEP, + cast_roles_default_role_display, + cast_roles_timeline_behaviour_display, clamp_timeline_fade_duration, clamp_visual_limiter_release, clamp_visual_limiter_threshold, @@ -532,6 +534,22 @@ def _format_track_preset_switching_rotation_set( ) +def _format_track_cast_roles_timeline_behaviour( + state: TuningViewState, desc: RowDescriptor +) -> str: + return cast_roles_timeline_behaviour_display( + _track_block(state, desc).cast_roles_timeline_behaviour + ) + + +def _format_track_cast_roles_default_role( + state: TuningViewState, desc: RowDescriptor +) -> str: + return cast_roles_default_role_display( + _track_block(state, desc).cast_roles_default_role + ) + + def _format_track_preset_duration(state: TuningViewState, desc: RowDescriptor) -> str: return f"{_track_block(state, desc).preset_duration:g}s" @@ -793,6 +811,30 @@ def _apply_track_preset_switching_rotation_set( controls._cycle_preset_switching_rotation_set(desc.slot, forward=forward) +def _apply_track_cast_roles_timeline_behaviour( + controls: TuningControls, + desc: RowDescriptor, + forward: bool, + _ctrl: bool, + _shift: bool, +) -> None: + if desc.slot is None: + return + controls._cycle_cast_roles_timeline_behaviour(desc.slot, forward=forward) + + +def _apply_track_cast_roles_default_role( + controls: TuningControls, + desc: RowDescriptor, + forward: bool, + _ctrl: bool, + _shift: bool, +) -> None: + if desc.slot is None: + return + controls._cycle_cast_roles_default_role(desc.slot, forward=forward) + + def _apply_track_preset_duration( controls: TuningControls, desc: RowDescriptor, forward: bool, ctrl: bool, _shift: bool, @@ -1248,7 +1290,9 @@ def _format_transport(_state: TuningViewState, _desc: RowDescriptor) -> str: return "" -def _format_panel_notification(state: TuningViewState, _desc: RowDescriptor) -> str: +def _format_panel_notification(state: TuningViewState, desc: RowDescriptor) -> str: + if desc.marker_index == 0: + return state.persistent_notification_message or "" return state.notification_message or "" @@ -1463,6 +1507,18 @@ def _apply_transport( format_value=_format_track_preset_switching_rotation_set, apply_horizontal=_apply_track_preset_switching_rotation_set, ), + RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR: RowFieldDef( + panel_label="timeline behaviour", + present_style=RowPresentStyle.LABELED_VALUE, + format_value=_format_track_cast_roles_timeline_behaviour, + apply_horizontal=_apply_track_cast_roles_timeline_behaviour, + ), + RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE: RowFieldDef( + panel_label="default role", + present_style=RowPresentStyle.LABELED_VALUE, + format_value=_format_track_cast_roles_default_role, + apply_horizontal=_apply_track_cast_roles_default_role, + ), RowKind.TRACK_PRESET_SWITCHING_SHUFFLE: RowFieldDef( panel_label="shuffle", present_style=RowPresentStyle.LABELED_VALUE, diff --git a/cleave/viz/row_layout.py b/cleave/viz/row_layout.py index df81bd1..fe9449b 100644 --- a/cleave/viz/row_layout.py +++ b/cleave/viz/row_layout.py @@ -143,8 +143,15 @@ def build(cls, state: TuningViewState) -> RowLayout: if not curation: row_list.append(RowDescriptor(RowKind.CONFIG_HEADER)) row_list.append(RowDescriptor(RowKind.TRANSPORT)) + # marker_index 0 = persistent error; 1 = timed toast (may stack). + if state.persistent_notification_message: + row_list.append( + RowDescriptor(RowKind.PANEL_NOTIFICATION, marker_index=0) + ) if state.notification_message and state.notification_remaining_sec > 0: - row_list.append(RowDescriptor(RowKind.PANEL_NOTIFICATION)) + row_list.append( + RowDescriptor(RowKind.PANEL_NOTIFICATION, marker_index=1) + ) if curation: if state.layer_z_order: append_expand_section_rows( diff --git a/cleave/viz/row_sections.py b/cleave/viz/row_sections.py index 1b6b572..536667f 100644 --- a/cleave/viz/row_sections.py +++ b/cleave/viz/row_sections.py @@ -525,6 +525,12 @@ def _preset_switching_user_defined(state: TuningViewState, desc: RowDescriptor) return state.tracks[desc.slot].preset_switching_rotation_set == "user_defined" +def _preset_switching_cast_roles(state: TuningViewState, desc: RowDescriptor) -> bool: + if desc.slot is None: + return False + return state.tracks[desc.slot].preset_switching_rotation_set == "cast_roles" + + def _preset_switching_projectm(state: TuningViewState, desc: RowDescriptor) -> bool: if desc.slot is None: return False @@ -574,6 +580,15 @@ def _preset_switching_shuffle_on(state: TuningViewState, desc: RowDescriptor) -> children=(SectionNode(expand=USER_PRESETS_SECTION),), ) +PRESET_SWITCHING_CAST_ROLES = ConditionalRowsDef( + name="preset_switching_cast_roles", + predicate=_preset_switching_cast_roles, + children=( + SectionNode(leaf_kind=RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR), + SectionNode(leaf_kind=RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE), + ), +) + PRESET_SWITCHING_PROJECTM = ConditionalRowsDef( name="preset_switching_projectm", predicate=_preset_switching_projectm, @@ -594,6 +609,7 @@ def _preset_switching_shuffle_on(state: TuningViewState, desc: RowDescriptor) -> children=( SectionNode(leaf_kind=RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET), SectionNode(conditional=PRESET_SWITCHING_USER_DEFINED), + SectionNode(conditional=PRESET_SWITCHING_CAST_ROLES), SectionNode(leaf_kind=RowKind.TRACK_PRESET_SWITCHING_SHUFFLE), SectionNode(conditional=PRESET_SWITCHING_SHUFFLE_ON), SectionNode(leaf_kind=RowKind.TRACK_PRESET_START_CLEAN), diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index feb5662..1289613 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -7,6 +7,8 @@ from cleave.blend_modes import BLEND_MODE_HELP_ENTRIES from cleave.config_schema import ( + CAST_ROLES_DEFAULT_ROLE_HELP_ENTRIES, + CAST_ROLES_TIMELINE_BEHAVIOUR_HELP_ENTRIES, CHROMA_BOOST_APPLY_MODE_HELP_ENTRIES, CHROMA_BOOST_VARIANT_HELP_ENTRIES, HIGHLIGHT_ROLLOFF_APPLY_MODE_HELP_ENTRIES, @@ -33,6 +35,8 @@ class RowKind(Enum): TRACK_USER_PRESET_ITEM = auto() TRACK_USER_PRESET_ADD = auto() TRACK_PRESET_SWITCHING_ROTATION_SET = auto() + TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR = auto() + TRACK_CAST_ROLES_DEFAULT_ROLE = auto() TRACK_PRESET_SWITCHING_SHUFFLE = auto() TRACK_PRESET_SWITCHING_SEED = auto() TRACK_PRESET_DURATION = auto() @@ -270,6 +274,24 @@ class RowBehavior: help_description=(), help_mode_entries=PRESET_SWITCHING_ROTATION_SET_HELP_ENTRIES, ), + RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR: RowBehavior( + RowAffordance.VALUE_STEP, + repeatable=True, + parent_group="track", + help_title="Timeline behaviour", + help_entries=(("Left/Right", "cycle behaviour"),), + help_description=(), + help_mode_entries=CAST_ROLES_TIMELINE_BEHAVIOUR_HELP_ENTRIES, + ), + RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE: RowBehavior( + RowAffordance.VALUE_STEP, + repeatable=True, + parent_group="track", + help_title="Default role", + help_entries=(("Left/Right", "cycle role"),), + help_description=(), + help_mode_entries=CAST_ROLES_DEFAULT_ROLE_HELP_ENTRIES, + ), RowKind.TRACK_PRESET_SWITCHING_SHUFFLE: RowBehavior( RowAffordance.VALUE_STEP, repeatable=True, diff --git a/cleave/viz/session.py b/cleave/viz/session.py index 6be962c..65afd50 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -17,6 +17,8 @@ ) from cleave.config_schema import ( DEFAULT_BEAT_SENSITIVITY, + DEFAULT_CAST_ROLES_DEFAULT_ROLE, + DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR, DEFAULT_PRESET_SWITCHING, DEFAULT_PRESET_SWITCHING_ROTATION_SET, DEFAULT_PRESET_SWITCHING_SHUFFLE, @@ -35,6 +37,7 @@ DEFAULT_VISUAL_LIMITER_ENABLED, DEFAULT_VISUAL_LIMITER_THRESHOLD, DEFAULT_VISUAL_LIMITER_RELEASE, + CastRolesTimelineBehaviour, HighlightRolloffApplyMode, HighlightRolloffCurve, PresetSwitchingMode, @@ -46,6 +49,7 @@ default_chroma_boost_runtime_values, default_render_post_fx_runtime_values, ) +from cleave.cue_roles import CueRole from cleave.extract import StemSource from cleave.preset_playlist import PresetPlaylist, preset_browse_floor from cleave.projectm_health import PresetSkipNotifyTracker, ProjectMLogNotifyTracker @@ -278,6 +282,10 @@ class LayerRuntime: locked: bool = False preset_switching: PresetSwitchingMode = DEFAULT_PRESET_SWITCHING preset_switching_rotation_set: PresetSwitchingRotationSet = DEFAULT_PRESET_SWITCHING_ROTATION_SET + cast_roles_timeline_behaviour: CastRolesTimelineBehaviour = ( + DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR + ) + cast_roles_default_role: CueRole = DEFAULT_CAST_ROLES_DEFAULT_ROLE preset_switching_shuffle: bool = DEFAULT_PRESET_SWITCHING_SHUFFLE preset_switching_shuffle_salt: int = DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT preset_duration: float = DEFAULT_PRESET_DURATION @@ -490,6 +498,8 @@ def session_from_cfg( locked=layer_cfg.locked, preset_switching=layer_cfg.preset_switching, preset_switching_rotation_set=layer_cfg.preset_switching_rotation_set, + cast_roles_timeline_behaviour=layer_cfg.cast_roles_timeline_behaviour, + cast_roles_default_role=layer_cfg.cast_roles_default_role, preset_switching_shuffle=layer_cfg.preset_switching_shuffle, preset_switching_shuffle_salt=layer_cfg.preset_switching_shuffle_salt, preset_duration=layer_cfg.preset_duration, diff --git a/cleave/viz/theme.py b/cleave/viz/theme.py index d0fa749..a368f7c 100644 --- a/cleave/viz/theme.py +++ b/cleave/viz/theme.py @@ -8,7 +8,7 @@ LOCKED — tinted text for locked sub-rows that cannot be edited Accent colors for modes and icons (not label/value roles): - HIGHLIGHT, MOVE_MODE, LOCK_ICON, PRESET_ICON, PRESET_FILE_ICON, + HIGHLIGHT, ERROR_NOTIFICATION, MOVE_MODE, LOCK_ICON, PRESET_ICON, PRESET_FILE_ICON, TIMELINE_BAR_ON, BAR_GRID, PLAYHEAD, PLAYHEAD_FLASH, SONG_MARKER, SONG_MARKER_SELECTED Layout scales: @@ -152,6 +152,7 @@ def timeline_panel_height_px( ACTION: tuple[int, int, int] = (80, 190, 125) DISABLED: tuple[int, int, int] = (140, 140, 140) HIGHLIGHT: tuple[int, int, int] = (255, 235, 130) +ERROR_NOTIFICATION: tuple[int, int, int] = (255, 140, 140) PRESET_ICON: tuple[int, int, int] = (255, 195, 90) PRESET_FILE_ICON: tuple[int, int, int] = (255, 250, 235) diff --git a/cleave/viz/timeline_controls.py b/cleave/viz/timeline_controls.py index 82f857d..d6c2544 100644 --- a/cleave/viz/timeline_controls.py +++ b/cleave/viz/timeline_controls.py @@ -355,6 +355,15 @@ def _cycle_selected_cue_role(self, *, forward: bool) -> None: if selected is None: return slot, cue = selected + if ( + self.session.layers[slot].preset_switching_rotation_set + != "cast_roles" + ): + if self._on_notification is not None: + self._on_notification( + "Set rotation set to cast roles to assign cast" + ) + return if not cue_editable_for_blend_role(cue): return options: tuple[CueRole | None, ...] = (None, *CUE_ROLES) diff --git a/cleave/viz/timeline_overlay.py b/cleave/viz/timeline_overlay.py index b1470a4..7cf74fc 100644 --- a/cleave/viz/timeline_overlay.py +++ b/cleave/viz/timeline_overlay.py @@ -142,6 +142,7 @@ class TimelineViewState: standard_cue_fades: TimelineFadeGroup = field(default_factory=TimelineFadeGroup) selected_cue_t: dict[str, float] = field(default_factory=dict) selected_cue_flash_start_ms: int | None = None + slot_rotation_sets: dict[str, str] = field(default_factory=dict) def cue_times_for_stem( @@ -357,34 +358,44 @@ def bar_tick_times_for_row(state: TimelineViewState, slot: str) -> list[float]: def selected_cue_readout_segments( cue: SlotCue, + *, + show_cast: bool = False, ) -> list[tuple[str, tuple[int, int, int]]]: """Label/value segments for the selected-cue badge readout.""" blend = cue.blend if cue.blend is not None else "-" - cast = cue.role if cue.role is not None else "-" opacity_pct = int(round(float(cue.level) * 100.0)) - return [ + segments: list[tuple[str, tuple[int, int, int]]] = [ (f"[{format_mmss(cue.t)}] ", VALUE), ("opacity: ", LABEL), (f"{opacity_pct}% ", VALUE), ("blend: ", LABEL), - (f"{blend} ", VALUE), - ("cast: ", LABEL), - (str(cast), VALUE), + (f"{blend}" if not show_cast else f"{blend} ", VALUE), ] + if show_cast: + cast = cue.role if cue.role is not None else "-" + segments.extend( + [ + ("cast: ", LABEL), + (str(cast), VALUE), + ] + ) + return segments -def selected_cue_readout_text(cue: SlotCue) -> str: +def selected_cue_readout_text(cue: SlotCue, *, show_cast: bool = False) -> str: """Plain-text form of the selected-cue badge readout.""" - return "".join(text for text, _ in selected_cue_readout_segments(cue)) + return "".join( + text for text, _ in selected_cue_readout_segments(cue, show_cast=show_cast) + ) def render_selected_cue_readout( - font: pygame.font.Font, cue: SlotCue + font: pygame.font.Font, cue: SlotCue, *, show_cast: bool = False ) -> pygame.Surface: """Badge-strip surface: LABEL prefixes, VALUE for time and field values.""" surfs = [ font.render(text, True, color) - for text, color in selected_cue_readout_segments(cue) + for text, color in selected_cue_readout_segments(cue, show_cast=show_cast) ] width = sum(surf.get_width() for surf in surfs) height = max((surf.get_height() for surf in surfs), default=0) @@ -1129,6 +1140,8 @@ def _draw_role_glyphs( layout.bar_width, max(1, row_h - BAR_VERTICAL_INSET * 2), ) + if state.slot_rotation_sets.get(slot) != "cast_roles": + continue for cue in bar_cues_for_row(state, slot): if cue.role is None or cue.level <= LEVEL_EPS: continue @@ -1498,8 +1511,17 @@ def _patch_live_overlay( upload.fill((0, 0, 0, 0), (bx, by, bw, bh)) selected_cue = _selected_cue_for_focus(state) + focused_slot = ( + state.layer_z_order[state.focus_row] + if 0 <= state.focus_row < len(state.layer_z_order) + else None + ) + show_cast = ( + focused_slot is not None + and state.slot_rotation_sets.get(focused_slot) == "cast_roles" + ) cue_readout = ( - render_selected_cue_readout(font, selected_cue) + render_selected_cue_readout(font, selected_cue, show_cast=show_cast) if selected_cue is not None else None ) diff --git a/cleave/viz/tuning_panel_draw.py b/cleave/viz/tuning_panel_draw.py index fae81e7..2f7073d 100644 --- a/cleave/viz/tuning_panel_draw.py +++ b/cleave/viz/tuning_panel_draw.py @@ -85,6 +85,7 @@ BORDER_COLOR, BORDER_WIDTH, DISABLED, + ERROR_NOTIFICATION, FADE_DURATION_SEC, FOCUS_ROW_BG_ALPHA, HIGHLIGHT, @@ -598,9 +599,12 @@ def _fit( if kind == RowKind.RENDER_SECTION_GAP: return "" if kind == RowKind.PANEL_NOTIFICATION: - return _fit( - "text", fit_text_to_width, state.notification_message or "", budget - ) + desc = state.layout.descriptor(index) + if desc.marker_index == 0: + text = state.persistent_notification_message or "" + else: + text = state.notification_message or "" + return _fit("text", fit_text_to_width, text, budget) field = ROW_FIELDS.get(kind) if field is not None and field.present_style == RowPresentStyle.FULL_LINE: if kind in { @@ -664,6 +668,9 @@ def _row_value_color(state: TuningViewState, index: int) -> tuple[int, int, int] """Return the VALUE-role color for a row (before label/value split rendering).""" kind = state.layout.kind(index) if kind == RowKind.PANEL_NOTIFICATION: + desc = state.layout.descriptor(index) + if desc.marker_index == 0: + return ERROR_NOTIFICATION return HIGHLIGHT desc = state.layout.descriptor(index) diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index 283bc01..a810823 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -14,6 +14,8 @@ RenderOverlaySlideDirection, ) from cleave.config_schema import ( + DEFAULT_CAST_ROLES_DEFAULT_ROLE, + DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR, DEFAULT_CHROMA_BOOST_APPLY_MODE, DEFAULT_CHROMA_BOOST_VARIANT, DEFAULT_HIGHLIGHT_ROLLOFF_APPLY_MODE, @@ -37,13 +39,16 @@ DEFAULT_VISUAL_LIMITER_ENABLED, DEFAULT_VISUAL_LIMITER_THRESHOLD, DEFAULT_VISUAL_LIMITER_RELEASE, + CastRolesTimelineBehaviour, default_render_overlay_runtime_values, default_render_post_fx_runtime_values, ) +from cleave.cue_roles import CueRole from cleave.extract import StemSource from cleave.preset_curation import PresetCurationIndex from cleave.preset_playlist import PresetPlaylist, preset_filename_display from cleave.timeline_presets.conductor import DEFAULT_TIMELINE_PRESET_CONDUCTOR +from cleave.viz.panel_notification import PanelNotificationActive from cleave.timeline_presets.crescendo import CrescendoTarget from cleave.timeline_presets.density import ( DEFAULT_TIMELINE_PRESET_DENSITY, @@ -80,6 +85,10 @@ class TrackBlock: preset_empty: bool = False preset_switching: str = "none" preset_switching_rotation_set: str = "directory" + cast_roles_timeline_behaviour: CastRolesTimelineBehaviour = ( + DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR + ) + cast_roles_default_role: CueRole = DEFAULT_CAST_ROLES_DEFAULT_ROLE preset_switching_shuffle: bool = DEFAULT_PRESET_SWITCHING_SHUFFLE preset_switching_shuffle_salt: int = DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT preset_duration: float = DEFAULT_PRESET_DURATION @@ -222,6 +231,7 @@ class TuningViewState: position_sec: float focus_cursor: FocusCursor move_mode_slot: str | None + persistent_notification_message: str | None = None notification_message: str | None = None notification_remaining_sec: float = 0.0 allow_overwrite: bool = True @@ -316,6 +326,7 @@ def view_state_structure_signature( config_save: ConfigSaveController, *, notification_active: bool, + persistent_notification_active: bool = False, ) -> str: layers: dict[str, object] = {} for slot in session.layer_z_order: @@ -327,6 +338,8 @@ def view_state_structure_signature( "user_presets_expanded": layer.user_presets_expanded, "preset_switching": layer.preset_switching, "preset_switching_rotation_set": layer.preset_switching_rotation_set, + "cast_roles_timeline_behaviour": layer.cast_roles_timeline_behaviour, + "cast_roles_default_role": layer.cast_roles_default_role, "preset_switching_shuffle": layer.preset_switching_shuffle, "preset_duration": layer.preset_duration, "soft_cut_duration": layer.soft_cut_duration, @@ -355,6 +368,7 @@ def view_state_structure_signature( "editor_mode": session.settings.editor_mode, }, "notification_active": notification_active, + "persistent_notification_active": persistent_notification_active, "layers": layers, "render_overlay": { "enabled": ro.enabled, @@ -415,7 +429,7 @@ def __init__( get_focus_cursor: Callable[[], FocusCursor], get_move_mode_slot: Callable[[], str | None], config_save: ConfigSaveController, - get_notification: Callable[[], tuple[str | None, float]], + get_notification: Callable[[], PanelNotificationActive], ) -> None: self.session = session self.playback = playback @@ -456,6 +470,7 @@ def _build_structure( *, signature: str, notification_active: bool, + persistent_notification_active: bool, user_names: set[str], ) -> _ViewStateStructure: from cleave.viz.focus_nav import MainFocus @@ -485,6 +500,8 @@ def _build_structure( preset_empty=not layer.playlist.paths, preset_switching=layer.preset_switching, preset_switching_rotation_set=layer.preset_switching_rotation_set, + cast_roles_timeline_behaviour=layer.cast_roles_timeline_behaviour, + cast_roles_default_role=layer.cast_roles_default_role, preset_switching_shuffle=layer.preset_switching_shuffle, preset_switching_shuffle_salt=layer.preset_switching_shuffle_salt, preset_duration=layer.preset_duration, @@ -577,6 +594,9 @@ def _build_structure( position_sec=0.0, focus_cursor=MainFocus(RowDescriptor(RowKind.TRANSPORT)), move_mode_slot=None, + persistent_notification_message=( + "…" if persistent_notification_active else None + ), notification_message="…" if notification_active else None, notification_remaining_sec=1.0 if notification_active else 0.0, render_overlay=render_overlay, @@ -639,20 +659,26 @@ def build( if position_sec is None: position_sec = current_sec(self.playback, self.duration_sec) - notification_message, notification_remaining_sec = self._get_notification() + notification = self._get_notification() + notification_message = notification.message + notification_remaining_sec = notification.remaining_sec + persistent_notification_message = notification.persistent_message notification_active = bool( notification_message and notification_remaining_sec > 0 ) + persistent_notification_active = bool(persistent_notification_message) user_names = self._user_preset_basenames() signature = view_state_structure_signature( self.session, self._config_save, notification_active=notification_active, + persistent_notification_active=persistent_notification_active, ) if self._structure is None or self._structure.signature != signature: self._structure = self._build_structure( signature=signature, notification_active=notification_active, + persistent_notification_active=persistent_notification_active, user_names=user_names, ) structure = self._structure @@ -671,6 +697,7 @@ def build( position_sec=position_sec, focus_cursor=self._get_focus_cursor(), move_mode_slot=self._get_move_mode_slot(), + persistent_notification_message=persistent_notification_message, notification_message=notification_message, notification_remaining_sec=notification_remaining_sec, allow_overwrite=self._config_save.allow_overwrite(), diff --git a/cleave/viz/wiring.py b/cleave/viz/wiring.py index 1a74f65..aae313a 100644 --- a/cleave/viz/wiring.py +++ b/cleave/viz/wiring.py @@ -225,8 +225,11 @@ def on_preset_change(slot: str, playlist: PresetPlaylist) -> None: hard_cut_sensitivity=runtime.hard_cut_sensitivity, shuffle=runtime.preset_switching_shuffle, shuffle_salt=runtime.preset_switching_shuffle_salt, + cast_roles_default_role=runtime.cast_roles_default_role, + cast_roles_timeline_behaviour=runtime.cast_roles_timeline_behaviour, preset_root=preset_root, on_empty=_empty_rotation_notify(slot), + session=session, ) return if playlist.current is None: @@ -246,6 +249,8 @@ def on_preset_change(slot: str, playlist: PresetPlaylist) -> None: user_presets=runtime.user_presets, shuffle=runtime.preset_switching_shuffle, shuffle_salt=runtime.preset_switching_shuffle_salt, + cast_roles_default_role=runtime.cast_roles_default_role, + preset_root=preset_root, ) return layer.pm.lock_preset(False) @@ -268,8 +273,11 @@ def on_preset_switching_change(slot: str) -> None: hard_cut_sensitivity=runtime.hard_cut_sensitivity, shuffle=runtime.preset_switching_shuffle, shuffle_salt=runtime.preset_switching_shuffle_salt, + cast_roles_default_role=runtime.cast_roles_default_role, + cast_roles_timeline_behaviour=runtime.cast_roles_timeline_behaviour, preset_root=preset_root, on_empty=_empty_rotation_notify(slot), + session=session, ) def lock_preset_for_modal(slot: str) -> None: diff --git a/tests/cleave/test_preset_switching_config.py b/tests/cleave/test_preset_switching_config.py index b21c366..9ddff83 100644 --- a/tests/cleave/test_preset_switching_config.py +++ b/tests/cleave/test_preset_switching_config.py @@ -8,6 +8,8 @@ from cleave.config import CleaveConfig, LayerConfig, PathsConfig, EditorConfig, load_config from cleave.config_schema import ( + DEFAULT_CAST_ROLES_DEFAULT_ROLE, + DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR, DEFAULT_EASTER_EGG, DEFAULT_HARD_CUT_DURATION, DEFAULT_HARD_CUT_ENABLED, @@ -48,6 +50,8 @@ def test_parse_layers_preset_switching_defaults_omitted() -> None: layer = layers["layer_1"] assert layer.preset_switching == DEFAULT_PRESET_SWITCHING assert layer.preset_switching_rotation_set == DEFAULT_PRESET_SWITCHING_ROTATION_SET + assert layer.cast_roles_timeline_behaviour == DEFAULT_CAST_ROLES_TIMELINE_BEHAVIOUR + assert layer.cast_roles_default_role == DEFAULT_CAST_ROLES_DEFAULT_ROLE assert layer.preset_switching_shuffle == DEFAULT_PRESET_SWITCHING_SHUFFLE assert layer.preset_switching_shuffle_salt == DEFAULT_PRESET_SWITCHING_SHUFFLE_SALT assert layer.preset_duration == DEFAULT_PRESET_DURATION @@ -95,6 +99,30 @@ def test_parse_layers_preset_switching_rotation_set_user_defined() -> None: assert layers["layer_1"].preset_switching_rotation_set == "user_defined" +def test_parse_layers_preset_switching_rotation_set_cast_roles() -> None: + preset_root = Path("/tmp/presets") + data = {"layers": _layer_yaml()} + data["layers"]["layer_1"]["preset_switching"] = "timeline" + data["layers"]["layer_1"]["preset_switching_rotation_set"] = "cast_roles" + data["layers"]["layer_1"]["cast_roles_timeline_behaviour"] = "hold_current" + data["layers"]["layer_1"]["cast_roles_default_role"] = "pulse" + layers = parse_layers_section(data, ParseCtx(preset_root=preset_root)) + layer = layers["layer_1"] + assert layer.preset_switching_rotation_set == "cast_roles" + assert layer.cast_roles_timeline_behaviour == "hold_current" + assert layer.cast_roles_default_role == "pulse" + + +def test_parse_layers_rejects_invalid_cast_roles_timeline_behaviour() -> None: + import pytest + + preset_root = Path("/tmp/presets") + data = {"layers": _layer_yaml()} + data["layers"]["layer_1"]["cast_roles_timeline_behaviour"] = "always" + with pytest.raises(ValueError, match="cast_roles_timeline_behaviour"): + parse_layers_section(data, ParseCtx(preset_root=preset_root)) + + def test_parse_layers_preset_switching_timing() -> None: preset_root = Path("/tmp/presets") data = {"layers": _layer_yaml()} @@ -152,6 +180,8 @@ def test_persist_layers_omits_default_preset_switching() -> None: out = persist_layers(PersistCtx(cfg=cfg, session=session)) assert "preset_switching" not in out["layer_1"] assert "preset_switching_rotation_set" not in out["layer_1"] + assert "cast_roles_timeline_behaviour" not in out["layer_1"] + assert "cast_roles_default_role" not in out["layer_1"] assert "preset_switching_shuffle" not in out["layer_1"] assert "preset_switching_shuffle_salt" not in out["layer_1"] assert "preset_duration" not in out["layer_1"] @@ -252,6 +282,18 @@ def test_persist_layers_writes_user_defined_rotation_set() -> None: assert out["layer_1"]["preset_switching_rotation_set"] == "user_defined" +def test_persist_layers_writes_cast_roles_fields() -> None: + cfg, session = _cfg_and_session(preset_switching="timeline") + runtime = session.layers["layer_1"] + runtime.preset_switching_rotation_set = "cast_roles" + runtime.cast_roles_timeline_behaviour = "hold_current" + runtime.cast_roles_default_role = "accent" + out = persist_layers(PersistCtx(cfg=cfg, session=session)) + assert out["layer_1"]["preset_switching_rotation_set"] == "cast_roles" + assert out["layer_1"]["cast_roles_timeline_behaviour"] == "hold_current" + assert out["layer_1"]["cast_roles_default_role"] == "accent" + + def test_persist_layers_writes_projectm_mode(tmp_path: Path) -> None: preset_root = Path("/tmp/presets") config_path = tmp_path / "cleave.config.yaml" diff --git a/tests/cleave/viz/test_app.py b/tests/cleave/viz/test_app.py index 9ac94b6..0a0c44e 100644 --- a/tests/cleave/viz/test_app.py +++ b/tests/cleave/viz/test_app.py @@ -177,6 +177,9 @@ def _heavy_init_side_effect( controls.build_view_state.return_value = MagicMock() controls.tick = MagicMock() controls.consume_hide_overlay.return_value = False + controls.key_repeat_armed = False + timeline_controls = MagicMock() + timeline_controls.key_repeat_armed = False playback = MagicMock() playback.paused = False mix_player = MagicMock() @@ -188,7 +191,7 @@ def _heavy_init_side_effect( compositor=compositor, post_process=post_process, controls=controls, - timeline_controls=MagicMock(), + timeline_controls=timeline_controls, modal_host=modal_host, mix_player=mix_player, playback=playback, diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 9b7a12a..f51117d 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -40,6 +40,7 @@ ) from cleave.viz.panel_notification import NOTIFICATION_DURATION_SEC from cleave.viz.modal import ModalKind +from cleave.viz.theme import ERROR_NOTIFICATION, HIGHLIGHT from cleave.viz.session import ( LayerRuntime, TimelineRuntime, @@ -4593,6 +4594,101 @@ def test_preset_switching_rotation_set_cycles_directory_and_user_defined() -> No assert controls.session.layers["layer_1"].user_presets_expanded is True +def test_preset_switching_rotation_set_skips_cast_roles_in_projectm_mode() -> None: + controls = _make_controls(("layer_1",)) + layer = controls.session.layers["layer_1"] + layer.preset_switching = "projectm" + layer.expanded = True + layer.preset_switching_rotation_set = "directory" + view = controls.build_view_state(paused=False) + row = _row(view, "layer_1", RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET) + controls.focus_descriptor = view.layout.descriptor(row) + + controls.handle_keydown(_keydown(pygame.K_RIGHT)) + assert layer.preset_switching_rotation_set == "user_defined" + controls.handle_keydown(_keydown(pygame.K_RIGHT)) + assert layer.preset_switching_rotation_set == "directory" + assert layer.preset_switching_rotation_set != "cast_roles" + + +def test_preset_switching_rotation_set_includes_cast_roles_in_timeline_mode() -> None: + controls = _make_controls(("layer_1",)) + layer = controls.session.layers["layer_1"] + layer.preset_switching = "timeline" + layer.expanded = True + layer.preset_switching_rotation_set = "directory" + view = controls.build_view_state(paused=False) + row = _row(view, "layer_1", RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET) + controls.focus_descriptor = view.layout.descriptor(row) + + controls.handle_keydown(_keydown(pygame.K_RIGHT)) + assert layer.preset_switching_rotation_set == "user_defined" + controls.handle_keydown(_keydown(pygame.K_RIGHT)) + assert layer.preset_switching_rotation_set == "cast_roles" + + +def test_cast_roles_empty_pool_sets_persistent_notification() -> None: + controls = _make_controls(("layer_1",)) + layer = controls.session.layers["layer_1"] + layer.preset_switching = "timeline" + layer.preset_switching_rotation_set = "cast_roles" + layer.cast_roles_default_role = "bed" + + with patch( + "cleave.viz.controls.role_pool_paths", + return_value=(), + ): + view = controls.build_view_state(paused=False) + + assert view.persistent_notification_message == "No presets in bed roles folder" + notification_rows = [ + row + for row in view.layout.rows + if row.kind == RowKind.PANEL_NOTIFICATION + ] + assert len(notification_rows) == 1 + assert notification_rows[0].marker_index == 0 + idx = view.layout.find_descriptor(notification_rows[0]) + assert _row_value_color(view, idx) == ERROR_NOTIFICATION + + layer.preset_switching_rotation_set = "directory" + with patch( + "cleave.viz.controls.role_pool_paths", + return_value=(), + ): + cleared = controls.build_view_state(paused=False) + assert cleared.persistent_notification_message is None + + +def test_persistent_and_timed_notifications_stack() -> None: + controls = _make_controls(("layer_1",)) + controls.session.layers["layer_1"].preset_switching = "timeline" + controls.session.layers["layer_1"].preset_switching_rotation_set = "cast_roles" + + with patch.object(time, "monotonic", return_value=1000.0): + with patch( + "cleave.viz.controls.role_pool_paths", + return_value=(), + ): + controls.show_notification("Saved") + view = controls.build_view_state(paused=False) + + assert view.persistent_notification_message == "No presets in bed roles folder" + assert view.notification_message == "Saved" + notification_rows = [ + row + for row in view.layout.rows + if row.kind == RowKind.PANEL_NOTIFICATION + ] + assert [row.marker_index for row in notification_rows] == [0, 1] + persistent_idx = view.layout.find_descriptor(notification_rows[0]) + timed_idx = view.layout.find_descriptor(notification_rows[1]) + assert _row_value_color(view, persistent_idx) == ERROR_NOTIFICATION + assert _row_value_color(view, timed_idx) == HIGHLIGHT + assert _row_text(view, persistent_idx) == "No presets in bed roles folder" + assert _row_text(view, timed_idx) == "Saved" + + def test_preset_switching_rotation_set_user_defined_auto_expands_user_presets() -> None: controls = _make_controls(("layer_1",)) layer = controls.session.layers["layer_1"] diff --git a/tests/cleave/viz/test_help_overlay.py b/tests/cleave/viz/test_help_overlay.py index a6fa27a..c77177b 100644 --- a/tests/cleave/viz/test_help_overlay.py +++ b/tests/cleave/viz/test_help_overlay.py @@ -5,6 +5,8 @@ from cleave.blend_modes import BLEND_MODE_HELP_ENTRIES, BLEND_MODES from cleave.cue_roles import CUE_ROLE_MARKER_HELP_ENTRIES from cleave.config_schema import ( + CAST_ROLES_DEFAULT_ROLE_HELP_ENTRIES, + CAST_ROLES_TIMELINE_BEHAVIOUR_HELP_ENTRIES, HIGHLIGHT_ROLLOFF_APPLY_MODE_HELP_ENTRIES, HIGHLIGHT_ROLLOFF_APPLY_MODES, HIGHLIGHT_ROLLOFF_CURVE_HELP_ENTRIES, @@ -248,6 +250,30 @@ def test_rotation_set_help_lists_rotation_sets() -> None: assert description.title == "Rotation set" assert description.lines == () assert description.entries == PRESET_SWITCHING_ROTATION_SET_HELP_ENTRIES + modes = [mode for mode, _ in description.entries] + assert modes == ["directory", "user_defined", "cast_roles"] + + +def test_cast_roles_timeline_behaviour_help_lists_options() -> None: + description = _description_section( + sections_for(RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR) + ) + assert description is not None + assert description.title == "Timeline behaviour" + assert description.entries == CAST_ROLES_TIMELINE_BEHAVIOUR_HELP_ENTRIES + assert [mode for mode, _ in description.entries] == [ + "hold_current", + "on_transition", + ] + + +def test_cast_roles_default_role_help_lists_roles() -> None: + description = _description_section( + sections_for(RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE) + ) + assert description is not None + assert description.title == "Default role" + assert description.entries == CAST_ROLES_DEFAULT_ROLE_HELP_ENTRIES def test_timeline_presets_help_lists_characters() -> None: @@ -580,6 +606,9 @@ def test_timeline_strip_help_paused() -> None: assert "Ctrl + Enter" not in entries assert entries["Space"] == "play" assert entries["Ctrl + Space / R"] == "start record" + assert entries["C / Shift+C"] == ( + "cycle cue cast (requires cast roles rotation set)" + ) keys = [key for key, _ in section.entries] assert keys.index("Ctrl + Space / R") + 1 == keys.index("Space") assert "Left/Right" in entries diff --git a/tests/cleave/viz/test_overlay.py b/tests/cleave/viz/test_overlay.py index 84d860f..c6ae248 100644 --- a/tests/cleave/viz/test_overlay.py +++ b/tests/cleave/viz/test_overlay.py @@ -963,6 +963,30 @@ def test_panel_notification_pinned_under_transport() -> None: assert _row_text(active, notification_idx) == NOTIFICATION_TIMELINE_ENABLED_TEXT +def test_persistent_and_timed_panel_notifications_stack() -> None: + from cleave.viz.theme import ERROR_NOTIFICATION + + stacked = _minimal_view_state( + persistent_notification_message="No presets in bed roles folder", + notification_message="Saved", + notification_remaining_sec=5.0, + ) + notification_rows = [ + row + for row in stacked.layout.rows + if row.kind == RowKind.PANEL_NOTIFICATION + ] + assert [row.marker_index for row in notification_rows] == [0, 1] + persistent_idx = stacked.layout.find_descriptor(notification_rows[0]) + timed_idx = stacked.layout.find_descriptor(notification_rows[1]) + transport_idx = stacked.layout.find_by_kind(RowKind.TRANSPORT) + assert transport_idx < persistent_idx < timed_idx + assert _row_value_color(stacked, persistent_idx) == ERROR_NOTIFICATION + assert _row_value_color(stacked, timed_idx) == HIGHLIGHT + assert _row_text(stacked, persistent_idx) == "No presets in bed roles folder" + assert _row_text(stacked, timed_idx) == "Saved" + + def test_draw_panel_notification_without_error() -> None: pygame.init() overlay = TuningOverlay() diff --git a/tests/cleave/viz/test_panel_notification.py b/tests/cleave/viz/test_panel_notification.py new file mode 100644 index 0000000..d7ff0b0 --- /dev/null +++ b/tests/cleave/viz/test_panel_notification.py @@ -0,0 +1,46 @@ +"""Unit tests for PanelNotificationHost.""" + +from __future__ import annotations + +import time +from unittest.mock import patch + +from cleave.viz.panel_notification import ( + NOTIFICATION_DURATION_SEC, + PanelNotificationHost, +) + + +def test_timed_notification_active_and_expiry() -> None: + host = PanelNotificationHost() + with patch.object(time, "monotonic", return_value=10.0): + host.show("hello") + active = host.active() + assert active.persistent_message is None + assert active.message == "hello" + assert active.remaining_sec == NOTIFICATION_DURATION_SEC + + with patch.object( + time, "monotonic", return_value=10.0 + NOTIFICATION_DURATION_SEC + 0.1 + ): + host.clear_expired() + active = host.active() + assert active.message is None + assert active.remaining_sec == 0.0 + + +def test_persistent_and_timed_stack_in_active() -> None: + host = PanelNotificationHost() + host.set_persistent("No presets in bed roles folder") + with patch.object(time, "monotonic", return_value=50.0): + host.show("Saved") + active = host.active() + assert active.persistent_message == "No presets in bed roles folder" + assert active.message == "Saved" + assert active.remaining_sec > 0 + + host.set_persistent(None) + with patch.object(time, "monotonic", return_value=50.0): + active = host.active() + assert active.persistent_message is None + assert active.message == "Saved" diff --git a/tests/cleave/viz/test_preset_switching.py b/tests/cleave/viz/test_preset_switching.py index 44593c6..44cdc4b 100644 --- a/tests/cleave/viz/test_preset_switching.py +++ b/tests/cleave/viz/test_preset_switching.py @@ -707,7 +707,11 @@ def test_tick_frame_core_skips_advance_when_paused() -> None: ) -def _role_timeline_session(layer: StemLayer) -> TuningSession: +def _role_timeline_session( + layer: StemLayer, + *, + rotation_set: str = "cast_roles", +) -> TuningSession: session = TuningSession( layer_z_order=["layer_1"], layers={ @@ -716,6 +720,7 @@ def _role_timeline_session(layer: StemLayer) -> TuningSession: browse_floor=layer.playlist.current_dir, stem="drums", preset_switching="timeline", + preset_switching_rotation_set=rotation_set, # type: ignore[arg-type] ), }, ) @@ -733,21 +738,32 @@ def _role_timeline_session(layer: StemLayer) -> TuningSession: return session +_BED_POOL = ( + Path("/tmp/presets/roles/bed/b0.milk"), + Path("/tmp/presets/roles/bed/b1.milk"), +) + + +def _cast_role_pools(preset_root: Path, role: str): + del preset_root + if role == "pulse": + return _PULSE_POOL + if role == "bed": + return _BED_POOL + return () + + @patch("cleave.viz.preset_switching.role_pool_paths") @patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) def test_advance_timeline_role_cue_loads_from_role_pool( _mock_milk: MagicMock, mock_role_pool: MagicMock, ) -> None: - def _pools(preset_root: Path, role: str): - del preset_root - return _PULSE_POOL if role == "pulse" else () - - mock_role_pool.side_effect = _pools + mock_role_pool.side_effect = _cast_role_pools layer = _stem_layer(paths=_MILK, index=0) session = _role_timeline_session(layer) apply_preset_switching( - layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT + layer, mode="timeline", rotation_set="cast_roles", preset_root=_PRESET_ROOT ) assert "pulse" in layer.role_rotations layer.pm.load_preset.reset_mock() @@ -763,15 +779,11 @@ def test_advance_timeline_role_index_is_seek_stable( _mock_milk: MagicMock, mock_role_pool: MagicMock, ) -> None: - def _pools(preset_root: Path, role: str): - del preset_root - return _PULSE_POOL if role == "pulse" else () - - mock_role_pool.side_effect = _pools + mock_role_pool.side_effect = _cast_role_pools layer = _stem_layer(paths=_MILK, index=0) session = _role_timeline_session(layer) apply_preset_switching( - layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT + layer, mode="timeline", rotation_set="cast_roles", preset_root=_PRESET_ROOT ) layer.pm.load_preset.reset_mock() @@ -780,20 +792,22 @@ def _pools(preset_root: Path, role: str): assert layer.pm.load_preset.call_args_list[-1] == call(_PULSE_POOL[1], smooth=False) layer.timeline_switch_count = 0 + layer.auto_preset_path = None layer.pm.load_preset.reset_mock() advance_timeline_preset_switching(session, {"layer_1": layer}, 15.0) layer.pm.load_preset.assert_called_once_with(_PULSE_POOL[1], smooth=False) assert layer.timeline_switch_count == 2 -@patch("cleave.viz.preset_switching.role_pool_paths", return_value=()) +@patch("cleave.viz.preset_switching.role_pool_paths") @patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_advance_timeline_empty_role_pool_falls_back_to_main( +def test_advance_timeline_directory_ignores_cue_roles( _mock_milk: MagicMock, - _mock_role_pool: MagicMock, + mock_role_pool: MagicMock, ) -> None: + mock_role_pool.side_effect = _cast_role_pools layer = _stem_layer(paths=_MILK, index=0) - session = _role_timeline_session(layer) + session = _role_timeline_session(layer, rotation_set="directory") apply_preset_switching( layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT ) @@ -801,32 +815,102 @@ def test_advance_timeline_empty_role_pool_falls_back_to_main( layer.pm.load_preset.reset_mock() advance_timeline_preset_switching(session, {"layer_1": layer}, 5.0) - layer.pm.load_preset.assert_called_once_with(_MILK[1], smooth=False) + expected = layer.preset_rotation.path_for(1) + layer.pm.load_preset.assert_called_once_with(expected, smooth=False) @patch("cleave.viz.preset_switching.role_pool_paths") @patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) -def test_advance_timeline_absent_role_uses_main_rotation( +def test_advance_timeline_cast_roles_null_role_uses_last_role( _mock_milk: MagicMock, mock_role_pool: MagicMock, ) -> None: - def _pools(preset_root: Path, role: str): - del preset_root - return _PULSE_POOL if role == "pulse" else () + mock_role_pool.side_effect = _cast_role_pools + layer = _stem_layer(paths=_MILK, index=0) + session = _role_timeline_session(layer) + apply_preset_switching( + layer, mode="timeline", rotation_set="cast_roles", preset_root=_PRESET_ROOT + ) + layer.pm.load_preset.reset_mock() - mock_role_pool.side_effect = _pools + # First two on-transitions are pulse; third (t=25) has no role -> last pulse. + advance_timeline_preset_switching(session, {"layer_1": layer}, 25.0) + assert layer.pm.load_preset.call_args_list[-1] == call(_PULSE_POOL[0], smooth=False) + assert layer.timeline_switch_count == 3 + assert layer.last_cast_role == "pulse" + + +@patch("cleave.viz.preset_switching.role_pool_paths") +@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) +def test_advance_timeline_cast_roles_hold_current_skips_null_role( + _mock_milk: MagicMock, + mock_role_pool: MagicMock, +) -> None: + mock_role_pool.side_effect = _cast_role_pools layer = _stem_layer(paths=_MILK, index=0) session = _role_timeline_session(layer) + session.layers["layer_1"].cast_roles_timeline_behaviour = "hold_current" apply_preset_switching( - layer, mode="timeline", rotation_set="directory", preset_root=_PRESET_ROOT + layer, mode="timeline", rotation_set="cast_roles", preset_root=_PRESET_ROOT ) + advance_timeline_preset_switching(session, {"layer_1": layer}, 15.0) + assert layer.timeline_switch_count == 2 layer.pm.load_preset.reset_mock() - # Third on-transition (t=25) has no role -> main rotation path_for(3). advance_timeline_preset_switching(session, {"layer_1": layer}, 25.0) - expected = layer.preset_rotation.path_for(3) - assert layer.pm.load_preset.call_args_list[-1] == call(expected, smooth=False) + layer.pm.load_preset.assert_not_called() assert layer.timeline_switch_count == 3 + assert layer.last_cast_role == "pulse" + + +@patch("cleave.viz.preset_switching.role_pool_paths") +@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) +def test_apply_cast_roles_uses_default_role_pool_as_main_rotation( + _mock_milk: MagicMock, + mock_role_pool: MagicMock, +) -> None: + mock_role_pool.side_effect = _cast_role_pools + layer = _stem_layer(paths=_MILK, index=0) + apply_preset_switching( + layer, + mode="timeline", + rotation_set="cast_roles", + cast_roles_default_role="pulse", + preset_root=_PRESET_ROOT, + ) + assert layer.preset_rotation is not None + assert layer.preset_rotation.paths == _PULSE_POOL + layer.pm.load_preset.assert_called_with(_PULSE_POOL[0], smooth=False) + + +@patch("cleave.viz.preset_switching.role_pool_paths") +@patch("cleave.viz.preset_switching.milk_files_in_dir", return_value=_MILK) +def test_apply_cast_roles_empty_default_role_pool_clears_rotation( + _mock_milk: MagicMock, + mock_role_pool: MagicMock, +) -> None: + empty: list[str] = [] + + def empty_default(preset_root: Path, role: str): + del preset_root + if role == "bed": + return () + if role == "pulse": + return _PULSE_POOL + return () + + mock_role_pool.side_effect = empty_default + layer = _stem_layer(paths=_MILK, index=0) + apply_preset_switching( + layer, + mode="timeline", + rotation_set="cast_roles", + preset_root=_PRESET_ROOT, + on_empty=lambda: empty.append("empty"), + ) + assert layer.preset_rotation is None + assert layer.role_rotations == {} + assert empty == ["empty"] def test_apply_preset_switching_rebuilds_role_rotations_from_disk() -> None: @@ -837,26 +921,20 @@ def test_apply_preset_switching_rebuilds_role_rotations_from_disk() -> None: pack.mkdir() main = pack / "main.milk" main.write_text("main", encoding="utf-8") + bed = root / "roles" / "bed" / "cast.milk" + bed.parent.mkdir(parents=True) + bed.write_text("cast", encoding="utf-8") layer = _stem_layer(paths=(main,), index=0) layer.playlist = PresetPlaylist(current_dir=pack, paths=(main,), index=0) apply_preset_switching( - layer, mode="timeline", rotation_set="directory", preset_root=root - ) - assert layer.role_rotations == {} - - cast = root / "roles" / "bed" / "cast.milk" - cast.parent.mkdir(parents=True) - cast.write_text("cast", encoding="utf-8") - - apply_preset_switching( - layer, mode="timeline", rotation_set="directory", preset_root=root + layer, mode="timeline", rotation_set="cast_roles", preset_root=root ) assert "bed" in layer.role_rotations - assert layer.role_rotations["bed"].paths == (cast,) + assert layer.role_rotations["bed"].paths == (bed,) - cast.unlink() + bed.unlink() apply_preset_switching( - layer, mode="timeline", rotation_set="directory", preset_root=root + layer, mode="timeline", rotation_set="cast_roles", preset_root=root ) assert layer.role_rotations == {} diff --git a/tests/cleave/viz/test_row_fields.py b/tests/cleave/viz/test_row_fields.py index b6ea11e..5419f19 100644 --- a/tests/cleave/viz/test_row_fields.py +++ b/tests/cleave/viz/test_row_fields.py @@ -319,7 +319,7 @@ def test_apply_field_horizontal_track_header_solo_and_expand() -> None: def test_row_fields_count() -> None: - assert len(ROW_FIELDS) == 95 + assert len(ROW_FIELDS) == 97 def test_row_kinds_requiring_fields_registry_complete() -> None: diff --git a/tests/cleave/viz/test_row_sections.py b/tests/cleave/viz/test_row_sections.py index c5459ae..d1d4bdc 100644 --- a/tests/cleave/viz/test_row_sections.py +++ b/tests/cleave/viz/test_row_sections.py @@ -100,6 +100,16 @@ def test_track_layout_conditional_rows_when_predicates_pass() -> None: assert RowKind.TRACK_HARD_CUT_DURATION in user_defined_kinds assert RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET in user_defined_kinds assert RowKind.TRACK_USER_PRESETS in user_defined_kinds + assert RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR not in user_defined_kinds + + cast_roles_kinds = _track_row_kinds( + preset_switching="timeline", + preset_switching_rotation_set="cast_roles", + effects_expanded=False, + ) + assert RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR in cast_roles_kinds + assert RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE in cast_roles_kinds + assert RowKind.TRACK_USER_PRESETS not in cast_roles_kinds def test_track_layout_omits_conditional_rows_when_predicates_fail() -> None: diff --git a/tests/cleave/viz/test_row_semantics.py b/tests/cleave/viz/test_row_semantics.py index 5d11d11..30f689d 100644 --- a/tests/cleave/viz/test_row_semantics.py +++ b/tests/cleave/viz/test_row_semantics.py @@ -34,6 +34,8 @@ def _track_lock_state(locked: bool) -> SimpleNamespace: RowKind.TRACK_PRESET_DIR, RowKind.TRACK_PRESET, RowKind.TRACK_PRESET_SWITCHING, + RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR, + RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE, RowKind.TRACK_PRESET_DURATION, RowKind.TRACK_SOFT_CUT_DURATION, RowKind.TRACK_EASTER_EGG, @@ -173,6 +175,8 @@ def test_track_sub_row_kinds() -> None: RowKind.TRACK_USER_PRESET_ITEM, RowKind.TRACK_USER_PRESET_ADD, RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET, + RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR, + RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE, RowKind.TRACK_PRESET_SWITCHING_SHUFFLE, RowKind.TRACK_PRESET_SWITCHING_SEED, RowKind.TRACK_PRESET_DURATION, @@ -220,6 +224,8 @@ def test_track_value_rows_blocked_by_section_lock() -> None: RowKind.TRACK_PRESET, RowKind.TRACK_PRESET_SWITCHING, RowKind.TRACK_PRESET_SWITCHING_ROTATION_SET, + RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR, + RowKind.TRACK_CAST_ROLES_DEFAULT_ROLE, RowKind.TRACK_PRESET_SWITCHING_SHUFFLE, RowKind.TRACK_PRESET_SWITCHING_SEED, RowKind.TRACK_PRESET_DURATION, diff --git a/tests/cleave/viz/test_timeline_controls.py b/tests/cleave/viz/test_timeline_controls.py index 7f5e6a2..ab57eb7 100644 --- a/tests/cleave/viz/test_timeline_controls.py +++ b/tests/cleave/viz/test_timeline_controls.py @@ -1173,6 +1173,7 @@ def test_c_casts_opening_baseline_materializing_cue_at_zero() -> None: lanes=lanes, position_sec=0.0, ) + session.layers["layer_1"].preset_switching_rotation_set = "cast_roles" controls.handle_keydown(keydown(pygame.K_PERIOD)) assert session.timeline.selected_cue_t["layer_1"] == 0.0 controls.handle_keydown(keydown(pygame.K_c)) @@ -1324,6 +1325,7 @@ def test_c_cycles_selected_cue_role_including_none() -> None: lanes=lanes, position_sec=5.0, ) + session.layers["layer_1"].preset_switching_rotation_set = "cast_roles" controls.handle_keydown(keydown(pygame.K_PERIOD)) assert session.timeline.lanes["layer_1"].cues[0].role is None @@ -1335,6 +1337,29 @@ def test_c_cycles_selected_cue_role_including_none() -> None: assert session.timeline.lanes["layer_1"].cues[0].role is None +def test_c_noop_when_rotation_set_is_not_cast_roles() -> None: + lanes = { + "layer_1": TimelineLane( + baseline=0.0, + cues=canonicalize( + 0.0, + [SlotCue(t=5.0, level=1.0)], + ), + ), + } + controls, session, _, _, _, notifications = _make_timeline_controls( + lanes=lanes, + position_sec=5.0, + ) + assert session.layers["layer_1"].preset_switching_rotation_set == "directory" + controls.handle_keydown(keydown(pygame.K_PERIOD)) + controls.handle_keydown(keydown(pygame.K_c)) + assert session.timeline.lanes["layer_1"].cues[0].role is None + assert notifications == [ + "Set rotation set to cast roles to assign cast" + ] + + def test_b_and_c_noop_on_off_cue() -> None: from cleave.blend_modes import BLEND_MODES from cleave.cue_roles import CUE_ROLES @@ -1346,6 +1371,7 @@ def test_b_and_c_noop_on_off_cue() -> None: lanes=lanes, position_sec=10.0, ) + session.layers["layer_1"].preset_switching_rotation_set = "cast_roles" session.timeline.selected_cue_t["layer_1"] = 10.0 off_before = session.timeline.lanes["layer_1"].cues[1] assert off_before.level == 0.0 diff --git a/tests/cleave/viz/test_timeline_overlay.py b/tests/cleave/viz/test_timeline_overlay.py index 8fd4a3e..3ffe101 100644 --- a/tests/cleave/viz/test_timeline_overlay.py +++ b/tests/cleave/viz/test_timeline_overlay.py @@ -129,6 +129,7 @@ def _view_state( standard_cue_fade_out: float = 2.0, selected_cue_t: dict[str, float] | None = None, selected_cue_flash_start_ms: int | None = None, + slot_rotation_sets: dict[str, str] | None = None, ) -> TimelineViewState: order = list(layer_z_order or list(DEFAULT_LAYER_SLOTS)) lane_map = dict(lanes or {}) @@ -196,6 +197,11 @@ def _view_state( ), selected_cue_t=dict(selected_cue_t or ()), selected_cue_flash_start_ms=selected_cue_flash_start_ms, + slot_rotation_sets=dict( + slot_rotation_sets + if slot_rotation_sets is not None + else {slot: "directory" for slot in order} + ), ) @@ -937,9 +943,14 @@ def test_bar_without_high_water_mark_behaves_as_before() -> None: def test_selected_cue_readout_text_format() -> None: assert selected_cue_readout_text( SlotCue(t=65.0, level=1.0, blend="add", role="lead") + ) == "[01:05] opacity: 100% blend: add" + assert selected_cue_readout_text( + SlotCue(t=65.0, level=1.0, blend="add", role="lead"), + show_cast=True, ) == "[01:05] opacity: 100% blend: add cast: lead" assert selected_cue_readout_text( - SlotCue(t=0.0, level=0.25, blend=None, role=None) + SlotCue(t=0.0, level=0.25, blend=None, role=None), + show_cast=True, ) == "[00:00] opacity: 25% blend: - cast: -" @@ -960,6 +971,7 @@ def test_draw_with_selected_cue_and_role_does_not_crash() -> None: focus_row=0, submenu_focused=True, selected_cue_t={"layer_1": 10.0}, + slot_rotation_sets={"layer_1": "cast_roles"}, ) surface = pygame.Surface((1280, 720), pygame.SRCALPHA) _draw(overlay, surface, state) @@ -983,6 +995,7 @@ def test_role_glyph_not_drawn_on_off_cue() -> None: duration_sec=100.0, focus_row=0, position_sec=0.0, + slot_rotation_sets={"layer_1": "cast_roles"}, ) composed = overlay.compose_panel( state, @@ -1300,6 +1313,7 @@ def test_role_glyph_xor_drawn_late_on_upload_not_static() -> None: duration_sec=100.0, focus_row=0, position_sec=0.0, + slot_rotation_sets={"layer_1": "cast_roles"}, ) composed = overlay.compose_panel( state, diff --git a/tests/cleave/viz/test_view_state_structure.py b/tests/cleave/viz/test_view_state_structure.py index 636edc4..e4f0f93 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -441,6 +441,78 @@ def test_structure_signature_invalidates_on_preset_switching_timeline_mode() -> assert sig_timeline != sig_none +def test_structure_signature_invalidates_on_cast_roles_rotation_set() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + config_save = controls._config_save + session.layers["layer_1"].preset_switching = "timeline" + session.layers["layer_1"].preset_switching_rotation_set = "directory" + sig_before = view_state_structure_signature( + session, config_save, notification_active=False + ) + session.layers["layer_1"].preset_switching_rotation_set = "cast_roles" + sig_after = view_state_structure_signature( + session, config_save, notification_active=False + ) + assert sig_before != sig_after + + builder = controls._view_state + view_cast = builder.build(paused=False) + behaviour = RowDescriptor( + RowKind.TRACK_CAST_ROLES_TIMELINE_BEHAVIOUR, slot="layer_1" + ) + assert behaviour in view_cast.layout.rows + + session.layers["layer_1"].preset_switching_rotation_set = "directory" + view_dir = builder.build(paused=False) + assert view_dir.layout is not view_cast.layout + assert behaviour not in view_dir.layout.rows + + +def test_structure_signature_invalidates_on_cast_roles_fields() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + config_save = controls._config_save + session.layers["layer_1"].preset_switching = "timeline" + session.layers["layer_1"].preset_switching_rotation_set = "cast_roles" + sig_before = view_state_structure_signature( + session, config_save, notification_active=False + ) + session.layers["layer_1"].cast_roles_timeline_behaviour = "hold_current" + sig_behaviour = view_state_structure_signature( + session, config_save, notification_active=False + ) + assert sig_before != sig_behaviour + session.layers["layer_1"].cast_roles_default_role = "pulse" + sig_role = view_state_structure_signature( + session, config_save, notification_active=False + ) + assert sig_behaviour != sig_role + + +def test_structure_signature_invalidates_on_persistent_notification() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + config_save = controls._config_save + sig_inactive = view_state_structure_signature( + session, config_save, notification_active=False + ) + sig_persistent = view_state_structure_signature( + session, + config_save, + notification_active=False, + persistent_notification_active=True, + ) + assert sig_inactive != sig_persistent + sig_both = view_state_structure_signature( + session, + config_save, + notification_active=True, + persistent_notification_active=True, + ) + assert sig_persistent != sig_both + + def test_timeline_preset_switching_hides_projectm_only_rows() -> None: controls = _make_controls(("layer_1",)) session = controls.session From ca098a3e187f121d693b335abb5c5cd26501abef Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sat, 1 Aug 2026 20:42:57 +0100 Subject: [PATCH 11/12] Update preset file rows when timeline cast role changes --- cleave/viz/controls.py | 1 + cleave/viz/preset_switching.py | 4 +- cleave/viz/session.py | 3 ++ cleave/viz/tuning_view_state.py | 53 ++++++++++++++++--- tests/cleave/viz/test_preset_switching.py | 1 + tests/cleave/viz/test_view_state_structure.py | 48 +++++++++++++++++ 6 files changed, 103 insertions(+), 7 deletions(-) diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index b77a92b..6003f3e 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -209,6 +209,7 @@ def __init__( get_move_mode_slot=lambda: self.move_mode_slot, config_save=self._config_save, get_notification=self._notification_host.active, + layers_by_slot=layers_by_slot, ) self._render_overlay = RenderOverlayControls(session) self._render_post_fx = RenderPostFxControls( diff --git a/cleave/viz/preset_switching.py b/cleave/viz/preset_switching.py index bf195de..4518b43 100644 --- a/cleave/viz/preset_switching.py +++ b/cleave/viz/preset_switching.py @@ -687,11 +687,13 @@ def load_manual_preset_clean( then restores the layer's configured value for later auto-switch transitions. """ pm = layer.pm - if layer.playlist.current is None: + current = layer.playlist.current + if current is None: return pm.set_preset_start_clean(True) layer.playlist.load_into(pm, smooth=False) pm.set_preset_start_clean(preset_start_clean) + layer.auto_preset_path = current.resolve() def sync_manual_browse_with_user_defined_rotation(layer: StemLayer) -> None: diff --git a/cleave/viz/session.py b/cleave/viz/session.py index 65afd50..4c12bbf 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -297,6 +297,9 @@ class LayerRuntime: preset_start_clean: bool = DEFAULT_PRESET_START_CLEAN user_presets: list[str] = field(default_factory=list) # absolute paths user_presets_expanded: bool = False + # Playing auto-switch preset (panel display); not persisted. Mirrored from + # StemLayer.auto_preset_path while the live layer map is available. + auto_preset_path: Path | None = None @dataclass diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index a810823..2833277 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -46,7 +46,11 @@ from cleave.cue_roles import CueRole from cleave.extract import StemSource from cleave.preset_curation import PresetCurationIndex -from cleave.preset_playlist import PresetPlaylist, preset_filename_display +from cleave.preset_playlist import ( + PresetPlaylist, + preset_filename_display, + scan_preset_playlist, +) from cleave.timeline_presets.conductor import DEFAULT_TIMELINE_PRESET_CONDUCTOR from cleave.viz.panel_notification import PanelNotificationActive from cleave.timeline_presets.crescendo import CrescendoTarget @@ -57,11 +61,12 @@ from cleave.viz.config_save import ConfigSaveController from cleave.viz.playback import PlaybackState, current_sec from cleave.viz.row_semantics import RowDescriptor, RowKind -from cleave.viz.session import TuningSession, config_path_display +from cleave.viz.session import LayerRuntime, TuningSession, config_path_display from cleave.viz.user_presets import user_preset_item_display_name if TYPE_CHECKING: from cleave.viz.focus_nav import FocusCursor + from cleave.viz.layer import StemLayer from cleave.viz.row_layout import RowLayout, RowLayoutFrame _RO_OVERLAY_DEFAULTS = default_render_overlay_runtime_values() @@ -355,6 +360,11 @@ def view_state_structure_signature( "paths": [str(path) for path in playlist.paths], "index": playlist.index, }, + "auto_preset_path": ( + None + if layer.auto_preset_path is None + else str(layer.auto_preset_path) + ), } ro = session.render_overlay pp = session.render_post_fx @@ -430,6 +440,7 @@ def __init__( get_move_mode_slot: Callable[[], str | None], config_save: ConfigSaveController, get_notification: Callable[[], PanelNotificationActive], + layers_by_slot: dict[str, StemLayer] | None = None, ) -> None: self.session = session self.playback = playback @@ -440,8 +451,19 @@ def __init__( self._get_move_mode_slot = get_move_mode_slot self._config_save = config_save self._get_notification = get_notification + self._layers_by_slot = layers_by_slot + self._auto_display_cache: dict[Path, PresetPlaylist] = {} self._structure: _ViewStateStructure | None = None + def _sync_auto_preset_paths(self) -> None: + """Mirror StemLayer playing paths onto session for panel display.""" + if not self._layers_by_slot: + return + for slot, stem in self._layers_by_slot.items(): + runtime = self.session.layers.get(slot) + if runtime is not None: + runtime.auto_preset_path = stem.auto_preset_path + def _user_preset_basenames(self) -> set[str]: names: set[str] = set() for layer in self.session.layers.values(): @@ -449,6 +471,21 @@ def _user_preset_basenames(self) -> set[str]: names.add(Path(path).name) return names + def _display_playlist(self, layer: LayerRuntime) -> PresetPlaylist: + """Playlist for dir/file rows: playing auto-switch preset when set.""" + auto = layer.auto_preset_path + if auto is None: + return layer.playlist + cached = self._auto_display_cache.get(auto) + if cached is not None: + return cached + try: + playlist = scan_preset_playlist(auto) + except (OSError, ValueError): + return layer.playlist + self._auto_display_cache[auto] = playlist + return playlist + def _preset_label( self, playlist: PresetPlaylist, *, user_names: set[str] ) -> str: @@ -479,14 +516,15 @@ def _build_structure( tracks: dict[str, TrackBlock] = {} for slot in layer_z_order: layer = self.session.layers[slot] + display = self._display_playlist(layer) tracks[slot] = TrackBlock( stem=layer.stem, - preset_dir_label=layer.playlist.directory_display_label( + preset_dir_label=display.directory_display_label( self.preset_root, browse_floor=layer.browse_floor, ), preset_label=self._preset_label( - layer.playlist, user_names=user_names + display, user_names=user_names ), blend_mode=layer.blend_mode, opacity_pct=layer.opacity_pct, @@ -497,7 +535,7 @@ def _build_structure( visible=layer.enabled, expanded=layer.expanded, locked=layer.locked, - preset_empty=not layer.playlist.paths, + preset_empty=not display.paths, preset_switching=layer.preset_switching, preset_switching_rotation_set=layer.preset_switching_rotation_set, cast_roles_timeline_behaviour=layer.cast_roles_timeline_behaviour, @@ -630,6 +668,7 @@ def _patch_tracks( for slot in structure.layer_z_order: base = structure.tracks[slot] layer = self.session.layers[slot] + display = self._display_playlist(layer) visible = effective_layer_enabled(self.session, slot, position_sec) tracks[slot] = replace( base, @@ -642,7 +681,7 @@ def _patch_tracks( beat_sensitivity=layer.beat_sensitivity, preset_switching_shuffle_salt=layer.preset_switching_shuffle_salt, preset_label=self._preset_label( - layer.playlist, user_names=user_names + display, user_names=user_names ), user_preset_labels=self._user_preset_labels(list(layer.user_presets)), effects=dict(layer.effects), @@ -659,6 +698,8 @@ def build( if position_sec is None: position_sec = current_sec(self.playback, self.duration_sec) + self._sync_auto_preset_paths() + notification = self._get_notification() notification_message = notification.message notification_remaining_sec = notification.remaining_sec diff --git a/tests/cleave/viz/test_preset_switching.py b/tests/cleave/viz/test_preset_switching.py index 44cdc4b..b4e4e4f 100644 --- a/tests/cleave/viz/test_preset_switching.py +++ b/tests/cleave/viz/test_preset_switching.py @@ -69,6 +69,7 @@ def test_load_manual_preset_clean_forces_black_boot_then_restores() -> None: call(False), ] layer.playlist.load_into.assert_called_once_with(layer.pm, smooth=False) + assert layer.auto_preset_path == _MILK[0].resolve() def test_load_manual_preset_clean_restores_configured_start_clean() -> None: diff --git a/tests/cleave/viz/test_view_state_structure.py b/tests/cleave/viz/test_view_state_structure.py index e4f0f93..f51cd53 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -441,6 +441,54 @@ def test_structure_signature_invalidates_on_preset_switching_timeline_mode() -> assert sig_timeline != sig_none +def test_structure_signature_invalidates_on_auto_preset_path() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + config_save = controls._config_save + layer = session.layers["layer_1"] + sig_before = view_state_structure_signature( + session, config_save, notification_active=False + ) + layer.auto_preset_path = layer.playlist.paths[1].resolve() + sig_after = view_state_structure_signature( + session, config_save, notification_active=False + ) + assert sig_before != sig_after + + +def test_builder_shows_auto_preset_in_dir_and_file_rows() -> None: + controls = _make_controls(("layer_1",)) + session = controls.session + layer = session.layers["layer_1"] + layer.preset_switching = "timeline" + layer.preset_switching_rotation_set = "cast_roles" + root = controls._view_state.preset_root + bed = root / "roles" / "bed" + pulse = root / "roles" / "pulse" + bed.mkdir(parents=True, exist_ok=True) + pulse.mkdir(parents=True, exist_ok=True) + bed_a = bed / "bed-a.milk" + bed_b = bed / "bed-b.milk" + pulse_a = pulse / "pulse-a.milk" + for path in (bed_a, bed_b, pulse_a): + path.write_text("milk", encoding="utf-8") + + view_browse = controls.build_view_state(paused=False) + assert "roles/" not in view_browse.tracks["layer_1"].preset_dir_label + + layer.auto_preset_path = bed_b.resolve() + view_bed = controls.build_view_state(paused=False) + assert "roles/bed/" in view_bed.tracks["layer_1"].preset_dir_label + assert view_bed.tracks["layer_1"].preset_label.startswith("bed-b.milk (2/2)") + + layer.auto_preset_path = pulse_a.resolve() + view_pulse = controls.build_view_state(paused=False) + assert "roles/pulse/" in view_pulse.tracks["layer_1"].preset_dir_label + assert view_pulse.tracks["layer_1"].preset_label.startswith("pulse-a.milk (1/1)") + # Browse playlist unchanged (config stays clean). + assert layer.playlist.index == 0 + + def test_structure_signature_invalidates_on_cast_roles_rotation_set() -> None: controls = _make_controls(("layer_1",)) session = controls.session From 58d11bd1a80b614db2fd1e1e2d7ce75dfa04997a Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Sat, 1 Aug 2026 21:17:21 +0100 Subject: [PATCH 12/12] Make the conductor apply cast roles for timeline preset creation --- cleave/cue_roles.py | 9 + cleave/timeline_presets/arrange.py | 8 +- cleave/timeline_presets/conductor.py | 48 +++++ cleave/timeline_presets/crescendo.py | 79 +++++++- cleave/timeline_presets/emit.py | 27 ++- docs/improved-timeline-presets.md | 17 +- tests/cleave/test_timeline_conductor.py | 229 +++++++++++++++++++++++- 7 files changed, 402 insertions(+), 15 deletions(-) diff --git a/cleave/cue_roles.py b/cleave/cue_roles.py index 1cd6cec..a7556ef 100644 --- a/cleave/cue_roles.py +++ b/cleave/cue_roles.py @@ -5,12 +5,21 @@ from pathlib import Path from typing import Literal +from cleave.blend_modes import BlendMode + CueRole = Literal["bed", "pulse", "lead", "accent"] CUE_ROLES: tuple[CueRole, ...] = ("bed", "pulse", "lead", "accent") CUE_ROLE_DIR = "roles" +CUE_ROLE_BLEND: dict[CueRole, BlendMode] = { + "bed": "black-key", + "pulse": "add", + "lead": "black-key", + "accent": "add", +} + CUE_ROLE_MARKER_LETTER: dict[CueRole, str] = { "bed": "B", "pulse": "P", diff --git a/cleave/timeline_presets/arrange.py b/cleave/timeline_presets/arrange.py index 18f89dd..c895a44 100644 --- a/cleave/timeline_presets/arrange.py +++ b/cleave/timeline_presets/arrange.py @@ -192,7 +192,13 @@ def compose_timeline( if conductor is None else conductor.level_states(states, duration_sec) ) - return cues_from_states(slot_list, level_states) + casts = None + if conductor is not None: + casts = [ + conductor.cast_for_state(active, conductor.phrase_at(t)) + for t, active in states + ] + return cues_from_states(slot_list, level_states, casts) def _normalize_song_markers( diff --git a/cleave/timeline_presets/conductor.py b/cleave/timeline_presets/conductor.py index 5cfe4ed..67dbb5b 100644 --- a/cleave/timeline_presets/conductor.py +++ b/cleave/timeline_presets/conductor.py @@ -7,6 +7,8 @@ import numpy as np +from cleave.blend_modes import BlendMode +from cleave.cue_roles import CUE_ROLE_BLEND, CueRole from cleave.extract import StemSource from cleave.signals import Signals from cleave.timeline import LEVEL_QUANTUM, clamp_level, quantize_level @@ -245,6 +247,52 @@ def chord_score(self, active: frozenset[str], weights: PhraseWeights) -> float: ) return total / len(active) + def cast_for_state( + self, + active: frozenset[str] | set[str], + weights: PhraseWeights, + ) -> dict[str, tuple[CueRole, BlendMode]]: + """Assign one role and blend per active slot for a state. + + Near-silent phrases cast every active slot as bed. Otherwise drums with + non-trivial activity become pulse; the highest-activity non-pulse slot + is lead (solo states are always lead); remaining slots are bed. At most + one lead; accent is never assigned. + """ + if not active: + return {} + + def _role(role: CueRole) -> tuple[CueRole, BlendMode]: + return (role, CUE_ROLE_BLEND[role]) + + if weights.near_silent: + return {slot: _role("bed") for slot in active} + + if len(active) == 1: + slot = next(iter(active)) + return {slot: _role("lead")} + + cast: dict[str, tuple[CueRole, BlendMode]] = {} + pulse_slots: set[str] = set() + for slot in active: + if ( + self._slot_stems.get(slot) == "drums" + and weights.slot_activity.get(slot, 0.0) > 0.0 + ): + cast[slot] = _role("pulse") + pulse_slots.add(slot) + + candidates = sorted(slot for slot in active if slot not in pulse_slots) + if candidates: + lead = max( + candidates, key=lambda s: weights.slot_activity.get(s, 0.0) + ) + cast[lead] = _role("lead") + for slot in candidates: + if slot != lead: + cast[slot] = _role("bed") + return cast + def level_states( self, states: Sequence[tuple[float, frozenset[str]]], diff --git a/cleave/timeline_presets/crescendo.py b/cleave/timeline_presets/crescendo.py index 3aa7541..5bc13a1 100644 --- a/cleave/timeline_presets/crescendo.py +++ b/cleave/timeline_presets/crescendo.py @@ -7,12 +7,16 @@ from dataclasses import dataclass from typing import Literal +from cleave.blend_modes import BlendMode +from cleave.cue_roles import CUE_ROLE_BLEND, CueRole from cleave.timeline import ( LEVEL_EPS, LEVEL_QUANTUM, TimelineLane, clamp_level, + lane_blend_at, lane_level_at, + lane_role_at, levels_equal, quantize_level, ) @@ -119,7 +123,12 @@ def apply_crescendo( target: CrescendoTarget, rng: random.Random, ) -> dict[str, TimelineLane]: - """Rewrite ``lanes`` from the crescendo window through song end.""" + """Rewrite ``lanes`` from the crescendo window through song end. + + When the source lanes carry cast roles (e.g. conductor Apply), the prefix + keeps those held role/blend values and the crescendo ramp assigns a simple + lead/bed cast so ``cues_from_states`` does not strip them. + """ slot_list = list(slots) if not slot_list or duration_sec <= 0.0: return lanes @@ -138,7 +147,12 @@ def apply_crescendo( merged = _merge_states(prefix, crescendo) if not merged: return lanes - return cues_from_states(slot_list, merged) + casts = None + if _lanes_have_roles(lanes): + casts = _casts_for_merged( + lanes, slot_list, merged, t_start=window.t_start + ) + return cues_from_states(slot_list, merged, casts) def _levels_equal_maps( @@ -153,6 +167,67 @@ def _levels_equal_maps( ) +def _lanes_have_roles(lanes: Mapping[str, TimelineLane]) -> bool: + return any( + cue.role is not None for lane in lanes.values() for cue in lane.cues + ) + + +def _cast_at_from_lanes( + lanes: Mapping[str, TimelineLane], + slots: Sequence[str], + t: float, + levels: Mapping[str, float], +) -> dict[str, tuple[CueRole, BlendMode]]: + """Held role/blend at ``t`` for slots that are on in ``levels``.""" + cast: dict[str, tuple[CueRole, BlendMode]] = {} + for slot in slots: + if float(levels.get(slot, 0.0)) <= LEVEL_EPS: + continue + lane = lanes.get(slot) or TimelineLane(baseline=0.0, cues=[]) + role = lane_role_at(lane, t) + if role is None: + continue + blend = lane_blend_at(lane, t) + if blend is None: + blend = CUE_ROLE_BLEND[role] + cast[slot] = (role, blend) + return cast + + +def _crescendo_cast_for_levels( + levels: Mapping[str, float], +) -> dict[str, tuple[CueRole, BlendMode]]: + """Simple ramp cast: first active slot (stack order) is lead; rest bed.""" + active = [ + slot for slot, level in levels.items() if float(level) > LEVEL_EPS + ] + if not active: + return {} + lead = active[0] + cast: dict[str, tuple[CueRole, BlendMode]] = {} + for slot in active: + role: CueRole = "lead" if slot == lead else "bed" + cast[slot] = (role, CUE_ROLE_BLEND[role]) + return cast + + +def _casts_for_merged( + lanes: Mapping[str, TimelineLane], + slots: Sequence[str], + merged: Sequence[tuple[float, Mapping[str, float]]], + *, + t_start: float, +) -> list[dict[str, tuple[CueRole, BlendMode]]]: + casts: list[dict[str, tuple[CueRole, BlendMode]]] = [] + for t, levels in merged: + if t < t_start - 1e-9: + casts.append(_cast_at_from_lanes(lanes, slots, t, levels)) + else: + casts.append(_crescendo_cast_for_levels(levels)) + return casts + + def _states_before( lanes: dict[str, TimelineLane], slots: Sequence[str], diff --git a/cleave/timeline_presets/emit.py b/cleave/timeline_presets/emit.py index 2a2fde4..d5c9383 100644 --- a/cleave/timeline_presets/emit.py +++ b/cleave/timeline_presets/emit.py @@ -4,7 +4,16 @@ from collections.abc import Mapping, Sequence -from cleave.timeline import SlotCue, TimelineLane, canonicalize, empty_lane, levels_equal +from cleave.blend_modes import BlendMode +from cleave.cue_roles import CueRole +from cleave.timeline import ( + LEVEL_EPS, + SlotCue, + TimelineLane, + canonicalize, + empty_lane, + levels_equal, +) def levels_from_active( @@ -18,12 +27,17 @@ def levels_from_active( def cues_from_states( slots: Sequence[str], states: Sequence[tuple[float, Mapping[str, float]]], + casts: Sequence[Mapping[str, tuple[CueRole, BlendMode]]] | None = None, ) -> dict[str, TimelineLane]: """Build per-slot lanes from level states. Baseline is always ``0.0``. The first state's levels are emitted as cues at ``t=0`` (so an opening on-section is selectable for cast/blend). Later states use their own times. Absent slots in a state mapping are ``0.0``. + + When ``casts`` is provided (parallel to ``states``), on-transitions and + other non-zero level cues write ``blend`` and ``role`` from the cast entry. + Off-cues remain stripped by ``canonicalize``. """ slot_list = list(slots) if not states: @@ -34,10 +48,19 @@ def cues_from_states( prev = {slot: 0.0 for slot in slot_list} for index, (t, levels) in enumerate(states): cue_t = 0.0 if index == 0 else float(t) + cast_map = casts[index] if casts is not None and index < len(casts) else None for slot in slot_list: now = float(levels.get(slot, 0.0)) if not levels_equal(now, prev[slot]): - cues_by_slot[slot].append(SlotCue(t=cue_t, level=now)) + blend: BlendMode | None = None + role: CueRole | None = None + if cast_map is not None and now > LEVEL_EPS: + entry = cast_map.get(slot) + if entry is not None: + role, blend = entry + cues_by_slot[slot].append( + SlotCue(t=cue_t, level=now, blend=blend, role=role) + ) prev[slot] = now return { slot: TimelineLane( diff --git a/docs/improved-timeline-presets.md b/docs/improved-timeline-presets.md index 238359a..2b9b2cb 100644 --- a/docs/improved-timeline-presets.md +++ b/docs/improved-timeline-presets.md @@ -21,10 +21,9 @@ What works today: Gaps the ideas below target: -1. The arranger still does not assign blend or role automatically (levels and stem gating are shipped; automatic mix assignment remains deferred). -2. Song form still needs manual markers for the best results. -3. Busy collisions often come from milk personality; role casting helps when pools are curated, but automatic pool fill needs Idea 3 fingerprints. -4. Nothing recurs. Chorus 2 gets a fresh roll of the dice, so the output reads as plausible-but-random rather than composed. +1. Song form still needs manual markers for the best results. +2. Busy collisions often come from milk personality; role casting helps when pools are curated, but automatic pool fill needs Idea 3 fingerprints. +3. Nothing recurs. Chorus 2 gets a fresh roll of the dice, so the output reads as plausible-but-random rather than composed. --- @@ -35,7 +34,7 @@ Gaps the ideas below target: **Deferred** (still part of the mix vision, not implemented): - Record toggles still write `0.0` / `1.0` (partial timeline opacity is Apply or strip nudge). -- Automatic blend/role assignment from the generative arranger (Idea 3). +- Fingerprint-driven role pool fill (Idea 3); conductor stem casting of blend/role is shipped under Idea 2. ### Intent @@ -56,7 +55,7 @@ Gaps the ideas below target: 8. Role: event property on on-transitions; seek-stable per-role pools in [cleave/viz/preset_switching.py](../cleave/viz/preset_switching.py); empty pool falls back to the main rotation. 9. Strip authoring: `,` / `.` select on cues (`level > 0`, including mid-on changes; offs skipped); `Shift` / `Ctrl` + `,` / `.` nudge selected cue timeline opacity by 1% / 10% (floor 10% so the cue is not erased; multiplies into the layer opacity fader; YAML field stays `level`); `b` / `c` cycle blend and cast on those only; selected tick highlight, role glyphs on on cues, and badge readout (`opacity N%`). -Overlaps [roadmap.md](roadmap.md) richer cue types; automatic blend/role assignment from stem content and fingerprints remains Idea 3. +Overlaps [roadmap.md](roadmap.md) richer cue types; fingerprint-driven pool fill remains Idea 3. ### User effort @@ -66,12 +65,12 @@ None for 0/1 lanes. Partial timeline opacity comes from generative Apply (cresce ## Idea 2: Stem conductor (shipped: opt-in audio-aware arrange) -**Shipped:** an opt-in staged `conductor` row under timeline preset. When on and `signals.json` is present (version 4), Apply builds a [StemConductor](../cleave/timeline_presets/conductor.py) from full-mix energy ranks and per-slot stem presence, scales the character budget, biases solo rotation and chord picks toward active stems, and emits continuous cue levels quantised to `LEVEL_QUANTUM` (active slots never land between 0 and 0.25; near-silent phrases keep one slot at 0.25). Missing signals notify and fall through to plain arrangement. `other` now carries `rms` alongside `spectral_centroid`. +**Shipped:** an opt-in staged `conductor` row under timeline preset. When on and `signals.json` is present (version 4), Apply builds a [StemConductor](../cleave/timeline_presets/conductor.py) from full-mix energy ranks and per-slot stem presence, scales the character budget, biases solo rotation and chord picks toward active stems, emits continuous cue levels quantised to `LEVEL_QUANTUM` (active slots never land between 0 and 0.25; near-silent phrases keep one slot at 0.25), and assigns per-slot cast roles and blends on on-cues (`cast_for_state`: drums with activity -> pulse/add; highest non-pulse -> lead; rest -> bed; near-silent -> bed; one lead max). Missing signals notify and fall through to plain arrangement. `other` now carries `rms` alongside `spectral_centroid`. **Deferred** (still part of the conductor vision, not implemented): - Section-role bias from auto form (Idea 4). -- Automatic blend or role assignment from stem content (Idea 1 deferred / Idea 3). +- Fingerprint-driven role pool fill and accent casting (Idea 3). ### Intent @@ -82,7 +81,7 @@ None for 0/1 lanes. Partial timeline opacity comes from generative Apply (cresce ### What landed 1. Signals: `other.rms` in extract/analyse; `SIGNALS_VERSION = 4`; `Signals.window_mean` for phrase and state windows. -2. Conductor module: phrase energy ranks, per-slot activity, rotation hint, chord score, `level_states`; staging helpers mirror density. +2. Conductor module: phrase energy ranks, per-slot activity, rotation hint, chord score, `level_states`, `cast_for_state`; staging helpers mirror density. 3. Arranger hooks: optional `signals` / `slot_stems` on all four builders into `compose_timeline`; UI toggle expressed by whether Apply passes those kwargs. 4. Panel: `timeline.preset.conductor` persisted; confirm modal lists `conductor: on|off`. diff --git a/tests/cleave/test_timeline_conductor.py b/tests/cleave/test_timeline_conductor.py index 0a7dda0..7bf63e5 100644 --- a/tests/cleave/test_timeline_conductor.py +++ b/tests/cleave/test_timeline_conductor.py @@ -8,9 +8,16 @@ import numpy as np import pytest +from cleave.cue_roles import CUE_ROLE_BLEND from cleave.extract import StemSource from cleave.signals import Signals -from cleave.timeline import LEVEL_QUANTUM, TimelineLane, empty_lane, lane_level_at +from cleave.timeline import ( + LEVEL_QUANTUM, + SlotCue, + TimelineLane, + empty_lane, + lane_level_at, +) from cleave.timeline_presets import ALL_BUILDERS, build_breathing_cues, build_pulse_cues from cleave.timeline_presets.conductor import ( CONDUCTOR_ACTIVITY_MIDPOINT, @@ -29,6 +36,7 @@ apply_crescendo, resolve_crescendo_window, ) +from cleave.timeline_presets.emit import cues_from_states _SR = 100.0 _DUR = 20.0 @@ -262,6 +270,131 @@ def test_deterministic_for_same_inputs() -> None: ) +def test_cast_for_state_near_silent_all_bed() -> None: + mix_rms = _envelope(0.001, 1.0) + mix_onset = _envelope(0.001, 1.0) + signals = _make_signals(mix_rms=mix_rms, mix_onset=mix_onset) + conductor = StemConductor.build(signals, _slot_stems(), _phrases()) + assert conductor is not None + weights = conductor.phrase_at(5.0) + assert weights.near_silent + active = frozenset({"layer_1", "layer_2", "layer_3"}) + cast = conductor.cast_for_state(active, weights) + assert set(cast) == active + for role, blend in cast.values(): + assert role == "bed" + assert blend == CUE_ROLE_BLEND["bed"] + + +def test_cast_for_state_drums_pulse_and_one_lead() -> None: + conductor = StemConductor.build(_make_signals(), _slot_stems(), _phrases()) + assert conductor is not None + weights = conductor.phrase_at(15.0) + assert not weights.near_silent + assert weights.slot_activity["layer_1"] > 0.0 + active = frozenset({"layer_1", "layer_2", "layer_3", "layer_4"}) + cast = conductor.cast_for_state(active, weights) + assert cast["layer_1"] == ("pulse", CUE_ROLE_BLEND["pulse"]) + roles = {role for role, _blend in cast.values()} + assert roles == {"pulse", "lead", "bed"} + leads = [slot for slot, (role, _) in cast.items() if role == "lead"] + assert len(leads) == 1 + lead = leads[0] + assert lead != "layer_1" + non_pulse = [s for s in active if s != "layer_1"] + expected_lead = max( + sorted(non_pulse), key=lambda s: weights.slot_activity.get(s, 0.0) + ) + assert lead == expected_lead + for slot in non_pulse: + if slot != lead: + assert cast[slot] == ("bed", CUE_ROLE_BLEND["bed"]) + assert "accent" not in roles + + +def test_cast_for_state_solo_is_lead() -> None: + conductor = StemConductor.build(_make_signals(), _slot_stems(), _phrases()) + assert conductor is not None + weights = conductor.phrase_at(15.0) + # Solo drums still casts as lead (solo overrides pulse). + cast = conductor.cast_for_state(frozenset({"layer_1"}), weights) + assert cast == {"layer_1": ("lead", CUE_ROLE_BLEND["lead"])} + cast_bass = conductor.cast_for_state(frozenset({"layer_2"}), weights) + assert cast_bass == {"layer_2": ("lead", CUE_ROLE_BLEND["lead"])} + + +def test_cast_for_state_silent_drums_not_pulse() -> None: + drums = np.zeros(_N, dtype=np.float64) + signals = _make_signals(drums=drums) + conductor = StemConductor.build(signals, _slot_stems(), _phrases()) + assert conductor is not None + weights = conductor.phrase_at(15.0) + assert weights.slot_activity["layer_1"] == pytest.approx(0.0) + cast = conductor.cast_for_state( + frozenset({"layer_1", "layer_2", "layer_3"}), weights + ) + assert cast["layer_1"][0] != "pulse" + leads = [slot for slot, (role, _) in cast.items() if role == "lead"] + assert len(leads) == 1 + + +def test_cues_from_states_writes_cast_on_on_transitions() -> None: + slots = ["layer_1", "layer_2"] + states = [ + (0.0, {"layer_1": 1.0, "layer_2": 0.0}), + (4.0, {"layer_1": 0.0, "layer_2": 0.5}), + (8.0, {"layer_1": 0.0, "layer_2": 1.0}), + ] + casts = [ + {"layer_1": ("lead", "black-key")}, + {"layer_2": ("pulse", "add")}, + {"layer_2": ("pulse", "add")}, + ] + lanes = cues_from_states(slots, states, casts) + assert lanes["layer_1"].cues == [ + SlotCue(t=0.0, level=1.0, blend="black-key", role="lead"), + SlotCue(t=4.0, level=0.0), + ] + assert lanes["layer_2"].cues == [ + SlotCue(t=4.0, level=0.5, blend="add", role="pulse"), + SlotCue(t=8.0, level=1.0, blend="add", role="pulse"), + ] + + +def test_cues_from_states_without_casts_omits_role_blend() -> None: + slots = ["layer_1"] + lanes = cues_from_states(slots, [(0.0, {"layer_1": 1.0})]) + assert lanes["layer_1"].cues == [SlotCue(t=0.0, level=1.0)] + + +def test_arranger_with_conductor_emits_roles_and_blends() -> None: + slots = ["layer_1", "layer_2", "layer_3", "layer_4"] + duration_sec = 20.0 + bars = _bar_times(duration_sec) + lanes = build_breathing_cues( + slots, + duration_sec, + random.Random(7), + bar_times=bars, + signals=_make_signals(), + slot_stems=_slot_stems(), + ) + on_cues = [ + cue + for lane in lanes.values() + for cue in lane.cues + if cue.level > 0.0 + ] + assert on_cues + assert any(cue.role is not None and cue.blend is not None for cue in on_cues) + for cue in on_cues: + if cue.role is None: + continue + assert cue.role in ("bed", "pulse", "lead") + assert cue.blend == CUE_ROLE_BLEND[cue.role] + assert cue.role != "accent" + + def test_chord_score_is_centred_mean_activity() -> None: conductor = StemConductor.build(_make_signals(), _slot_stems(), _phrases()) assert conductor is not None @@ -733,3 +866,97 @@ def test_arranger_conductor_crescendo_preserves_prefix_levels() -> None: lane_level_at(base[slot], t, inherit=0.0) ) t += 1.0 + + +def test_arranger_conductor_crescendo_preserves_prefix_roles() -> None: + """Regression: apply_crescendo must not strip conductor casts on rebuild.""" + slots = ["layer_1", "layer_2", "layer_3", "layer_4"] + duration_sec = 120.0 + bars = _bar_times(duration_sec) + n = int(duration_sec * _SR) + mix_rms = np.linspace(0.15, 1.0, n, dtype=np.float64) + mix_onset = np.linspace(0.1, 0.9, n, dtype=np.float64) + stem = np.linspace(0.2, 0.9, n, dtype=np.float64) + signals = Signals( + sample_rate_hz=_SR, + duration_sec=duration_sec, + path=Path("."), + stems={ + "drums": {"onset_strength": stem}, + "bass": {"rms": stem, "sub_bass": stem, "mid_bass": stem}, + "vocals": {"rms": stem, "pitch_hz": stem}, + "other": {"spectral_centroid": stem * 1000.0, "rms": stem}, + "full_mix": {"onset_strength": mix_onset, "rms": mix_rms}, + }, + ) + markers = [20.0, 50.0, 80.0, 100.0] + window = resolve_crescendo_window(markers, duration_sec, "last") + assert window is not None + base = build_breathing_cues( + slots, + duration_sec, + random.Random(5), + bar_times=bars, + signals=signals, + slot_stems=_slot_stems(), + ) + base_prefix_on = [ + cue + for lane in base.values() + for cue in lane.cues + if cue.t < window.t_start - 1e-9 and cue.level > 0.0 + ] + assert base_prefix_on + assert any(cue.role is not None for cue in base_prefix_on) + + after = apply_crescendo( + base, + slots, + duration_sec=duration_sec, + bar_times=bars, + song_marker_times=markers, + target="last", + rng=random.Random(6), + ) + after_prefix_on = [ + cue + for lane in after.values() + for cue in lane.cues + if cue.t < window.t_start - 1e-9 and cue.level > 0.0 + ] + assert after_prefix_on + assert all(cue.role is not None for cue in after_prefix_on) + for cue in after_prefix_on: + assert cue.blend == CUE_ROLE_BLEND[cue.role] + + # Crescendo ramp itself also gets lead/bed casts (not left null). + ramp_on = [ + cue + for lane in after.values() + for cue in lane.cues + if cue.t >= window.t_start - 1e-9 and cue.level > 0.0 + ] + assert ramp_on + assert all(cue.role in ("lead", "bed") for cue in ramp_on) + assert all(cue.blend == CUE_ROLE_BLEND[cue.role] for cue in ramp_on) + + +def test_apply_crescendo_without_roles_stays_role_free() -> None: + slots = ["layer_1", "layer_2", "layer_3", "layer_4"] + duration_sec = 120.0 + bars = _bar_times(duration_sec) + markers = [20.0, 50.0, 80.0, 100.0] + base = build_breathing_cues( + slots, duration_sec, random.Random(1), bar_times=bars + ) + assert all(cue.role is None for lane in base.values() for cue in lane.cues) + after = apply_crescendo( + base, + slots, + duration_sec=duration_sec, + bar_times=bars, + song_marker_times=markers, + target="last", + rng=random.Random(2), + ) + assert all(cue.role is None for lane in after.values() for cue in lane.cues)