From ceb60118a8c1f70d906433cdcab6a33e1517a33b Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Thu, 6 Aug 2026 00:32:37 +0100 Subject: [PATCH 1/5] Add song marker type --- cleave/project.py | 75 ++++++++++++++++++++++++------ cleave/song_markers.py | 70 +++++++++++++++++++++------- cleave/viz/app.py | 2 +- cleave/viz/config_save.py | 8 ++-- cleave/viz/controls.py | 36 ++++++++------ cleave/viz/editor_mode_controls.py | 2 +- cleave/viz/row_fields.py | 47 ++++++++++++++++++- cleave/viz/row_semantics.py | 6 ++- cleave/viz/session.py | 12 ++++- cleave/viz/tuning_view_state.py | 7 +++ docs/completed/song-markers.md | 8 +++- tests/cleave/test_project.py | 57 ++++++++++++++++++++--- tests/cleave/test_separate.py | 3 +- tests/cleave/test_song_markers.py | 56 ++++++++++++++++------ tests/cleave/viz/test_controls.py | 51 ++++++++++++++++---- 15 files changed, 350 insertions(+), 90 deletions(-) diff --git a/cleave/project.py b/cleave/project.py index a4dba70..4aa25f2 100644 --- a/cleave/project.py +++ b/cleave/project.py @@ -10,9 +10,45 @@ import yaml +from cleave.song_markers import ( + DEFAULT_SONG_MARKER_TYPE, + SongMarker, + parse_song_marker_type, +) + PROJECT_FILENAME = "project.yaml" +def _parse_song_markers(raw_markers: object) -> tuple[SongMarker, ...]: + if raw_markers is None: + return () + if not isinstance(raw_markers, list): + raise ValueError("invalid project manifest: song-markers") + markers: list[SongMarker] = [] + for item in raw_markers: + if isinstance(item, (int, float)): + markers.append(SongMarker(float(item))) + continue + if not isinstance(item, dict): + raise ValueError("invalid project manifest: song-markers entry") + if "time" not in item: + raise ValueError("invalid project manifest: song-markers entry time") + marker_type = ( + DEFAULT_SONG_MARKER_TYPE + if "type" not in item + else parse_song_marker_type(item["type"]) + ) + markers.append(SongMarker(float(item["time"]), marker_type)) + return tuple(markers) + + +def _song_markers_to_yaml(markers: Sequence[SongMarker]) -> list[dict]: + return [ + {"time": float(m.time), "type": m.marker_type} + for m in markers + ] + + @dataclass(frozen=True) class ProjectManifest: version: int @@ -22,7 +58,7 @@ class ProjectManifest: separated_at: str demucs_model: str restored_from: str | None = None - song_markers: tuple[float, ...] = () + song_markers: tuple[SongMarker, ...] = () @classmethod def from_dict(cls, data: dict) -> ProjectManifest: @@ -35,13 +71,6 @@ def from_dict(cls, data: dict) -> ProjectManifest: raise ValueError("invalid project manifest: mix.filename") restored = data.get("restored-from") restored_from = None if restored is None else str(restored) - raw_markers = data.get("song-markers") - if raw_markers is None: - song_markers: tuple[float, ...] = () - elif isinstance(raw_markers, list): - song_markers = tuple(float(x) for x in raw_markers) - else: - raise ValueError("invalid project manifest: song-markers") return cls( version=int(data["version"]), slug=str(data["slug"]), @@ -50,7 +79,7 @@ def from_dict(cls, data: dict) -> ProjectManifest: separated_at=str(ingest["separated_at"]), demucs_model=str(ingest["demucs_model"]), restored_from=restored_from, - song_markers=song_markers, + song_markers=_parse_song_markers(data.get("song-markers")), ) def to_dict(self) -> dict: @@ -67,7 +96,7 @@ def to_dict(self) -> dict: if self.restored_from is not None: data["restored-from"] = self.restored_from if self.song_markers: - data["song-markers"] = [float(t) for t in self.song_markers] + data["song-markers"] = _song_markers_to_yaml(self.song_markers) return data @@ -101,6 +130,20 @@ def load_manifest(project_dir: Path) -> ProjectManifest: return ProjectManifest.from_dict(data) +def coerce_song_markers( + markers: Sequence[SongMarker | float] | None, +) -> tuple[SongMarker, ...]: + if markers is None: + return () + out: list[SongMarker] = [] + for item in markers: + if isinstance(item, SongMarker): + out.append(item) + else: + out.append(SongMarker(float(item))) + return tuple(out) + + def write_manifest( project_dir: Path, *, @@ -109,7 +152,7 @@ def write_manifest( original_path: Path, demucs_model: str, separated_at: datetime | None = None, - song_markers: Sequence[float] | None = None, + song_markers: Sequence[SongMarker | float] | None = None, ) -> Path: """Create or update ``project.yaml`` mix and ingest fields. @@ -124,7 +167,7 @@ def write_manifest( if path.is_file(): existing = load_manifest(project_dir) markers = ( - tuple(float(t) for t in song_markers) + coerce_song_markers(song_markers) if song_markers is not None else existing.song_markers ) @@ -145,17 +188,19 @@ def write_manifest( original_path=original, separated_at=separated, demucs_model=demucs_model, - song_markers=tuple(float(t) for t in (song_markers or ())), + song_markers=coerce_song_markers(song_markers), ) with path.open("w", encoding="utf-8") as handle: yaml.safe_dump(manifest.to_dict(), handle, sort_keys=False) return path -def save_song_markers(project_dir: Path, markers: Sequence[float]) -> Path: +def save_song_markers( + project_dir: Path, markers: Sequence[SongMarker | float] +) -> Path: """Replace ``song-markers`` in ``project.yaml``, preserving ingest and provenance.""" manifest = load_manifest(project_dir) - updated = replace(manifest, song_markers=tuple(float(t) for t in markers)) + updated = replace(manifest, song_markers=coerce_song_markers(markers)) path = manifest_path(project_dir) with path.open("w", encoding="utf-8") as handle: yaml.safe_dump(updated.to_dict(), handle, sort_keys=False) diff --git a/cleave/song_markers.py b/cleave/song_markers.py index 86b6596..825158e 100644 --- a/cleave/song_markers.py +++ b/cleave/song_markers.py @@ -3,7 +3,43 @@ from __future__ import annotations import bisect -from typing import Sequence +from dataclasses import dataclass +from typing import Literal, Sequence + +SongMarkerType = Literal["standard", "crescendo", "diminuendo"] + +DEFAULT_SONG_MARKER_TYPE: SongMarkerType = "standard" + +SONG_MARKER_TYPES: tuple[SongMarkerType, ...] = ( + "standard", + "crescendo", + "diminuendo", +) + + +@dataclass(frozen=True) +class SongMarker: + """One project-scoped song marker (time plus structural type).""" + + time: float + marker_type: SongMarkerType = DEFAULT_SONG_MARKER_TYPE + + +def cycle_song_marker_type( + value: SongMarkerType, *, forward: bool +) -> SongMarkerType: + try: + index = SONG_MARKER_TYPES.index(value) + except ValueError: + index = 0 + delta = 1 if forward else -1 + return SONG_MARKER_TYPES[(index + delta) % len(SONG_MARKER_TYPES)] + + +def parse_song_marker_type(raw: object) -> SongMarkerType: + if raw in SONG_MARKER_TYPES: + return raw # type: ignore[return-value] + raise ValueError(f"invalid song marker type: {raw!r}") def nearest_index(times: Sequence[float], t: float) -> int: @@ -24,34 +60,36 @@ def nearest_index(times: Sequence[float], t: float) -> int: def place_marker( - times: Sequence[float], + markers: Sequence[SongMarker], t: float, window: float = 2.0, -) -> tuple[tuple[float, ...], int | None, float | None]: +) -> tuple[tuple[SongMarker, ...], int | None, float | None]: """Insert ``t`` into sorted song markers, or replace within ``window`` seconds. If any existing marker lies within ``window`` of ``t``, the nearest one is - replaced (earlier marker on a tie). Otherwise ``t`` is inserted in sorted - order. + replaced (earlier marker on a tie). The replaced marker keeps its type. + Otherwise ``t`` is inserted as a standard marker in sorted order. - Returns ``(new_times, replaced_index, replaced_time)``. On replace, - ``replaced_index`` is the index of the new marker in ``new_times`` and + Returns ``(new_markers, replaced_index, replaced_time)``. On replace, + ``replaced_index`` is the index of the new marker in ``new_markers`` and ``replaced_time`` is the previous time. On insert, both are ``None``. """ - if not times: - return (float(t),), None, None + t = float(t) + if not markers: + return (SongMarker(t),), None, None + times = [m.time for m in markers] idx = nearest_index(times, t) if abs(times[idx] - t) <= window: - old = float(times[idx]) - updated = [float(x) for x in times] - updated[idx] = float(t) - updated.sort() - new_idx = updated.index(float(t)) + old = float(markers[idx].time) + updated = list(markers) + updated[idx] = SongMarker(t, markers[idx].marker_type) + updated.sort(key=lambda m: m.time) + new_idx = next(i for i, m in enumerate(updated) if m.time == t) return tuple(updated), new_idx, old - updated = [float(x) for x in times] - bisect.insort(updated, float(t)) + updated = list(markers) + bisect.insort(updated, SongMarker(t), key=lambda m: m.time) return tuple(updated), None, None diff --git a/cleave/viz/app.py b/cleave/viz/app.py index 3465dbc..ded2b03 100644 --- a/cleave/viz/app.py +++ b/cleave/viz/app.py @@ -120,7 +120,7 @@ def build_runtime_base( ) -> VisualizerSeed: pcm_bank = load_stem_pcm(project_dir) session = session_from_cfg(cfg, playlists) - session.song_markers.times = list(load_manifest(project_dir).song_markers) + session.song_markers.markers = list(load_manifest(project_dir).song_markers) return VisualizerSeed( project_dir=project_dir, audio_path=audio_path, diff --git a/cleave/viz/config_save.py b/cleave/viz/config_save.py index 14b4ffa..433f922 100644 --- a/cleave/viz/config_save.py +++ b/cleave/viz/config_save.py @@ -56,7 +56,7 @@ def __init__( self._move_mode_signature = move_mode_signature self._saved_signature = self._persisted_signature() - self._saved_song_markers = tuple(session.song_markers.times) + self._saved_song_markers = tuple(session.song_markers.markers) self._pending_exit = False self._quit_after_save = False self._on_commit_save: list[Callable[[], None]] = [] @@ -73,17 +73,17 @@ def active_config_path(self) -> Path | None: def config_dirty(self) -> bool: return ( self._persisted_signature() != self._saved_signature - or tuple(self.session.song_markers.times) != self._saved_song_markers + or tuple(self.session.song_markers.markers) != self._saved_song_markers ) def clear_config_dirty(self) -> None: self._saved_signature = self._persisted_signature() - self._saved_song_markers = tuple(self.session.song_markers.times) + self._saved_song_markers = tuple(self.session.song_markers.markers) def _flush_song_markers(self) -> None: if self._project_dir is None: return - save_song_markers(self._project_dir, self.session.song_markers.times) + save_song_markers(self._project_dir, self.session.song_markers.markers) def _commit_save(self) -> None: """Flush project song markers (when available) and clear dirty baselines.""" diff --git a/cleave/viz/controls.py b/cleave/viz/controls.py index fbed49b..6fce18b 100644 --- a/cleave/viz/controls.py +++ b/cleave/viz/controls.py @@ -1572,18 +1572,20 @@ def drop_song_marker(self) -> None: prior_selected_time: float | None = None if ( markers.selected_index is not None - and 0 <= markers.selected_index < len(markers.times) + and 0 <= markers.selected_index < len(markers.markers) ): - prior_selected_time = markers.times[markers.selected_index] - new_times, replaced_index, replaced_time = place_marker(markers.times, t) - markers.times = list(new_times) + prior_selected_time = markers.markers[markers.selected_index].time + new_markers, replaced_index, replaced_time = place_marker( + markers.markers, t + ) + markers.markers = list(new_markers) markers.expanded = True self.session.timeline.panel_open = True # Never activate the newly placed marker; keep prior selection by time. if prior_selected_time is None: if markers.selected_index is not None and ( markers.selected_index < 0 - or markers.selected_index >= len(markers.times) + or markers.selected_index >= len(markers.markers) ): markers.selected_index = None elif ( @@ -1594,24 +1596,28 @@ def drop_song_marker(self) -> None: markers.selected_index = replaced_index else: try: - markers.selected_index = new_times.index(prior_selected_time) - except ValueError: + markers.selected_index = next( + i + for i, m in enumerate(new_markers) + if m.time == prior_selected_time + ) + except StopIteration: markers.selected_index = None if replaced_index is not None: assert replaced_time is not None self.show_notification( f"Song marker replaced " f"{format_marker_time(replaced_time)} -> " - f"{format_marker_time(new_times[replaced_index])}" + f"{format_marker_time(new_markers[replaced_index].time)}" ) else: self.show_notification(f"Song marker {format_marker_time(t)}") def _delete_song_marker(self, index: int) -> None: markers = self.session.song_markers - if index < 0 or index >= len(markers.times): + if index < 0 or index >= len(markers.markers): return - label = format_marker_time(markers.times[index]) + label = format_marker_time(markers.markers[index].time) self._modal_host.prompt_yes_no( f"Remove song marker {label}?", on_confirm=lambda: self._confirm_delete_song_marker(index), @@ -1619,19 +1625,19 @@ def _delete_song_marker(self, index: int) -> None: def _confirm_delete_song_marker(self, index: int) -> None: markers = self.session.song_markers - if index < 0 or index >= len(markers.times): + if index < 0 or index >= len(markers.markers): return - removed = markers.times.pop(index) - if not markers.times: + removed = markers.markers.pop(index) + if not markers.markers: markers.selected_index = None elif markers.selected_index is None: pass elif markers.selected_index == index: - markers.selected_index = min(index, len(markers.times) - 1) + markers.selected_index = min(index, len(markers.markers) - 1) elif markers.selected_index > index: markers.selected_index -= 1 self.show_notification( - f"Song marker removed {format_marker_time(removed)}" + f"Song marker removed {format_marker_time(removed.time)}" ) if markers.selected_index is not None: self._apply_focus_cursor( diff --git a/cleave/viz/editor_mode_controls.py b/cleave/viz/editor_mode_controls.py index df27068..363eebf 100644 --- a/cleave/viz/editor_mode_controls.py +++ b/cleave/viz/editor_mode_controls.py @@ -247,7 +247,7 @@ def _reload_active_config( panel_open=panel_open, ) if self._project_dir is not None: - self.session.song_markers.times = list( + self.session.song_markers.markers = list( load_manifest(self._project_dir).song_markers ) if self._layer_manager is not None: diff --git a/cleave/viz/row_fields.py b/cleave/viz/row_fields.py index 5e0b095..d82bfb2 100644 --- a/cleave/viz/row_fields.py +++ b/cleave/viz/row_fields.py @@ -32,7 +32,13 @@ ui_fade_display, ) from cleave.extract import stem_control_label, stem_overlay_header -from cleave.song_markers import format_marker_time +from cleave.song_markers import ( + DEFAULT_SONG_MARKER_TYPE, + SongMarker, + cycle_song_marker_type, + format_marker_time, + parse_song_marker_type, +) from cleave.timeline_presets.characters import ( cycle_timeline_preset_kind, timeline_preset_kind_display, @@ -1523,10 +1529,46 @@ def _format_song_markers_count(state: TuningViewState, _desc: RowDescriptor) -> return f"({len(state.render_timeline.song_marker_times)})" +def _song_marker_type_display(marker_type: str) -> str: + if marker_type == DEFAULT_SONG_MARKER_TYPE: + return "-" + return marker_type + + def _format_song_marker_item(state: TuningViewState, desc: RowDescriptor) -> str: assert desc.marker_index is not None + index = desc.marker_index times = state.render_timeline.song_marker_times - return f"[{format_marker_time(times[desc.marker_index])}]" + types = state.render_timeline.song_marker_types + marker_type = ( + types[index] + if 0 <= index < len(types) + else DEFAULT_SONG_MARKER_TYPE + ) + return ( + f"[{format_marker_time(times[index])}] " + f"{_song_marker_type_display(marker_type)}" + ) + + +def _apply_song_marker_type( + controls: TuningControls, + desc: RowDescriptor, + forward: bool, + _ctrl: bool, + _shift: bool, +) -> None: + assert desc.marker_index is not None + markers = controls.session.song_markers + index = desc.marker_index + if index < 0 or index >= len(markers.markers): + return + current = markers.markers[index] + next_type = cycle_song_marker_type( + parse_song_marker_type(current.marker_type), + forward=forward, + ) + markers.markers[index] = SongMarker(current.time, next_type) def _format_transport(_state: TuningViewState, _desc: RowDescriptor) -> str: @@ -2312,6 +2354,7 @@ def _apply_transport( panel_label="", present_style=RowPresentStyle.FULL_LINE, format_value=_format_song_marker_item, + apply_horizontal=_apply_song_marker_type, ), RowKind.PANEL_NOTIFICATION: RowFieldDef( panel_label="", diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index 974d92d..3b21221 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -1327,11 +1327,13 @@ class RowBehavior: help_title="Song marker", help_entries=( ("Enter", "seek to marker"), + ("Left / Right", "cycle marker type"), ("Delete", "confirm remove"), ), help_description=( - "A song marker time. Enter seeks the playhead;", - "Delete asks to remove the marker.", + "A song marker time and type (-, crescendo,", + "diminuendo). Enter seeks the playhead; Left/Right", + "cycles type; Delete asks to remove the marker.", ), ), RowKind.SETTINGS_HEADER: RowBehavior( diff --git a/cleave/viz/session.py b/cleave/viz/session.py index d001937..bc774f9 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -51,6 +51,7 @@ from cleave.extract import StemSource from cleave.preset_playlist import PresetPlaylist, preset_browse_floor from cleave.projectm_health import PresetSkipNotifyTracker, ProjectMLogNotifyTracker +from cleave.song_markers import SongMarker 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 @@ -313,10 +314,19 @@ def default_timeline_runtime() -> TimelineRuntime: class SongMarkerRuntime: """Project-scoped song markers held live; not part of viz YAML.""" - times: list[float] = field(default_factory=list) + markers: list[SongMarker] = field(default_factory=list) selected_index: int | None = None expanded: bool = False + @property + def times(self) -> list[float]: + return [m.time for m in self.markers] + + @times.setter + def times(self, values: list[float]) -> None: + """Replace markers with standard-typed times (tests and simple loaders).""" + self.markers = [SongMarker(float(t)) for t in values] + def default_song_marker_runtime() -> SongMarkerRuntime: return SongMarkerRuntime() diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index 3234adc..e0bf9d6 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -263,6 +263,7 @@ class RenderTimelineBlock: locked: bool = False song_markers_expanded: bool = False song_marker_times: tuple[float, ...] = () + song_marker_types: tuple[str, ...] = () @dataclass @@ -775,6 +776,9 @@ def _build_structure( ), song_markers_expanded=self.session.song_markers.expanded, song_marker_times=tuple(self.session.song_markers.times), + song_marker_types=tuple( + m.marker_type for m in self.session.song_markers.markers + ), ) layout_state = TuningViewState( layer_z_order=layer_z_order, @@ -1023,6 +1027,9 @@ def build( locked=tl.locked, song_markers_expanded=self.session.song_markers.expanded, song_marker_times=tuple(self.session.song_markers.times), + song_marker_types=tuple( + m.marker_type for m in self.session.song_markers.markers + ), ), settings=replace( structure.settings, diff --git a/docs/completed/song-markers.md b/docs/completed/song-markers.md index 7d8315d..43d4e28 100644 --- a/docs/completed/song-markers.md +++ b/docs/completed/song-markers.md @@ -12,7 +12,9 @@ Naming: use **song markers** everywhere in UI, docs, and code identifiers. Reser **Project-scoped, not viz config.** Song markers are definitive information about the song. On disk they live in `project.yaml` (alongside `signals.json`) and persist across many viz YAML snapshots. They are not written into viz YAML and are not lost when saving `unnamed-N.yaml` or switching configs. -**Deferred write (session until Save).** Drop, replace, and delete update in-memory session state only. Markers are uncommitted until the user Saves (same Enter / config-row flow as viz config). A successful save writes the active viz YAML **and** flushes song markers to that project's `project.yaml`. Save-as-new still flushes markers to the same `project.yaml` (project-scoped). Marker edits mark the session dirty (config-row asterisk and quit-unsaved modal) the same way viz edits do. +**Deferred write (session until Save).** Drop, replace, delete, and type changes update in-memory session state only. Markers are uncommitted until the user Saves (same Enter / config-row flow as viz config). A successful save writes the active viz YAML **and** flushes song markers to that project's `project.yaml`. Save-as-new still flushes markers to the same `project.yaml` (project-scoped). Marker edits mark the session dirty (config-row asterisk and quit-unsaved modal) the same way viz edits do. + +On disk, each entry is `{time, type}` under `song-markers`. Bare numeric times still load as `standard`. v1 is **manual only** — user-driven placement with streamlined UX. No auto-suggestion in the first release. @@ -37,12 +39,14 @@ Build UI and editing only. No preset generation or beat-phase logic yet. ### Panel - First expandable child under **Render: Timeline**: header label `song markers (N)` with expand arrow (e.g. `song markers (4)`). -- When expanded: list of marker times in `[mm:ss.ms]` format, then **snap to song markers** as the last row (green action row; no expand arrow). +- When expanded: list of marker rows as `[mm:ss.cc] ` (e.g. `[00:26.02] -`), then **snap to song markers** as the last row (green action row; no expand arrow). +- Marker types: `standard` (shown as `-`), `crescendo`, `diminuendo`. New drops default to `standard`. ### List interaction - Focus on a song-marker list row highlights that row and the matching strip tick. Drop/replace does **not** move focus onto the new marker; if a marker row was already focused, that selection is remapped by time when the list shifts. Do **not** auto-follow the playhead. - **Enter** on a focused song marker seeks the playhead to that time (audition / verify placement). Timeline row arm uses **a**, so **Enter** is free for seek-to-marker. +- **Left** / **Right** cycles the focused marker's type (`standard` -> `crescendo` -> `diminuendo` -> …). - **Delete** prompts a confirm modal, then removes the focused song marker. - No nudge in v1 — delete and re-drop at the playhead is enough, with **Enter** to verify. diff --git a/tests/cleave/test_project.py b/tests/cleave/test_project.py index 349030f..58cc320 100644 --- a/tests/cleave/test_project.py +++ b/tests/cleave/test_project.py @@ -19,6 +19,7 @@ save_song_markers, write_manifest, ) +from cleave.song_markers import SongMarker def test_write_and_load_manifest(tmp_path: Path) -> None: @@ -193,7 +194,7 @@ def test_rewrite_manifest_slug_updates_slug_and_restored_from(tmp_path: Path) -> assert manifest.slug == "new-slug" assert manifest.restored_from == "old-slug" assert manifest.mix_filename == "old-slug.flac" - assert manifest.song_markers == (12.5, 64.0) + assert [m.time for m in manifest.song_markers] == [12.5, 64.0] def test_manifest_round_trip_without_song_markers(tmp_path: Path) -> None: @@ -225,7 +226,11 @@ def test_manifest_round_trip_with_song_markers(tmp_path: Path) -> None: separated_at="2026-06-08T20:15:00+00:00", demucs_model="htdemucs", restored_from="original-slug", - song_markers=(8.25, 64.5, 120.0), + song_markers=( + SongMarker(8.25, "standard"), + SongMarker(64.5, "crescendo"), + SongMarker(120.0, "diminuendo"), + ), ) with (project / PROJECT_FILENAME).open("w", encoding="utf-8") as handle: yaml.safe_dump(manifest.to_dict(), handle, sort_keys=False) @@ -236,11 +241,40 @@ def test_manifest_round_trip_with_song_markers(tmp_path: Path) -> None: with (project / PROJECT_FILENAME).open(encoding="utf-8") as handle: data = yaml.safe_load(handle) assert data["version"] == 1 - assert data["song-markers"] == [8.25, 64.5, 120.0] + assert data["song-markers"] == [ + {"time": 8.25, "type": "standard"}, + {"time": 64.5, "type": "crescendo"}, + {"time": 120.0, "type": "diminuendo"}, + ] assert data["restored-from"] == "original-slug" assert data["ingest"]["demucs_model"] == "htdemucs" +def test_manifest_loads_bare_float_song_markers_as_standard(tmp_path: Path) -> None: + project = tmp_path / "song" + project.mkdir() + with (project / PROJECT_FILENAME).open("w", encoding="utf-8") as handle: + yaml.safe_dump( + { + "version": 1, + "slug": "song", + "mix": {"filename": "song.flac"}, + "ingest": { + "original_path": "/tmp/source.flac", + "separated_at": "2026-06-08T20:15:00+00:00", + "demucs_model": "htdemucs", + }, + "song-markers": [8.25, 64.5], + }, + handle, + sort_keys=False, + ) + + loaded = load_manifest(project) + assert loaded.song_markers == (SongMarker(8.25), SongMarker(64.5)) + + + def test_write_manifest_update_preserves_song_markers_and_restored_from( tmp_path: Path, ) -> None: @@ -277,7 +311,7 @@ def test_write_manifest_update_preserves_song_markers_and_restored_from( assert manifest.original_path == str(new_original.resolve()) assert manifest.separated_at == "2026-07-22T12:00:00+00:00" assert manifest.demucs_model == "htdemucs_ft" - assert manifest.song_markers == (10.0, 42.5) + assert manifest.song_markers == (SongMarker(10.0), SongMarker(42.5)) assert manifest.restored_from == "archived-slug" assert manifest.version == 1 @@ -298,10 +332,16 @@ def test_save_song_markers_preserves_ingest(tmp_path: Path) -> None: ) rewrite_manifest_slug(project, "song", restored_from="archived-slug") - save_song_markers(project, (10.0, 42.5)) + save_song_markers( + project, + (SongMarker(10.0, "crescendo"), SongMarker(42.5, "diminuendo")), + ) manifest = load_manifest(project) - assert manifest.song_markers == (10.0, 42.5) + assert manifest.song_markers == ( + SongMarker(10.0, "crescendo"), + SongMarker(42.5, "diminuendo"), + ) assert manifest.slug == "song" assert manifest.mix_filename == "song.flac" assert manifest.original_path == str(original.resolve()) @@ -318,4 +358,7 @@ def test_save_song_markers_preserves_ingest(tmp_path: Path) -> None: "demucs_model": "htdemucs_ft", } assert data["restored-from"] == "archived-slug" - assert data["song-markers"] == [10.0, 42.5] + assert data["song-markers"] == [ + {"time": 10.0, "type": "crescendo"}, + {"time": 42.5, "type": "diminuendo"}, + ] diff --git a/tests/cleave/test_separate.py b/tests/cleave/test_separate.py index 50633ed..4bfe00e 100644 --- a/tests/cleave/test_separate.py +++ b/tests/cleave/test_separate.py @@ -554,7 +554,8 @@ def fake_run(cmd: list[str], *, check: bool) -> None: run_separate("song", force=True) manifest = load_manifest(project) - assert manifest.song_markers == (12.5, 64.0, 120.0) + assert [m.time for m in manifest.song_markers] == [12.5, 64.0, 120.0] + assert all(m.marker_type == "standard" for m in manifest.song_markers) assert manifest.restored_from == "archived-slug" assert manifest.demucs_model == "htdemucs" assert manifest.mix_filename == "song.flac" diff --git a/tests/cleave/test_song_markers.py b/tests/cleave/test_song_markers.py index a223110..3fce5a4 100644 --- a/tests/cleave/test_song_markers.py +++ b/tests/cleave/test_song_markers.py @@ -4,52 +4,70 @@ import pytest -from cleave.song_markers import format_marker_time, nearest_index, place_marker +from cleave.song_markers import ( + SongMarker, + cycle_song_marker_type, + format_marker_time, + nearest_index, + place_marker, +) def test_place_marker_insert_sorted() -> None: - times, replaced_index, replaced_time = place_marker((10.0, 30.0), 20.0) - assert times == (10.0, 20.0, 30.0) + markers, replaced_index, replaced_time = place_marker( + (SongMarker(10.0), SongMarker(30.0)), 20.0 + ) + assert markers == (SongMarker(10.0), SongMarker(20.0), SongMarker(30.0)) assert replaced_index is None assert replaced_time is None def test_place_marker_insert_empty() -> None: - times, replaced_index, replaced_time = place_marker((), 12.5) - assert times == (12.5,) + markers, replaced_index, replaced_time = place_marker((), 12.5) + assert markers == (SongMarker(12.5),) assert replaced_index is None assert replaced_time is None def test_place_marker_replace_within_2s() -> None: - times, replaced_index, replaced_time = place_marker((10.0, 30.0), 11.5) - assert times == (11.5, 30.0) + markers, replaced_index, replaced_time = place_marker( + (SongMarker(10.0, "crescendo"), SongMarker(30.0)), 11.5 + ) + assert markers == (SongMarker(11.5, "crescendo"), SongMarker(30.0)) assert replaced_index == 0 assert replaced_time == 10.0 def test_place_marker_replace_nearest_of_two() -> None: - times, replaced_index, replaced_time = place_marker((10.0, 12.0), 10.5) - assert times == (10.5, 12.0) + markers, replaced_index, replaced_time = place_marker( + (SongMarker(10.0), SongMarker(12.0)), 10.5 + ) + assert markers == (SongMarker(10.5), SongMarker(12.0)) assert replaced_index == 0 assert replaced_time == 10.0 - times, replaced_index, replaced_time = place_marker((10.0, 12.0), 11.5) - assert times == (10.0, 11.5) + markers, replaced_index, replaced_time = place_marker( + (SongMarker(10.0), SongMarker(12.0, "diminuendo")), 11.5 + ) + assert markers == (SongMarker(10.0), SongMarker(11.5, "diminuendo")) assert replaced_index == 1 assert replaced_time == 12.0 def test_place_marker_outside_window_inserts() -> None: - times, replaced_index, replaced_time = place_marker((10.0, 20.0), 13.0) - assert times == (10.0, 13.0, 20.0) + markers, replaced_index, replaced_time = place_marker( + (SongMarker(10.0), SongMarker(20.0)), 13.0 + ) + assert markers == (SongMarker(10.0), SongMarker(13.0), SongMarker(20.0)) assert replaced_index is None assert replaced_time is None def test_place_marker_window_boundary_replaces() -> None: - times, replaced_index, replaced_time = place_marker((10.0,), 12.0) - assert times == (12.0,) + markers, replaced_index, replaced_time = place_marker( + (SongMarker(10.0),), 12.0 + ) + assert markers == (SongMarker(12.0),) assert replaced_index == 0 assert replaced_time == 10.0 @@ -71,3 +89,11 @@ def test_format_marker_time() -> None: assert format_marker_time(65.129) == "01:05.13" assert format_marker_time(125.456) == "02:05.46" assert format_marker_time(-1.0) == "00:00.00" + + +def test_cycle_song_marker_type() -> None: + assert cycle_song_marker_type("standard", forward=True) == "crescendo" + assert cycle_song_marker_type("crescendo", forward=True) == "diminuendo" + assert cycle_song_marker_type("diminuendo", forward=True) == "standard" + assert cycle_song_marker_type("standard", forward=False) == "diminuendo" + assert cycle_song_marker_type("crescendo", forward=False) == "standard" diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index b89117a..0c77073 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -26,6 +26,7 @@ ) from cleave.timeline import SlotCue, TimelineLane, canonicalize, lane_level_at from cleave.project import load_manifest, write_manifest +from cleave.song_markers import SongMarker from cleave.viz.focus_nav import MainFocus, TimelineFocus from cleave.viz.key_repeat import mod_shift from cleave.viz.playback import format_mmss @@ -5235,6 +5236,7 @@ def test_drop_song_marker_does_not_select_or_steal_focus() -> None: controls.drop_song_marker() markers = controls.session.song_markers assert markers.times == [15.0] + assert markers.markers == [SongMarker(15.0, "standard")] assert markers.selected_index is None assert markers.expanded is True assert controls.session.timeline.panel_open is True @@ -5243,6 +5245,35 @@ def test_drop_song_marker_does_not_select_or_steal_focus() -> None: assert RowDescriptor(RowKind.SONG_MARKER_ITEM, marker_index=0) in view.layout.rows +def test_song_marker_left_right_cycles_type() -> None: + controls = _make_controls(("layer_1",)) + controls.session.timeline.panel_open = True + markers = controls.session.song_markers + markers.markers = [SongMarker(26.02, "standard")] + markers.expanded = True + controls.focus_descriptor = RowDescriptor( + RowKind.SONG_MARKER_ITEM, marker_index=0 + ) + controls.clear_config_dirty() + + view = controls.build_view_state(paused=False) + row = view.layout.find_descriptor(controls.focus_descriptor) + assert "[00:26.02] -" in _row_text(view, row) + + assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True + assert markers.markers[0].marker_type == "crescendo" + assert controls.config_dirty + view = controls.build_view_state(paused=False) + assert "[00:26.02] crescendo" in _row_text(view, row) + + assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True + assert markers.markers[0].marker_type == "diminuendo" + assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True + assert markers.markers[0].marker_type == "standard" + assert controls.handle_keydown(_keydown(pygame.K_LEFT)) is True + assert markers.markers[0].marker_type == "diminuendo" + + def test_drop_song_marker_insert_preserves_prior_selection_by_time() -> None: """Insert before the selected marker remaps selected_index by prior time.""" controls = _make_controls(("layer_1",)) @@ -5372,6 +5403,10 @@ def test_up_down_syncs_song_marker_selected_index() -> None: assert markers.selected_index == 0 +def _song_markers(*times: float) -> tuple[SongMarker, ...]: + return tuple(SongMarker(t) for t in times) + + def _project_with_markers(tmp_path: Path, markers: tuple[float, ...] = ()) -> Path: project = tmp_path / "song" project.mkdir() @@ -5383,7 +5418,7 @@ def _project_with_markers(tmp_path: Path, markers: tuple[float, ...] = ()) -> Pa demucs_model="htdemucs", song_markers=markers, ) - assert load_manifest(project).song_markers == markers + assert load_manifest(project).song_markers == _song_markers(*markers) return project @@ -5395,7 +5430,7 @@ def test_drop_song_marker_does_not_write_project_yaml(tmp_path: Path) -> None: controls.playback.player.seek(25.0) controls.drop_song_marker() assert controls.session.song_markers.times == [10.0, 25.0] - assert load_manifest(project).song_markers == (10.0,) + assert load_manifest(project).song_markers == _song_markers(10.0) assert controls.config_dirty @@ -5414,7 +5449,7 @@ def test_delete_song_marker_does_not_write_project_yaml(tmp_path: Path) -> None: assert controls.handle_keydown(_keydown(pygame.K_DELETE)) is True controls.handle_modal_keydown(_keydown(pygame.K_RETURN)) assert markers.times == [5.0, 25.0] - assert load_manifest(project).song_markers == (5.0, 15.0, 25.0) + assert load_manifest(project).song_markers == _song_markers(5.0, 15.0, 25.0) assert controls.config_dirty @@ -5435,7 +5470,7 @@ def test_marker_only_edit_marks_dirty_and_save_clears(tmp_path: Path) -> None: _choose_save_as_new(controls) assert not controls.config_dirty - assert load_manifest(project).song_markers == (12.5,) + assert load_manifest(project).song_markers == _song_markers(12.5) def test_overwrite_save_flushes_song_markers(tmp_path: Path) -> None: @@ -5457,7 +5492,7 @@ def test_overwrite_save_flushes_song_markers(tmp_path: Path) -> None: controls.playback.player.seek(40.0) controls.drop_song_marker() assert controls.config_dirty - assert load_manifest(project).song_markers == (8.0,) + assert load_manifest(project).song_markers == _song_markers(8.0) view = controls.build_view_state(paused=False) controls.focus_descriptor = _desc(view, _config_header_row(view)) @@ -5465,7 +5500,7 @@ def test_overwrite_save_flushes_song_markers(tmp_path: Path) -> None: controls.handle_keydown(_keydown(pygame.K_RETURN)) assert writes == [launch] - assert load_manifest(project).song_markers == (8.0, 40.0) + assert load_manifest(project).song_markers == _song_markers(8.0, 40.0) assert not controls.config_dirty @@ -5486,7 +5521,7 @@ def test_quit_discard_leaves_project_markers_unchanged(tmp_path: Path) -> None: controls.handle_modal_keydown(_keydown(pygame.K_RIGHT)) controls.handle_modal_keydown(_keydown(pygame.K_RETURN)) assert controls.consume_pending_exit() is True - assert load_manifest(project).song_markers == (10.0, 20.0) + assert load_manifest(project).song_markers == _song_markers(10.0, 20.0) def test_save_as_new_flushes_markers_to_same_project_yaml(tmp_path: Path) -> None: @@ -5501,7 +5536,7 @@ def test_save_as_new_flushes_markers_to_same_project_yaml(tmp_path: Path) -> Non controls.focus_descriptor = _desc(view, _config_header_row(view)) _choose_save_as_new(controls) - assert load_manifest(project).song_markers == (3.0,) + assert load_manifest(project).song_markers == _song_markers(3.0) assert controls._config_save.active_config_path == new_yaml assert not controls.config_dirty From d14eef2b625a56f4529dbd2d19b931902812fc0b Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Thu, 6 Aug 2026 23:25:08 +0100 Subject: [PATCH 2/5] Add crescendo song marker type and mofiy the preset generator to use it --- .cursor/rules/live-tuning-ui.mdc | 4 +- README.md | 22 ++- cleave/config.py | 4 +- cleave/config_schema.py | 15 -- cleave/timeline_presets/crescendo.py | 164 +++++++++++------- cleave/viz/row_fields.py | 32 ---- cleave/viz/row_sections.py | 2 - cleave/viz/row_semantics.py | 24 +-- cleave/viz/session.py | 5 - cleave/viz/timeline_preset_controls.py | 55 ++---- cleave/viz/tuning_view_state.py | 4 - docs/completed/song-markers.md | 3 +- docs/improved-timeline-presets.md | 2 +- tests/cleave/test_config.py | 10 +- tests/cleave/test_config_snapshot.py | 4 - tests/cleave/test_timeline_conductor.py | 35 ++-- tests/cleave/test_timeline_presets.py | 54 ++++-- tests/cleave/viz/test_controls.py | 24 ++- tests/cleave/viz/test_help_overlay.py | 2 +- tests/cleave/viz/test_row_fields.py | 2 +- tests/cleave/viz/test_row_semantics.py | 1 - tests/cleave/viz/test_text_fit.py | 2 - tests/cleave/viz/test_view_state_structure.py | 30 ++-- 23 files changed, 242 insertions(+), 258 deletions(-) diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index c237812..a9a82e0 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, re-populate preset lists, conductor); the green **apply timeline preset** action confirms with Yes/Cancel and lists the staged choices as `labeled_lines` in the modal ([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, density, re-populate preset lists, conductor); the green **apply timeline preset** action confirms with Yes/Cancel and lists the staged choices as `labeled_lines` in the modal ([cleave/viz/timeline_preset_controls.py](cleave/viz/timeline_preset_controls.py)). Crescendos come from song markers typed `crescendo`, not a staged row. ## Track rows section @@ -52,7 +52,7 @@ Below overlays, **Render: POST FX** (`RowKind.RENDER_POST_FX_HEADER`) is always **Section lock** (overlays, post-FX, timeline, and layer tracks share one mechanism in [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py)): persisted `locked` on `RenderOverlaysConfig` / `RenderPostFxConfig` / `TimelineConfig` and the matching runtimes. **l** on `RENDER_OVERLAYS_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 / re-populate / 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 when expanded), beat / bar grid (expandable; placement snap, bar grid, bar phase), snap cues (expandable; **snap to beats**, **snap to bars**, then **snap to song markers**), timeline cuts (expandable; hard cuts and soft cuts each with enabled/disabled and fade in/out duration when enabled, then **apply soft cuts to cues** and **apply hard cuts to cues**), timeline preset (expandable; character, crescendo, density, re-populate preset lists, 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 beats** / **snap to bars** are green action rows under snap cues (Enter opens Yes/Cancel). **snap to song markers** is a green action row under snap cues (Enter opens proximity then layer-scope choice modals). **apply soft cuts to cues** / **apply hard cuts to cues** are green action rows under timeline cuts (Enter opens a scope choice modal). 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 / density / re-populate / 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 when expanded; markers typed `crescendo` drive crescendo builds on apply), beat / bar grid (expandable; placement snap, bar grid, bar phase), snap cues (expandable; **snap to beats**, **snap to bars**, then **snap to song markers**), timeline cuts (expandable; hard cuts and soft cuts each with enabled/disabled and fade in/out duration when enabled, then **apply soft cuts to cues** and **apply hard cuts to cues**), timeline preset (expandable; character, density, re-populate preset lists, 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 beats** / **snap to bars** are green action rows under snap cues (Enter opens Yes/Cancel). **snap to song markers** is a green action row under snap cues (Enter opens proximity then layer-scope choice modals). **apply soft cuts to cues** / **apply hard cuts to cues** are green action rows under timeline cuts (Enter opens a scope choice modal). 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/README.md b/README.md index 1e2129f..fbf89e9 100644 --- a/README.md +++ b/README.md @@ -147,13 +147,31 @@ When enabled, the standard layer visibility controls are disabled, the timeline * These can be used as snap points and are also used by the `timeline preset` as anchor points for generation. ##### `Beat / Bar Grid` -* Powered by Beat This! which uses AI to try to detect beats and bars in the song. + +* Powered by Beat This! an AI beat detection library. * By default it will use the full-mix stem for analysis. * Choose a different stem with the `--beat-detection-stem` switch. * You can snap cues to the grid either on record or after record. ##### `Timeline Presets` -* TODO: Document + +* This makes it easy to generate a complete layered visualisation of a song. +* For best results you should curate presets into Roles. +* If song markers are available, they will be used to drive the preset generation (do this for best results). +* There are multiple song marker types: + * `-` standard song marker, no special behaviour. + * `crescendo` - used to denote where the visual intensity should build t, before crashing off to low intensity. + * `dimininuendo` - used to denote where the visual intensity should reduce to, before returning to normal intensity. + * `begin` - used to denote where crescendo or dimininuendo ramp should begin. + * `sustain` - used to denote where crescendo or dimininuendo should hit maximum / minimum intensity. + +``` +CRESCENDO: thin ↗↗↗ FULL ──── FULL ──── ► solo + begin sustain crescendo + +DIMINUENDO: FULL ↘↘↘ thin ──── thin ──── ► restore + begin sustain diminuendo +``` ### Project Directory diff --git a/cleave/config.py b/cleave/config.py index 793a59c..f299a6b 100644 --- a/cleave/config.py +++ b/cleave/config.py @@ -114,7 +114,6 @@ 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.cue_snap import ( DEFAULT_TIMELINE_PRESET_CUE_SNAP, TimelinePresetCueSnap, @@ -300,10 +299,9 @@ class TimelineCutsConfig: @dataclass(frozen=True) class TimelinePresetConfig: - """Staged character / crescendo / density / post-process / conductor for Apply.""" + """Staged character / density / post-process / conductor for Apply.""" character: str = DEFAULT_TIMELINE_PRESET_KIND - crescendo: CrescendoTarget | None = None density: TimelinePresetDensity = DEFAULT_TIMELINE_PRESET_DENSITY cue_snap: TimelinePresetCueSnap = DEFAULT_TIMELINE_PRESET_CUE_SNAP song_marker_snap: TimelinePresetSongMarkerSnap = ( diff --git a/cleave/config_schema.py b/cleave/config_schema.py index 945a3f7..0b51b09 100644 --- a/cleave/config_schema.py +++ b/cleave/config_schema.py @@ -21,7 +21,6 @@ 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.cue_snap import ( DEFAULT_TIMELINE_PRESET_CUE_SNAP, TIMELINE_PRESET_CUE_SNAP_OPTIONS, @@ -457,15 +456,6 @@ def parse_timeline_preset_character(raw: Any, label: str) -> str: return value -def parse_timeline_preset_crescendo(raw: Any, label: str) -> CrescendoTarget | None: - if raw is None: - return None - value = str(raw) - if value not in ("last", "penultimate"): - raise ValueError(f"{label} must be one of: last, penultimate, or null") - return value # type: ignore[return-value] - - def parse_timeline_preset_density(raw: Any, label: str) -> TimelinePresetDensity: value = str(raw) if value not in TIMELINE_PRESET_DENSITY_OPTIONS: @@ -2333,10 +2323,6 @@ def _parse_timeline_preset(raw: Any) -> Any: preset_map.get("character", DEFAULT_TIMELINE_PRESET_KIND), "timeline.preset.character", ), - crescendo=parse_timeline_preset_crescendo( - preset_map.get("crescendo"), - "timeline.preset.crescendo", - ), density=parse_timeline_preset_density( preset_map.get("density", DEFAULT_TIMELINE_PRESET_DENSITY), "timeline.preset.density", @@ -2550,7 +2536,6 @@ def persist_timeline(ctx: PersistCtx) -> dict[str, Any]: }, "preset": { "character": runtime.timeline_preset_kind, - "crescendo": runtime.timeline_preset_crescendo, "density": runtime.timeline_preset_density, "cue_snap": runtime.timeline_preset_cue_snap, "song_marker_snap": runtime.timeline_preset_song_marker_snap, diff --git a/cleave/timeline_presets/crescendo.py b/cleave/timeline_presets/crescendo.py index e90b863..f66072a 100644 --- a/cleave/timeline_presets/crescendo.py +++ b/cleave/timeline_presets/crescendo.py @@ -1,14 +1,18 @@ -"""Optional song-marker crescendo overlay for timeline presets.""" +"""Song-marker crescendo overlay for timeline presets. + +Builds crescendos to each in-range song marker typed ``crescendo``. +``diminuendo`` markers are ignored. +""" from __future__ import annotations import random from collections.abc import Mapping, Sequence 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.song_markers import SongMarker from cleave.timeline import ( LEVEL_EPS, LEVEL_QUANTUM, @@ -23,48 +27,17 @@ from cleave.timeline_presets.chords import MAX_CONCURRENT_LAYERS from cleave.timeline_presets.emit import cues_from_states -CrescendoTarget = Literal["last", "penultimate"] - -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, - "last", - "penultimate", -) - -_CRESCENDO_DISPLAY: dict[CrescendoTarget | None, str] = { - None: "no", - "last": "last song marker", - "penultimate": "penultimate song marker", -} - 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]) - - -def cycle_timeline_preset_crescendo( - value: CrescendoTarget | None, *, forward: bool -) -> CrescendoTarget | None: - options = TIMELINE_PRESET_CRESCENDO_OPTIONS - try: - index = options.index(value) - except ValueError: - index = 0 - delta = 1 if forward else -1 - return options[(index + delta) % len(options)] - - @dataclass(frozen=True) class CrescendoWindow: """Times for ramp start, full stack, and drop-to-solo.""" @@ -75,35 +48,74 @@ class CrescendoWindow: def normalize_crescendo_markers( - song_marker_times: Sequence[float], + song_markers: Sequence[SongMarker], duration_sec: float, -) -> list[float]: - """Sorted unique markers strictly inside ``(0, duration_sec)``.""" - return sorted( - { - float(t) - for t in song_marker_times - if 0.0 < float(t) < duration_sec - } - ) +) -> list[SongMarker]: + """Sorted unique markers strictly inside ``(0, duration_sec)``. + + On duplicate times, the first occurrence wins. + """ + by_time: dict[float, SongMarker] = {} + for marker in song_markers: + t = float(marker.time) + if 0.0 < t < duration_sec and t not in by_time: + by_time[t] = SongMarker(t, marker.marker_type) + return [by_time[t] for t in sorted(by_time)] + + +def resolve_crescendo_windows( + song_markers: Sequence[SongMarker], + duration_sec: float, +) -> list[CrescendoWindow]: + """Resolve one window per crescendo-typed marker that has a prior marker.""" + markers = normalize_crescendo_markers(song_markers, duration_sec) + if duration_sec <= 0.0 or not markers: + return [] + windows: list[CrescendoWindow] = [] + for selected_idx, marker in enumerate(markers): + if marker.marker_type != "crescendo": + continue + window = _window_at_index(markers, selected_idx, duration_sec) + if window is not None: + windows.append(window) + return windows def resolve_crescendo_window( - song_marker_times: Sequence[float], + song_markers: Sequence[SongMarker], duration_sec: float, - target: CrescendoTarget, + *, + peak_time: float | None = None, ) -> CrescendoWindow | None: - """Resolve crescendo times for ``last`` / ``penultimate`` marker targets.""" - markers = normalize_crescendo_markers(song_marker_times, duration_sec) - if len(markers) < CRESCENDO_MIN_MARKERS or duration_sec <= 0.0: + """Resolve a single crescendo window. + + When ``peak_time`` is set, resolve the window for that crescendo marker. + Otherwise return the earliest resolved crescendo window, or ``None``. + """ + if peak_time is not None: + markers = normalize_crescendo_markers(song_markers, duration_sec) + peak = float(peak_time) + for selected_idx, marker in enumerate(markers): + if marker.marker_type != "crescendo": + continue + if abs(marker.time - peak) < 1e-9: + return _window_at_index(markers, selected_idx, duration_sec) return None - selected_idx = len(markers) - 1 if target == "last" else len(markers) - 2 - if selected_idx < 1: + windows = resolve_crescendo_windows(song_markers, duration_sec) + return windows[0] if windows else None + + +def _window_at_index( + markers: Sequence[SongMarker], + selected_idx: int, + duration_sec: float, +) -> CrescendoWindow | None: + if selected_idx < 1 or duration_sec <= 0.0: return None - t_peak_end = markers[selected_idx] - t_full = markers[selected_idx - 1] + t_peak_end = float(markers[selected_idx].time) + t_full = float(markers[selected_idx - 1].time) if selected_idx >= 2: - t_start = markers[selected_idx - 2] + t_start = float(markers[selected_idx - 2].time) else: t_start = max(0.0, t_peak_end - _FALLBACK_START_FRACTION * duration_sec) if t_start > t_full: @@ -119,26 +131,48 @@ def apply_crescendo( *, duration_sec: float, bar_times: Sequence[float], - song_marker_times: Sequence[float], - target: CrescendoTarget, + song_markers: Sequence[SongMarker], rng: random.Random, ) -> dict[str, TimelineLane]: - """Rewrite ``lanes`` from the crescendo window through song end. + """Rewrite ``lanes`` for each crescendo-typed song marker 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. + Windows are applied earliest-first so a later crescendo overwrites from its + ramp start. When the source lanes carry cast roles (e.g. conductor Apply), + the prefix keeps those held role/blend values and each 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 - window = resolve_crescendo_window(song_marker_times, duration_sec, target) - if window is None: + windows = resolve_crescendo_windows(song_markers, duration_sec) + if not windows: return lanes - prefix = _states_before(lanes, slot_list, window.t_start) + result = lanes + for window in windows: + result = _apply_crescendo_window( + result, + slot_list, + window, + duration_sec=duration_sec, + bar_times=bar_times, + rng=rng, + ) + return result + + +def _apply_crescendo_window( + lanes: dict[str, TimelineLane], + slots: Sequence[str], + window: CrescendoWindow, + *, + duration_sec: float, + bar_times: Sequence[float], + rng: random.Random, +) -> dict[str, TimelineLane]: + prefix = _states_before(lanes, slots, window.t_start) crescendo = _crescendo_states( - slot_list, + slots, window, duration_sec=duration_sec, bar_times=bar_times, @@ -150,10 +184,10 @@ def apply_crescendo( casts = None if _lanes_have_roles(lanes): casts = _casts_for_merged( - lanes, slot_list, merged, t_start=window.t_start + lanes, slots, merged, t_start=window.t_start ) return cues_from_states( - slot_list, + list(slots), merged, casts, ) diff --git a/cleave/viz/row_fields.py b/cleave/viz/row_fields.py index d82bfb2..a616a19 100644 --- a/cleave/viz/row_fields.py +++ b/cleave/viz/row_fields.py @@ -51,10 +51,6 @@ cycle_timeline_preset_repopulate, timeline_preset_repopulate_display, ) -from cleave.timeline_presets.crescendo import ( - cycle_timeline_preset_crescendo, - timeline_preset_crescendo_display, -) from cleave.timeline_presets.cue_snap import ( cycle_timeline_preset_cue_snap, timeline_preset_cue_snap_display, @@ -243,28 +239,6 @@ def _apply_timeline_preset_character( ) -def _format_timeline_preset_crescendo( - state: TuningViewState, _desc: RowDescriptor -) -> str: - return timeline_preset_crescendo_display( - state.render_timeline.timeline_preset_crescendo - ) - - -def _apply_timeline_preset_crescendo( - controls: TuningControls, - _desc: RowDescriptor, - forward: bool, - _ctrl: bool, - _shift: bool, -) -> None: - tl = controls.session.timeline - tl.timeline_preset_crescendo = cycle_timeline_preset_crescendo( - tl.timeline_preset_crescendo, - forward=forward, - ) - - def _format_timeline_preset_density( state: TuningViewState, _desc: RowDescriptor ) -> str: @@ -2169,12 +2143,6 @@ def _apply_transport( format_value=_format_timeline_preset_character, apply_horizontal=_apply_timeline_preset_character, ), - RowKind.TIMELINE_PRESET_CRESCENDO: RowFieldDef( - panel_label="crescendo", - present_style=RowPresentStyle.LABELED_VALUE, - format_value=_format_timeline_preset_crescendo, - apply_horizontal=_apply_timeline_preset_crescendo, - ), RowKind.TIMELINE_PRESET_DENSITY: RowFieldDef( panel_label="density", present_style=RowPresentStyle.LABELED_VALUE, diff --git a/cleave/viz/row_sections.py b/cleave/viz/row_sections.py index daed00d..b18c495 100644 --- a/cleave/viz/row_sections.py +++ b/cleave/viz/row_sections.py @@ -884,7 +884,6 @@ def _timeline_soft_cut_fades_enabled( toggle=_toggle_timeline_presets, children=( 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_CUE_SNAP), SectionNode(leaf_kind=RowKind.TIMELINE_PRESET_SONG_MARKER_SNAP), @@ -1065,7 +1064,6 @@ def kinds_in_expand_section(section: ExpandSectionDef) -> frozenset[RowKind]: RowKind.SONG_MARKER_ITEM, RowKind.TIMELINE_PRESETS_HEADER, RowKind.TIMELINE_PRESET_CHARACTER, - RowKind.TIMELINE_PRESET_CRESCENDO, RowKind.TIMELINE_PRESET_DENSITY, RowKind.TIMELINE_PRESET_CUE_SNAP, RowKind.TIMELINE_PRESET_SONG_MARKER_SNAP, diff --git a/cleave/viz/row_semantics.py b/cleave/viz/row_semantics.py index 3b21221..1b26362 100644 --- a/cleave/viz/row_semantics.py +++ b/cleave/viz/row_semantics.py @@ -102,7 +102,6 @@ class RowKind(Enum): RENDER_TIMELINE_HEADER = auto() TIMELINE_PRESETS_HEADER = auto() TIMELINE_PRESET_CHARACTER = auto() - TIMELINE_PRESET_CRESCENDO = auto() TIMELINE_PRESET_DENSITY = auto() TIMELINE_PRESET_CUE_SNAP = auto() TIMELINE_PRESET_SONG_MARKER_SNAP = auto() @@ -924,7 +923,7 @@ class RowBehavior: is_sub_header=True, help_title="Timeline preset", help_description=( - "Stage character, crescendo, density, re-populate, and conductor, then", + "Stage character, density, re-populate, and conductor, then", "apply a randomly generated timeline preset. Overwrites the current timeline.", ), ), @@ -936,21 +935,10 @@ class RowBehavior: help_entries=(("Left/Right", "cycle character"),), help_description=( "Procedural timeline character used when applying a preset.", - "If song markers are present, they are favoured for cue placement.", + "Song markers favour cue placement; crescendo types build crescendos.", ), help_mode_entries=TIMELINE_PRESET_HELP_ENTRIES, ), - RowKind.TIMELINE_PRESET_CRESCENDO: RowBehavior( - RowAffordance.VALUE_STEP, - navigable=True, - blocked_by_section_lock=True, - help_title="Crescendo", - help_entries=(("Left/Right", "cycle crescendo target"),), - help_description=( - "Optional build to a crescendo at a song marker.", - "Requires three or more song markers; otherwise apply skips crescendo.", - ), - ), RowKind.TIMELINE_PRESET_DENSITY: RowBehavior( RowAffordance.VALUE_STEP, navigable=True, @@ -1025,8 +1013,8 @@ class RowBehavior: help_title="Apply timeline preset", help_entries=(("Enter", "apply timeline preset"),), help_description=( - "Apply the staged character, crescendo, density, snaps, cuts,", - "re-populate, and conductor. This overwrites the current timeline.", + "Apply the staged character, density, snaps, cuts, re-populate,", + "and conductor. Crescendo song markers build crescendos. Overwrites the timeline.", ), ), RowKind.TIMELINE_VISUAL_LIMITER_HEADER: RowBehavior( @@ -1332,8 +1320,8 @@ class RowBehavior: ), help_description=( "A song marker time and type (-, crescendo,", - "diminuendo). Enter seeks the playhead; Left/Right", - "cycles type; Delete asks to remove the marker.", + "diminuendo). Crescendo markers build crescendos on", + "timeline preset apply. Enter seeks; Left/Right cycles type.", ), ), RowKind.SETTINGS_HEADER: RowBehavior( diff --git a/cleave/viz/session.py b/cleave/viz/session.py index bc774f9..79a4877 100644 --- a/cleave/viz/session.py +++ b/cleave/viz/session.py @@ -56,7 +56,6 @@ 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.cue_snap import ( DEFAULT_TIMELINE_PRESET_CUE_SNAP, TimelinePresetCueSnap, @@ -284,7 +283,6 @@ class TimelineRuntime: cuts_expanded: bool = False timeline_presets_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 timeline_preset_cue_snap: TimelinePresetCueSnap = DEFAULT_TIMELINE_PRESET_CUE_SNAP timeline_preset_song_marker_snap: TimelinePresetSongMarkerSnap = ( @@ -529,7 +527,6 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime: preset_kind = ( DEFAULT_TIMELINE_PRESET_KIND if preset is None else preset.character ) - preset_crescendo = None if preset is None else preset.crescendo preset_density = ( DEFAULT_TIMELINE_PRESET_DENSITY if preset is None else preset.density ) @@ -568,7 +565,6 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime: lanes=lanes, placement_snap=placement_snap, timeline_preset_kind=preset_kind, - timeline_preset_crescendo=preset_crescendo, timeline_preset_density=preset_density, timeline_preset_cue_snap=preset_cue_snap, timeline_preset_song_marker_snap=preset_song_marker_snap, @@ -583,7 +579,6 @@ def timeline_runtime_from_cfg(cfg: CleaveConfig) -> TimelineRuntime: lanes=lanes, placement_snap=placement_snap, timeline_preset_kind=preset_kind, - timeline_preset_crescendo=preset_crescendo, timeline_preset_density=preset_density, timeline_preset_cue_snap=preset_cue_snap, timeline_preset_song_marker_snap=preset_song_marker_snap, diff --git a/cleave/viz/timeline_preset_controls.py b/cleave/viz/timeline_preset_controls.py index dd69268..564c62c 100644 --- a/cleave/viz/timeline_preset_controls.py +++ b/cleave/viz/timeline_preset_controls.py @@ -22,13 +22,7 @@ ) 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, - apply_crescendo, - normalize_crescendo_markers, - timeline_preset_crescendo_display, -) +from cleave.timeline_presets.crescendo import apply_crescendo from cleave.timeline_presets.cue_snap import timeline_preset_cue_snap_display from cleave.timeline_presets.density import ( density_bias_for, @@ -98,10 +92,6 @@ def _apply_prompt_labeled_lines(self) -> tuple[ModalLabeledLine, ...]: ModalLabeledLine( "character", timeline_preset_kind_display(tl.timeline_preset_kind) ), - ModalLabeledLine( - "crescendo", - timeline_preset_crescendo_display(tl.timeline_preset_crescendo), - ), ModalLabeledLine( "density", timeline_preset_density_display(tl.timeline_preset_density) ), @@ -141,24 +131,12 @@ def prompt_reset(self) -> None: self._modal.prompt_choice(_RESET_PROMPT_MESSAGE, options, on_dismiss=dismiss) def _confirm_apply(self, duration_sec: float) -> None: - tl = self.session.timeline - kind = tl.timeline_preset_kind - crescendo = tl.timeline_preset_crescendo - if crescendo is not None: - markers = normalize_crescendo_markers( - self.session.song_markers.times, - duration_sec, - ) - if len(markers) < CRESCENDO_MIN_MARKERS: - crescendo = None - self._apply(kind, duration_sec, crescendo=crescendo) + self._apply(self.session.timeline.timeline_preset_kind, duration_sec) def _apply( self, kind: str, duration_sec: float, - *, - crescendo: CrescendoTarget | None, ) -> None: if not self._bar_times: self._notify("No bars available; re-run separate") @@ -179,11 +157,12 @@ def _apply( tl = self.session.timeline tl.enabled = True slots = list(self.session.layer_z_order) - markers = list(self.session.song_markers.times) + markers = list(self.session.song_markers.markers) + marker_times = [m.time for m in markers] rng = random.Random() builder_kwargs: dict = { "bar_times": grid, - "song_marker_times": markers, + "song_marker_times": marker_times, "density_bias": density_bias_for(tl.timeline_preset_density), } conductor_skipped = False @@ -201,23 +180,23 @@ def _apply( rng, **builder_kwargs, ) - if crescendo is not None: - built = apply_crescendo( - built, - slots, - duration_sec=duration_sec, - bar_times=grid, - song_marker_times=markers, - target=crescendo, - rng=rng, - ) + after_crescendo = apply_crescendo( + built, + slots, + duration_sec=duration_sec, + bar_times=grid, + song_markers=markers, + rng=rng, + ) + if after_crescendo is not built: + built = after_crescendo message = f"{message} (crescendo)" built = { slot: copy_lane(built.get(slot, empty_lane())) for slot in slots } self._apply_cue_snap(built, grid) - self._apply_song_marker_snap(built, markers, slots) - self._apply_timeline_cuts(built, markers, tl.timeline_preset_timeline_cuts) + self._apply_song_marker_snap(built, marker_times, slots) + self._apply_timeline_cuts(built, marker_times, tl.timeline_preset_timeline_cuts) for slot in slots: tl.lanes[slot] = built[slot] if ( diff --git a/cleave/viz/tuning_view_state.py b/cleave/viz/tuning_view_state.py index e0bf9d6..924b1de 100644 --- a/cleave/viz/tuning_view_state.py +++ b/cleave/viz/tuning_view_state.py @@ -50,7 +50,6 @@ ) 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.cue_snap import ( DEFAULT_TIMELINE_PRESET_CUE_SNAP, TimelinePresetCueSnap, @@ -240,7 +239,6 @@ class RenderTimelineBlock: cuts_expanded: bool = False timeline_presets_expanded: bool = False timeline_preset_kind: str = "breathing" - timeline_preset_crescendo: CrescendoTarget | None = None timeline_preset_density: TimelinePresetDensity = DEFAULT_TIMELINE_PRESET_DENSITY timeline_preset_cue_snap: TimelinePresetCueSnap = DEFAULT_TIMELINE_PRESET_CUE_SNAP timeline_preset_song_marker_snap: TimelinePresetSongMarkerSnap = ( @@ -749,7 +747,6 @@ def _build_structure( cuts_expanded=tl.cuts_expanded, timeline_presets_expanded=tl.timeline_presets_expanded, timeline_preset_kind=tl.timeline_preset_kind, - timeline_preset_crescendo=tl.timeline_preset_crescendo, timeline_preset_density=tl.timeline_preset_density, timeline_preset_cue_snap=tl.timeline_preset_cue_snap, timeline_preset_song_marker_snap=tl.timeline_preset_song_marker_snap, @@ -999,7 +996,6 @@ def build( cuts_expanded=tl.cuts_expanded, timeline_presets_expanded=tl.timeline_presets_expanded, timeline_preset_kind=tl.timeline_preset_kind, - timeline_preset_crescendo=tl.timeline_preset_crescendo, timeline_preset_density=tl.timeline_preset_density, timeline_preset_cue_snap=tl.timeline_preset_cue_snap, timeline_preset_song_marker_snap=tl.timeline_preset_song_marker_snap, diff --git a/docs/completed/song-markers.md b/docs/completed/song-markers.md index 43d4e28..325ed23 100644 --- a/docs/completed/song-markers.md +++ b/docs/completed/song-markers.md @@ -106,12 +106,13 @@ When song markers exist, applying a timeline preset (Breathing / Dialogue / Arc 1. **Section-driven phrases.** Markers are hard section walls. Phrases never cross a marker; each marker starts a new phrase (then the usual 4–8 bar / minimum-duration partitioning fills each section). Empty or out-of-range markers leave bar-only partitioning unchanged. 2. **Soft latch.** Planned motif switches still prefer the bar grid. If an unclaimed marker lies within **5.0s** of a planned switch and min switch gaps still hold, that switch moves onto the marker (exclusive: each marker claimed at most once). Soft latch does **not** invent extra transitions solely to hit a marker. +3. **Crescendo markers.** Each in-range marker typed `crescendo` that has at least one prior marker gets a crescendo post-pass (ramp from earlier markers, full stack at the previous marker, solo from the crescendo marker through song end, or until a later crescendo overwrites). `diminuendo` is ignored for generation. No new panel knobs. Phase 2 **snap to song markers** remains a separate manual polish step and is not run automatically after apply. ### Phase 3 deliverable -Preset apply reads `session.song_markers.times` and threads them into `compose_timeline`. Tests cover section walls, exclusive soft latch, and gap veto. +Preset apply reads `session.song_markers` (times for section walls / soft latch; types for crescendo) and threads them into `compose_timeline` plus `apply_crescendo`. Tests cover section walls, exclusive soft latch, gap veto, and typed crescendo windows. --- diff --git a/docs/improved-timeline-presets.md b/docs/improved-timeline-presets.md index 2b9b2cb..1283314 100644 --- a/docs/improved-timeline-presets.md +++ b/docs/improved-timeline-presets.md @@ -12,7 +12,7 @@ Naming: **cue** remains a per-lane transition. **Song markers** remain project-s 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. +- 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 crescendos driven by song markers typed `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. diff --git a/tests/cleave/test_config.py b/tests/cleave/test_config.py index 8e7b39d..85f8d93 100644 --- a/tests/cleave/test_config.py +++ b/tests/cleave/test_config.py @@ -1279,7 +1279,6 @@ def test_persist_timeline_preset_round_trip() -> None: timeline=TimelineRuntime( enabled=True, timeline_preset_kind="arc", - timeline_preset_crescendo="penultimate", timeline_preset_density="very dense", timeline_preset_cue_snap="bars", timeline_preset_song_marker_snap=2.0, @@ -1299,7 +1298,6 @@ def test_persist_timeline_preset_round_trip() -> None: payload = persist_timeline(PersistCtx(cfg=cfg, session=session, cfg_dir=None)) assert payload["preset"] == { "character": "arc", - "crescendo": "penultimate", "density": "very dense", "cue_snap": "bars", "song_marker_snap": 2.0, @@ -1314,7 +1312,6 @@ def test_persist_timeline_preset_round_trip() -> None: assert round_trip is not None assert round_trip.preset == TimelinePresetConfig( character="arc", - crescendo="penultimate", density="very dense", cue_snap="bars", song_marker_snap=2.0, @@ -1330,7 +1327,6 @@ def test_parse_timeline_reads_preset() -> None: "timeline": { "preset": { "character": "pulse", - "crescendo": "last", "density": "sparse", "cue_snap": "beats", "song_marker_snap": 1.0, @@ -1345,7 +1341,6 @@ def test_parse_timeline_reads_preset() -> None: assert timeline is not None assert timeline.preset == TimelinePresetConfig( character="pulse", - crescendo="last", density="sparse", cue_snap="beats", song_marker_snap=1.0, @@ -1355,14 +1350,13 @@ def test_parse_timeline_reads_preset() -> None: ) -def test_parse_timeline_preset_null_crescendo() -> None: +def test_parse_timeline_preset_defaults() -> None: timeline = parse_timeline_section( - {"timeline": {"preset": {"character": "dialogue", "crescendo": None}}}, + {"timeline": {"preset": {"character": "dialogue"}}}, _timeline_parse_ctx(), ) assert timeline is not None assert timeline.preset.character == "dialogue" - assert timeline.preset.crescendo is None assert timeline.preset.density == "normal" assert timeline.preset.cue_snap == "none" assert timeline.preset.song_marker_snap is None diff --git a/tests/cleave/test_config_snapshot.py b/tests/cleave/test_config_snapshot.py index f371471..7fe97f3 100644 --- a/tests/cleave/test_config_snapshot.py +++ b/tests/cleave/test_config_snapshot.py @@ -1221,7 +1221,6 @@ def test_write_session_snapshot_persists_timeline_disabled_without_cues( }, "preset": { "character": "breathing", - "crescendo": None, "density": "normal", "cue_snap": "none", "song_marker_snap": None, @@ -1241,7 +1240,6 @@ def test_write_session_snapshot_persists_timeline_disabled_without_cues( def test_write_session_snapshot_round_trips_timeline_preset(tmp_path: Path) -> None: cfg, session, out_path = _snapshot_fixture(tmp_path) session.timeline.timeline_preset_kind = "arc" - session.timeline.timeline_preset_crescendo = "last" session.timeline.timeline_preset_density = "dense" session.timeline.timeline_preset_cue_snap = "beats" session.timeline.timeline_preset_song_marker_snap = 5.0 @@ -1253,7 +1251,6 @@ def test_write_session_snapshot_round_trips_timeline_preset(tmp_path: Path) -> N data = yaml.safe_load(out_path.read_text(encoding="utf-8")) assert data["timeline"]["preset"] == { "character": "arc", - "crescendo": "last", "density": "dense", "cue_snap": "beats", "song_marker_snap": 5.0, @@ -1279,7 +1276,6 @@ def test_write_session_snapshot_round_trips_timeline_preset(tmp_path: Path) -> N ) session2 = session_from_cfg(cfg_with_timeline, playlists) 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_cue_snap == "beats" assert session2.timeline.timeline_preset_song_marker_snap == 5.0 diff --git a/tests/cleave/test_timeline_conductor.py b/tests/cleave/test_timeline_conductor.py index 9b7d55a..fd28d14 100644 --- a/tests/cleave/test_timeline_conductor.py +++ b/tests/cleave/test_timeline_conductor.py @@ -32,6 +32,7 @@ support_floor_for, timeline_preset_conductor_display, ) +from cleave.song_markers import SongMarker from cleave.timeline_presets.crescendo import ( apply_crescendo, resolve_crescendo_window, @@ -856,8 +857,13 @@ def test_arranger_conductor_crescendo_preserves_prefix_levels() -> None: "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") + markers = [ + SongMarker(20.0), + SongMarker(50.0), + SongMarker(80.0), + SongMarker(100.0, "crescendo"), + ] + window = resolve_crescendo_window(markers, duration_sec) assert window is not None base = build_breathing_cues( slots, @@ -872,8 +878,7 @@ def test_arranger_conductor_crescendo_preserves_prefix_levels() -> None: slots, duration_sec=duration_sec, bar_times=bars, - song_marker_times=markers, - target="last", + song_markers=markers, rng=random.Random(6), ) t = 0.0 @@ -906,8 +911,13 @@ def test_arranger_conductor_crescendo_preserves_prefix_roles() -> None: "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") + markers = [ + SongMarker(20.0), + SongMarker(50.0), + SongMarker(80.0), + SongMarker(100.0, "crescendo"), + ] + window = resolve_crescendo_window(markers, duration_sec) assert window is not None base = build_breathing_cues( slots, @@ -931,8 +941,7 @@ def test_arranger_conductor_crescendo_preserves_prefix_roles() -> None: slots, duration_sec=duration_sec, bar_times=bars, - song_marker_times=markers, - target="last", + song_markers=markers, rng=random.Random(6), ) after_prefix_on = [ @@ -962,7 +971,12 @@ 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] + markers = [ + SongMarker(20.0), + SongMarker(50.0), + SongMarker(80.0), + SongMarker(100.0, "crescendo"), + ] base = build_breathing_cues( slots, duration_sec, random.Random(1), bar_times=bars ) @@ -972,8 +986,7 @@ def test_apply_crescendo_without_roles_stays_role_free() -> None: slots, duration_sec=duration_sec, bar_times=bars, - song_marker_times=markers, - target="last", + song_markers=markers, rng=random.Random(2), ) assert all(cue.role is None for lane in after.values() for cue in lane.cues) diff --git a/tests/cleave/test_timeline_presets.py b/tests/cleave/test_timeline_presets.py index f480ead..ab53edb 100644 --- a/tests/cleave/test_timeline_presets.py +++ b/tests/cleave/test_timeline_presets.py @@ -28,9 +28,11 @@ stack_density_level, ) from cleave.timeline_presets.density import density_bias_for +from cleave.song_markers import SongMarker from cleave.timeline_presets.crescendo import ( apply_crescendo, resolve_crescendo_window, + resolve_crescendo_windows, ) from cleave.timeline_presets.grid import thin_bar_times_for_arrange from cleave.timeline_presets.motifs import hamming_distance @@ -714,18 +716,28 @@ def test_density_bias_monotonic_active_counts(builder) -> None: ) -def test_resolve_crescendo_window_last_uses_marker_minus_two() -> None: - markers = [10.0, 40.0, 70.0, 100.0] - window = resolve_crescendo_window(markers, 120.0, "last") +def _crescendo_markers( + times: list[float], *, peak: float | None = None +) -> list[SongMarker]: + """Standard markers; when ``peak`` is set that time is typed crescendo.""" + return [ + SongMarker(t, "crescendo" if peak is not None and t == peak else "standard") + for t in times + ] + + +def test_resolve_crescendo_window_uses_marker_minus_two() -> None: + markers = _crescendo_markers([10.0, 40.0, 70.0, 100.0], peak=100.0) + window = resolve_crescendo_window(markers, 120.0) assert window is not None assert window.t_start == 40.0 assert window.t_full == 70.0 assert window.t_peak_end == 100.0 -def test_resolve_crescendo_window_penultimate_falls_back_without_minus_two() -> None: - markers = [20.0, 60.0, 100.0] - window = resolve_crescendo_window(markers, 120.0, "penultimate") +def test_resolve_crescendo_window_falls_back_without_minus_two() -> None: + markers = _crescendo_markers([20.0, 60.0, 100.0], peak=60.0) + window = resolve_crescendo_window(markers, 120.0, peak_time=60.0) assert window is not None assert window.t_peak_end == 60.0 assert window.t_full == 20.0 @@ -734,8 +746,29 @@ def test_resolve_crescendo_window_penultimate_falls_back_without_minus_two() -> assert window.t_start < window.t_full <= window.t_peak_end -def test_resolve_crescendo_window_requires_three_markers() -> None: - assert resolve_crescendo_window([10.0, 50.0], 100.0, "last") is None +def test_resolve_crescendo_window_requires_prior_marker() -> None: + assert resolve_crescendo_window( + [SongMarker(50.0, "crescendo")], 100.0 + ) is None + assert resolve_crescendo_window( + _crescendo_markers([10.0, 50.0]), 100.0 + ) is None + + +def test_resolve_crescendo_windows_all_typed_peaks() -> None: + markers = [ + SongMarker(10.0), + SongMarker(40.0, "crescendo"), + SongMarker(70.0), + SongMarker(100.0, "crescendo"), + ] + windows = resolve_crescendo_windows(markers, 120.0) + assert len(windows) == 2 + assert windows[0].t_peak_end == 40.0 + assert windows[0].t_full == 10.0 + assert windows[1].t_peak_end == 100.0 + assert windows[1].t_full == 70.0 + assert windows[1].t_start == 40.0 def test_crescendo_states_ramp_through_quantised_levels() -> None: @@ -789,7 +822,7 @@ def test_crescendo_spread_times_are_evenly_spaced() -> None: def test_apply_crescendo_ramps_holds_then_solos() -> None: slots = _slots(4) duration_sec = 120.0 - markers = [20.0, 50.0, 80.0, 100.0] + markers = _crescendo_markers([20.0, 50.0, 80.0, 100.0], peak=100.0) bars = _bar_times_for(duration_sec) base = build_breathing_cues(slots, duration_sec, random.Random(1), bar_times=bars) lanes = apply_crescendo( @@ -797,8 +830,7 @@ def test_apply_crescendo_ramps_holds_then_solos() -> None: slots, duration_sec=duration_sec, bar_times=bars, - song_marker_times=markers, - target="last", + song_markers=markers, rng=random.Random(2), ) # Ramp start: one layer; full stack at marker-1; hold through selected; solo after. diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index 0c77073..cbff993 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -1636,7 +1636,6 @@ def _choose_modal_option(controls: TuningControls, label: str) -> None: 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 @@ -1647,7 +1646,6 @@ def test_timeline_presets_enter_opens_yes_cancel_modal() -> None: assert modal_view.message == "Apply timeline preset?" assert modal_view.labeled_lines == ( ModalLabeledLine("character", "arc"), - ModalLabeledLine("crescendo", "last song marker"), ModalLabeledLine("density", "normal"), ModalLabeledLine("cue snap", "none"), ModalLabeledLine("song marker snap", "none"), @@ -1741,7 +1739,6 @@ def test_timeline_presets_cuts_none_keeps_cut_none() -> None: bar_times=bars, ) controls.session.timeline.timeline_preset_kind = "breathing" - controls.session.timeline.timeline_preset_crescendo = None controls.session.timeline.timeline_preset_timeline_cuts = "none" _focus_timeline_presets(controls) _confirm_timeline_preset(controls) @@ -1761,7 +1758,6 @@ def test_timeline_presets_by_marker_cuts_assign_soft_or_hard() -> None: ) controls.session.song_markers.times = [30.0, 90.0, 150.0] controls.session.timeline.timeline_preset_kind = "breathing" - controls.session.timeline.timeline_preset_crescendo = None controls.session.timeline.timeline_preset_timeline_cuts = "by marker" _focus_timeline_presets(controls) _confirm_timeline_preset(controls) @@ -1815,7 +1811,6 @@ def test_timeline_presets_breathing_clears_and_applies() -> None: controls.session.timeline.recording = True controls.session.timeline.armed_slots.add("layer_1") controls.session.timeline.timeline_preset_kind = "breathing" - controls.session.timeline.timeline_preset_crescendo = None _focus_timeline_presets(controls) _confirm_timeline_preset(controls) assert not controls.modal_host.active @@ -1843,7 +1838,6 @@ def test_timeline_presets_arc_clears_and_applies() -> None: controls.session.timeline.lanes = {"layer_1": _lane(True, (5.0, False))} controls.session.timeline.enabled = False controls.session.timeline.timeline_preset_kind = "arc" - controls.session.timeline.timeline_preset_crescendo = None _focus_timeline_presets(controls) _confirm_timeline_preset(controls) assert not controls.modal_host.active @@ -1854,7 +1848,9 @@ def test_timeline_presets_arc_clears_and_applies() -> None: assert all(lane.baseline is not None for lane in lanes.values()) -def test_timeline_presets_crescendo_when_enough_markers() -> None: +def test_timeline_presets_crescendo_when_typed_marker() -> None: + from cleave.song_markers import SongMarker + beats = tuple(float(i) for i in range(241)) bars = tuple(float(i) for i in range(0, 241, 4)) controls = _make_controls( @@ -1863,9 +1859,13 @@ def test_timeline_presets_crescendo_when_enough_markers() -> None: bar_times=bars, duration_sec=240.0, ) - controls.session.song_markers.times = [30.0, 90.0, 150.0, 200.0] + controls.session.song_markers.markers = [ + SongMarker(30.0), + SongMarker(90.0), + SongMarker(150.0), + SongMarker(200.0, "crescendo"), + ] controls.session.timeline.timeline_preset_kind = "breathing" - controls.session.timeline.timeline_preset_crescendo = "last" _focus_timeline_presets(controls) _confirm_timeline_preset(controls) assert not controls.modal_host.active @@ -1873,7 +1873,7 @@ def test_timeline_presets_crescendo_when_enough_markers() -> None: assert view.notification_message == "Applied Breathing timeline preset (crescendo)" -def test_timeline_presets_skips_crescendo_without_enough_markers() -> None: +def test_timeline_presets_skips_crescendo_without_typed_marker() -> None: beats = tuple(float(i) for i in range(241)) bars = tuple(float(i) for i in range(0, 241, 4)) controls = _make_controls( @@ -1882,9 +1882,8 @@ def test_timeline_presets_skips_crescendo_without_enough_markers() -> None: bar_times=bars, duration_sec=240.0, ) - controls.session.song_markers.times = [30.0, 90.0] + controls.session.song_markers.times = [30.0, 90.0, 150.0, 200.0] controls.session.timeline.timeline_preset_kind = "pulse" - controls.session.timeline.timeline_preset_crescendo = "last" _focus_timeline_presets(controls) _confirm_timeline_preset(controls) assert not controls.modal_host.active @@ -3067,7 +3066,6 @@ def test_render_timeline_sub_rows_dim_when_disabled() -> None: RowKind.TIMELINE_APPLY_HARD_CUTS, RowKind.TIMELINE_PRESETS_HEADER, RowKind.TIMELINE_PRESET_CHARACTER, - RowKind.TIMELINE_PRESET_CRESCENDO, RowKind.TIMELINE_PRESET_DENSITY, RowKind.TIMELINE_PRESET_CUE_SNAP, RowKind.TIMELINE_PRESET_SONG_MARKER_SNAP, diff --git a/tests/cleave/viz/test_help_overlay.py b/tests/cleave/viz/test_help_overlay.py index 684339d..4693f77 100644 --- a/tests/cleave/viz/test_help_overlay.py +++ b/tests/cleave/viz/test_help_overlay.py @@ -247,7 +247,7 @@ def test_timeline_presets_help_lists_characters() -> None: assert description.title == "Character" assert description.lines == ( "Procedural timeline character used when applying a preset.", - "If song markers are present, they are favoured for cue placement.", + "Song markers favour cue placement; crescendo types build crescendos.", ) assert description.entries == TIMELINE_PRESET_HELP_ENTRIES assert [name for name, _ in description.entries] == [ diff --git a/tests/cleave/viz/test_row_fields.py b/tests/cleave/viz/test_row_fields.py index 0b4c1ab..ce58d80 100644 --- a/tests/cleave/viz/test_row_fields.py +++ b/tests/cleave/viz/test_row_fields.py @@ -338,7 +338,7 @@ def test_apply_field_horizontal_track_header_solo_and_expand() -> None: def test_row_fields_count() -> None: - assert len(ROW_FIELDS) == 122 + assert len(ROW_FIELDS) == 121 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 bb8793a..b266aee 100644 --- a/tests/cleave/viz/test_row_semantics.py +++ b/tests/cleave/viz/test_row_semantics.py @@ -312,7 +312,6 @@ def test_render_value_children_blocked_by_section_lock() -> None: assert row_blocked_by_section_lock(RowKind.RENDER_POST_FX_CHROMA_BOOST_AMOUNT) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_PRESETS) is True 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_CUE_SNAP) is True assert row_blocked_by_section_lock(RowKind.TIMELINE_PRESET_SONG_MARKER_SNAP) is True diff --git a/tests/cleave/viz/test_text_fit.py b/tests/cleave/viz/test_text_fit.py index 36cb768..35a0b11 100644 --- a/tests/cleave/viz/test_text_fit.py +++ b/tests/cleave/viz/test_text_fit.py @@ -231,7 +231,6 @@ def test_wrap_text_to_width_preserves_explicit_newlines() -> None: text = ( "Apply timeline preset?\n" "character: arc\n" - "crescendo: no\n" "density: normal\n" "conductor: off" ) @@ -239,7 +238,6 @@ def test_wrap_text_to_width_preserves_explicit_newlines() -> None: 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 1d6340e..a691249 100644 --- a/tests/cleave/viz/test_view_state_structure.py +++ b/tests/cleave/viz/test_view_state_structure.py @@ -147,7 +147,6 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: view_closed = builder.build(paused=False) presets_header = RowDescriptor(RowKind.TIMELINE_PRESETS_HEADER) preset_character = RowDescriptor(RowKind.TIMELINE_PRESET_CHARACTER) - preset_crescendo = RowDescriptor(RowKind.TIMELINE_PRESET_CRESCENDO) preset_density = RowDescriptor(RowKind.TIMELINE_PRESET_DENSITY) preset_cue_snap = RowDescriptor(RowKind.TIMELINE_PRESET_CUE_SNAP) preset_song_marker_snap = RowDescriptor(RowKind.TIMELINE_PRESET_SONG_MARKER_SNAP) @@ -200,7 +199,6 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert view_open.layout is not view_closed.layout assert presets_header in view_open.layout.rows 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_cue_snap not in view_open.layout.rows assert preset_song_marker_snap not in view_open.layout.rows @@ -323,49 +321,45 @@ def test_builder_rebuilds_layout_when_timeline_panel_open_changes() -> None: assert view_presets_expanded.layout.rows.index(preset_character) == ( presets_header_idx + 1 ) - assert view_presets_expanded.layout.rows.index(preset_crescendo) == ( - presets_header_idx + 2 - ) assert view_presets_expanded.layout.rows.index(preset_density) == ( - presets_header_idx + 3 + presets_header_idx + 2 ) assert view_presets_expanded.layout.rows.index(preset_cue_snap) == ( - presets_header_idx + 4 + presets_header_idx + 3 ) assert view_presets_expanded.layout.rows.index(preset_song_marker_snap) == ( - presets_header_idx + 5 + presets_header_idx + 4 ) assert view_presets_expanded.layout.rows.index(preset_timeline_cuts) == ( - presets_header_idx + 6 + presets_header_idx + 5 ) assert view_presets_expanded.layout.rows.index(preset_repopulate) == ( - presets_header_idx + 7 + presets_header_idx + 6 ) assert view_presets_expanded.layout.rows.index(preset_conductor) == ( - presets_header_idx + 8 + presets_header_idx + 7 ) assert view_presets_expanded.layout.rows.index(presets_apply) == ( - presets_header_idx + 9 + presets_header_idx + 8 ) assert view_presets_expanded.layout.rows.index(limiter_header) == ( - presets_header_idx + 10 + presets_header_idx + 9 ) assert view_presets_expanded.layout.rows.index(limiter_threshold) == ( - presets_header_idx + 11 + presets_header_idx + 10 ) assert view_presets_expanded.layout.rows.index(limiter_ratio) == ( - presets_header_idx + 12 + presets_header_idx + 11 ) assert view_presets_expanded.layout.rows.index(limiter_release) == ( - presets_header_idx + 13 + presets_header_idx + 12 ) - assert view_presets_expanded.layout.rows.index(reset) == presets_header_idx + 14 + assert view_presets_expanded.layout.rows.index(reset) == presets_header_idx + 13 session.timeline.timeline_presets_expanded = False view_presets_collapsed = builder.build(paused=False) assert view_presets_collapsed.layout is not view_presets_expanded.layout 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_cue_snap not in view_presets_collapsed.layout.rows assert preset_song_marker_snap not in view_presets_collapsed.layout.rows From 50a6f0f5d30ccaa576defcb4a27617cb0a03816c Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Fri, 7 Aug 2026 00:02:43 +0100 Subject: [PATCH 3/5] Add begin and sustain song marker types and wire them up to preset generation --- cleave/song_markers.py | 36 +++++++- cleave/timeline_presets/crescendo.py | 48 ++++++++-- cleave/viz/row_fields.py | 4 + docs/completed/song-markers.md | 6 +- docs/improved-timeline-presets.md | 2 +- tests/cleave/test_song_markers.py | 73 +++++++++++++++- tests/cleave/test_timeline_presets.py | 121 +++++++++++++++++++++++++- tests/cleave/viz/test_controls.py | 43 ++++++++- 8 files changed, 311 insertions(+), 22 deletions(-) diff --git a/cleave/song_markers.py b/cleave/song_markers.py index 825158e..2f2fe75 100644 --- a/cleave/song_markers.py +++ b/cleave/song_markers.py @@ -6,16 +6,22 @@ from dataclasses import dataclass from typing import Literal, Sequence -SongMarkerType = Literal["standard", "crescendo", "diminuendo"] +SongMarkerType = Literal[ + "standard", "begin", "sustain", "crescendo", "diminuendo" +] DEFAULT_SONG_MARKER_TYPE: SongMarkerType = "standard" SONG_MARKER_TYPES: tuple[SongMarkerType, ...] = ( "standard", + "begin", + "sustain", "crescendo", "diminuendo", ) +_GESTURE_PEAK_TYPES = frozenset({"crescendo", "diminuendo"}) + @dataclass(frozen=True) class SongMarker: @@ -42,6 +48,34 @@ def parse_song_marker_type(raw: object) -> SongMarkerType: raise ValueError(f"invalid song marker type: {raw!r}") +def song_marker_gesture_warning( + markers: Sequence[SongMarker], + changed_index: int, +) -> str | None: + """Return a warn-only message when the edited marker is structurally invalid. + + Peak types are ``crescendo`` and ``diminuendo``. A ``begin`` / ``sustain`` + binds forward to the first peak; a later ``begin`` starts a fresh gesture. + """ + if changed_index < 0 or changed_index >= len(markers): + return None + marker_type = markers[changed_index].marker_type + if marker_type in _GESTURE_PEAK_TYPES: + if changed_index == 0: + return f"{marker_type} has no marker before it to rise from" + if marker_type == "diminuendo": + return "diminuendo is not generated yet" + return None + if marker_type not in ("begin", "sustain"): + return None + for j in range(changed_index + 1, len(markers)): + other = markers[j].marker_type + if other == "begin": + break + if other in _GESTURE_PEAK_TYPES: + return None + return f"{marker_type} has no crescendo/diminuendo after it" + def nearest_index(times: Sequence[float], t: float) -> int: """Return the index of the song marker nearest to ``t``. diff --git a/cleave/timeline_presets/crescendo.py b/cleave/timeline_presets/crescendo.py index f66072a..60aedd6 100644 --- a/cleave/timeline_presets/crescendo.py +++ b/cleave/timeline_presets/crescendo.py @@ -1,7 +1,9 @@ """Song-marker crescendo overlay for timeline presets. Builds crescendos to each in-range song marker typed ``crescendo``. -``diminuendo`` markers are ignored. +Optional ``begin`` / ``sustain`` anchors set the rise window; absent anchors +fall back to the prior two markers. ``diminuendo`` is ignored for generation +but still scopes gesture search as a peak. """ from __future__ import annotations @@ -32,6 +34,7 @@ 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)) +_GESTURE_PEAK_TYPES = frozenset({"crescendo", "diminuendo"}) def _lerp(a: float, b: float, t: float) -> float: @@ -112,15 +115,42 @@ def _window_at_index( ) -> CrescendoWindow | None: if selected_idx < 1 or duration_sec <= 0.0: return None + prev_peak_idx = -1 + for i in range(selected_idx - 1, -1, -1): + if markers[i].marker_type in _GESTURE_PEAK_TYPES: + prev_peak_idx = i + break + # Anchors strictly between the previous peak and this crescendo. + scope = range(prev_peak_idx + 1, selected_idx) t_peak_end = float(markers[selected_idx].time) - t_full = float(markers[selected_idx - 1].time) - if selected_idx >= 2: - t_start = float(markers[selected_idx - 2].time) - else: - t_start = max(0.0, t_peak_end - _FALLBACK_START_FRACTION * duration_sec) - if t_start > t_full: - t_start = max(0.0, t_full - _FALLBACK_START_FRACTION * duration_sec) - if t_full > t_peak_end: + + t_full: float | None = None + for i in scope: + if markers[i].marker_type == "sustain": + t_full = float(markers[i].time) + if t_full is None: + t_full = float(markers[selected_idx - 1].time) + + t_start: float | None = None + for i in scope: + if markers[i].marker_type == "begin": + begin_t = float(markers[i].time) + if begin_t < t_full: + t_start = begin_t + if t_start is None: + if selected_idx >= 2: + t_start = float(markers[selected_idx - 2].time) + else: + t_start = max( + 0.0, t_peak_end - _FALLBACK_START_FRACTION * duration_sec + ) + if prev_peak_idx >= 0: + t_start = max(t_start, float(markers[prev_peak_idx].time)) + + if t_start >= t_full: + # Collapse the hold: pure rise from t_start to the peak. + t_full = t_peak_end + if t_full > t_peak_end or t_start >= t_peak_end: return None return CrescendoWindow(t_start=t_start, t_full=t_full, t_peak_end=t_peak_end) diff --git a/cleave/viz/row_fields.py b/cleave/viz/row_fields.py index a616a19..6984256 100644 --- a/cleave/viz/row_fields.py +++ b/cleave/viz/row_fields.py @@ -38,6 +38,7 @@ cycle_song_marker_type, format_marker_time, parse_song_marker_type, + song_marker_gesture_warning, ) from cleave.timeline_presets.characters import ( cycle_timeline_preset_kind, @@ -1543,6 +1544,9 @@ def _apply_song_marker_type( forward=forward, ) markers.markers[index] = SongMarker(current.time, next_type) + warning = song_marker_gesture_warning(markers.markers, index) + if warning is not None: + controls.show_notification(warning) def _format_transport(_state: TuningViewState, _desc: RowDescriptor) -> str: diff --git a/docs/completed/song-markers.md b/docs/completed/song-markers.md index 325ed23..068dbdf 100644 --- a/docs/completed/song-markers.md +++ b/docs/completed/song-markers.md @@ -40,13 +40,13 @@ Build UI and editing only. No preset generation or beat-phase logic yet. - First expandable child under **Render: Timeline**: header label `song markers (N)` with expand arrow (e.g. `song markers (4)`). - When expanded: list of marker rows as `[mm:ss.cc] ` (e.g. `[00:26.02] -`), then **snap to song markers** as the last row (green action row; no expand arrow). -- Marker types: `standard` (shown as `-`), `crescendo`, `diminuendo`. New drops default to `standard`. +- Marker types: `standard` (shown as `-`), `begin`, `sustain`, `crescendo`, `diminuendo`. New drops default to `standard`. ### List interaction - Focus on a song-marker list row highlights that row and the matching strip tick. Drop/replace does **not** move focus onto the new marker; if a marker row was already focused, that selection is remapped by time when the list shifts. Do **not** auto-follow the playhead. - **Enter** on a focused song marker seeks the playhead to that time (audition / verify placement). Timeline row arm uses **a**, so **Enter** is free for seek-to-marker. -- **Left** / **Right** cycles the focused marker's type (`standard` -> `crescendo` -> `diminuendo` -> …). +- **Left** / **Right** cycles the focused marker's type (`standard` -> `begin` -> `sustain` -> `crescendo` -> `diminuendo` -> …). Invalid gesture structure (orphan `begin`/`sustain`, peak with no prior marker) shows a warn-only toast; the type change still applies. Cycling to `diminuendo` also toasts that generation is not implemented yet. - **Delete** prompts a confirm modal, then removes the focused song marker. - No nudge in v1 — delete and re-drop at the playhead is enough, with **Enter** to verify. @@ -106,7 +106,7 @@ When song markers exist, applying a timeline preset (Breathing / Dialogue / Arc 1. **Section-driven phrases.** Markers are hard section walls. Phrases never cross a marker; each marker starts a new phrase (then the usual 4–8 bar / minimum-duration partitioning fills each section). Empty or out-of-range markers leave bar-only partitioning unchanged. 2. **Soft latch.** Planned motif switches still prefer the bar grid. If an unclaimed marker lies within **5.0s** of a planned switch and min switch gaps still hold, that switch moves onto the marker (exclusive: each marker claimed at most once). Soft latch does **not** invent extra transitions solely to hit a marker. -3. **Crescendo markers.** Each in-range marker typed `crescendo` that has at least one prior marker gets a crescendo post-pass (ramp from earlier markers, full stack at the previous marker, solo from the crescendo marker through song end, or until a later crescendo overwrites). `diminuendo` is ignored for generation. +3. **Crescendo markers.** Each in-range marker typed `crescendo` that has at least one prior marker gets a crescendo post-pass. Optional `begin` sets where the rise starts and optional `sustain` sets where the full stack is reached and held until the peak; both are scoped between the previous peak (`crescendo` or `diminuendo`) and this one. Without those anchors, rise starts two markers before the peak (or a duration fraction when only one prior marker exists) and full stack is one marker before. Solo runs from the crescendo marker through song end, or until a later crescendo overwrites. `diminuendo` is ignored for generation but still ends a gesture for scoping. No new panel knobs. Phase 2 **snap to song markers** remains a separate manual polish step and is not run automatically after apply. diff --git a/docs/improved-timeline-presets.md b/docs/improved-timeline-presets.md index 1283314..23cad9b 100644 --- a/docs/improved-timeline-presets.md +++ b/docs/improved-timeline-presets.md @@ -12,7 +12,7 @@ Naming: **cue** remains a per-lane transition. **Song markers** remain project-s 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 crescendos driven by song markers typed `crescendo`. +- 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 crescendos driven by song markers typed `crescendo` (optional `begin` / `sustain` set the rise window). - 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. diff --git a/tests/cleave/test_song_markers.py b/tests/cleave/test_song_markers.py index 3fce5a4..151ba57 100644 --- a/tests/cleave/test_song_markers.py +++ b/tests/cleave/test_song_markers.py @@ -10,6 +10,7 @@ format_marker_time, nearest_index, place_marker, + song_marker_gesture_warning, ) @@ -92,8 +93,76 @@ def test_format_marker_time() -> None: def test_cycle_song_marker_type() -> None: - assert cycle_song_marker_type("standard", forward=True) == "crescendo" + assert cycle_song_marker_type("standard", forward=True) == "begin" + assert cycle_song_marker_type("begin", forward=True) == "sustain" + assert cycle_song_marker_type("sustain", forward=True) == "crescendo" assert cycle_song_marker_type("crescendo", forward=True) == "diminuendo" assert cycle_song_marker_type("diminuendo", forward=True) == "standard" assert cycle_song_marker_type("standard", forward=False) == "diminuendo" - assert cycle_song_marker_type("crescendo", forward=False) == "standard" + assert cycle_song_marker_type("begin", forward=False) == "standard" + assert cycle_song_marker_type("crescendo", forward=False) == "sustain" + + +def test_song_marker_gesture_warning_orphan_begin() -> None: + markers = [SongMarker(10.0, "begin"), SongMarker(20.0)] + assert ( + song_marker_gesture_warning(markers, 0) + == "begin has no crescendo/diminuendo after it" + ) + + +def test_song_marker_gesture_warning_orphan_sustain() -> None: + markers = [ + SongMarker(10.0, "crescendo"), + SongMarker(20.0, "sustain"), + SongMarker(30.0), + ] + assert ( + song_marker_gesture_warning(markers, 1) + == "sustain has no crescendo/diminuendo after it" + ) + + +def test_song_marker_gesture_warning_sustain_before_begin() -> None: + markers = [ + SongMarker(10.0, "sustain"), + SongMarker(20.0, "begin"), + SongMarker(30.0, "crescendo"), + ] + assert ( + song_marker_gesture_warning(markers, 0) + == "sustain has no crescendo/diminuendo after it" + ) + assert song_marker_gesture_warning(markers, 1) is None + + +def test_song_marker_gesture_warning_peak_first() -> None: + markers = [SongMarker(10.0, "crescendo"), SongMarker(20.0)] + assert ( + song_marker_gesture_warning(markers, 0) + == "crescendo has no marker before it to rise from" + ) + markers[0] = SongMarker(10.0, "diminuendo") + assert ( + song_marker_gesture_warning(markers, 0) + == "diminuendo has no marker before it to rise from" + ) + + +def test_song_marker_gesture_warning_valid_gesture() -> None: + markers = [ + SongMarker(10.0, "begin"), + SongMarker(20.0, "sustain"), + SongMarker(30.0, "crescendo"), + ] + assert song_marker_gesture_warning(markers, 0) is None + assert song_marker_gesture_warning(markers, 1) is None + assert song_marker_gesture_warning(markers, 2) is None + + +def test_song_marker_gesture_warning_diminuendo_info() -> None: + markers = [SongMarker(10.0), SongMarker(20.0, "diminuendo")] + assert ( + song_marker_gesture_warning(markers, 1) + == "diminuendo is not generated yet" + ) diff --git a/tests/cleave/test_timeline_presets.py b/tests/cleave/test_timeline_presets.py index ab53edb..2420ae0 100644 --- a/tests/cleave/test_timeline_presets.py +++ b/tests/cleave/test_timeline_presets.py @@ -740,9 +740,9 @@ def test_resolve_crescendo_window_falls_back_without_minus_two() -> None: window = resolve_crescendo_window(markers, 120.0, peak_time=60.0) assert window is not None assert window.t_peak_end == 60.0 - assert window.t_full == 20.0 - # Fallback 60 - 20% lands after t_full; clamp so the ramp still precedes it. - assert window.t_start == pytest.approx(0.0) + # Fraction start (60 - 20% of 120 = 36) lands after t_full (20); collapse hold. + assert window.t_start == pytest.approx(36.0) + assert window.t_full == 60.0 assert window.t_start < window.t_full <= window.t_peak_end @@ -764,13 +764,126 @@ def test_resolve_crescendo_windows_all_typed_peaks() -> None: ] windows = resolve_crescendo_windows(markers, 120.0) assert len(windows) == 2 + # First peak has only one prior marker: fraction start lands after t_full, + # so the hold collapses to a pure rise into the peak. assert windows[0].t_peak_end == 40.0 - assert windows[0].t_full == 10.0 + assert windows[0].t_start == pytest.approx(16.0) + assert windows[0].t_full == 40.0 assert windows[1].t_peak_end == 100.0 assert windows[1].t_full == 70.0 assert windows[1].t_start == 40.0 +def test_resolve_crescendo_window_begin_anchor() -> None: + markers = [ + SongMarker(10.0), + SongMarker(20.0, "begin"), + SongMarker(40.0), + SongMarker(70.0, "crescendo"), + ] + window = resolve_crescendo_window(markers, 120.0) + assert window is not None + assert window.t_start == 20.0 + assert window.t_full == 40.0 + assert window.t_peak_end == 70.0 + + +def test_resolve_crescendo_window_sustain_anchor() -> None: + markers = [ + SongMarker(10.0), + SongMarker(25.0), + SongMarker(40.0, "sustain"), + SongMarker(70.0, "crescendo"), + ] + window = resolve_crescendo_window(markers, 120.0) + assert window is not None + assert window.t_start == 25.0 + assert window.t_full == 40.0 + assert window.t_peak_end == 70.0 + + +def test_resolve_crescendo_window_begin_and_sustain() -> None: + markers = [ + SongMarker(5.0), + SongMarker(15.0, "begin"), + SongMarker(30.0), + SongMarker(45.0, "sustain"), + SongMarker(60.0), + SongMarker(80.0, "crescendo"), + ] + window = resolve_crescendo_window(markers, 120.0) + assert window is not None + assert window.t_start == 15.0 + assert window.t_full == 45.0 + assert window.t_peak_end == 80.0 + + +def test_resolve_crescendo_window_multi_section_span() -> None: + markers = [ + SongMarker(10.0, "begin"), + SongMarker(20.0), + SongMarker(30.0), + SongMarker(40.0, "sustain"), + SongMarker(50.0), + SongMarker(60.0, "crescendo"), + ] + window = resolve_crescendo_window(markers, 120.0) + assert window is not None + assert window.t_start == 10.0 + assert window.t_full == 40.0 + assert window.t_peak_end == 60.0 + + +def test_resolve_crescendo_window_scopes_after_prior_peak() -> None: + markers = [ + SongMarker(10.0, "begin"), + SongMarker(15.0, "sustain"), + SongMarker(20.0, "crescendo"), + SongMarker(30.0, "begin"), + SongMarker(40.0, "sustain"), + SongMarker(50.0, "crescendo"), + ] + windows = resolve_crescendo_windows(markers, 120.0) + assert len(windows) == 2 + assert windows[0].t_start == 10.0 + assert windows[0].t_full == 15.0 + assert windows[0].t_peak_end == 20.0 + assert windows[1].t_start == 30.0 + assert windows[1].t_full == 40.0 + assert windows[1].t_peak_end == 50.0 + + +def test_resolve_crescendo_window_collapses_hold_when_start_not_before_full() -> None: + markers = [ + SongMarker(30.0, "sustain"), + SongMarker(50.0, "begin"), + SongMarker(70.0, "crescendo"), + ] + window = resolve_crescendo_window(markers, 120.0) + assert window is not None + # begin at 50 is not < t_full (30), so begin is skipped; N-2 fallback is + # sustain at 30 and t_full is also 30 -> collapse hold to peak. + assert window.t_start == 30.0 + assert window.t_full == 70.0 + assert window.t_peak_end == 70.0 + + +def test_resolve_crescendo_window_scopes_past_diminuendo() -> None: + markers = [ + SongMarker(10.0, "begin"), + SongMarker(20.0, "sustain"), + SongMarker(40.0, "diminuendo"), + SongMarker(45.0, "begin"), + SongMarker(55.0, "sustain"), + SongMarker(70.0, "crescendo"), + ] + windows = resolve_crescendo_windows(markers, 120.0) + assert len(windows) == 1 + assert windows[0].t_start == 45.0 + assert windows[0].t_full == 55.0 + assert windows[0].t_peak_end == 70.0 + + def test_crescendo_states_ramp_through_quantised_levels() -> None: from cleave.timeline_presets.crescendo import ( CRESCENDO_ENTRY_LEVEL, diff --git a/tests/cleave/viz/test_controls.py b/tests/cleave/viz/test_controls.py index cbff993..bfd5dbb 100644 --- a/tests/cleave/viz/test_controls.py +++ b/tests/cleave/viz/test_controls.py @@ -5259,11 +5259,16 @@ def test_song_marker_left_right_cycles_type() -> None: assert "[00:26.02] -" in _row_text(view, row) assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True - assert markers.markers[0].marker_type == "crescendo" + assert markers.markers[0].marker_type == "begin" assert controls.config_dirty view = controls.build_view_state(paused=False) - assert "[00:26.02] crescendo" in _row_text(view, row) + row = view.layout.find_descriptor(controls.focus_descriptor) + assert "[00:26.02] begin" in _row_text(view, row) + assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True + assert markers.markers[0].marker_type == "sustain" + assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True + assert markers.markers[0].marker_type == "crescendo" assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True assert markers.markers[0].marker_type == "diminuendo" assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True @@ -5272,6 +5277,40 @@ def test_song_marker_left_right_cycles_type() -> None: assert markers.markers[0].marker_type == "diminuendo" +def test_song_marker_type_cycle_toasts_orphan_begin() -> None: + controls = _make_controls(("layer_1",)) + controls.session.timeline.panel_open = True + markers = controls.session.song_markers + markers.markers = [SongMarker(10.0, "standard"), SongMarker(20.0)] + markers.expanded = True + controls.focus_descriptor = RowDescriptor( + RowKind.SONG_MARKER_ITEM, marker_index=0 + ) + noted: list[str] = [] + controls.show_notification = noted.append # type: ignore[method-assign] + + assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True + assert markers.markers[0].marker_type == "begin" + assert noted == ["begin has no crescendo/diminuendo after it"] + + +def test_song_marker_type_cycle_toasts_orphan_sustain() -> None: + controls = _make_controls(("layer_1",)) + controls.session.timeline.panel_open = True + markers = controls.session.song_markers + markers.markers = [SongMarker(10.0, "begin"), SongMarker(20.0)] + markers.expanded = True + controls.focus_descriptor = RowDescriptor( + RowKind.SONG_MARKER_ITEM, marker_index=0 + ) + noted: list[str] = [] + controls.show_notification = noted.append # type: ignore[method-assign] + + assert controls.handle_keydown(_keydown(pygame.K_RIGHT)) is True + assert markers.markers[0].marker_type == "sustain" + assert noted == ["sustain has no crescendo/diminuendo after it"] + + def test_drop_song_marker_insert_preserves_prior_selection_by_time() -> None: """Insert before the selected marker remaps selected_index by prior time.""" controls = _make_controls(("layer_1",)) From 29281a6494d3c1d619ce2c4c018ebc9118689ec2 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Fri, 7 Aug 2026 22:45:07 +0100 Subject: [PATCH 4/5] Add items to roadmap --- docs/roadmap.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/roadmap.md b/docs/roadmap.md index 48ad1c6..aa5b707 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -23,6 +23,22 @@ Side effect: louder PCM also affects hard-cut detection, so the beat-sensitivity Watch upstream: if libprojectM wires beat sensitivity back into the audio path, drop the PCM pre-scale and rely on the native API again. Until then, keep the workaround. +## Named hardcut profiles + +Inspired by [MilkDrop3](https://github.com/milkdrop2077/MilkDrop3) hardcut modes: named profiles (bass/treb thresholds, minimum delay, load-next vs inject-effect) instead of only continuous `hard_cut_sensitivity`. Would sit beside existing projectM preset switching and stem-driven hard cuts. + +## Geometric transition wipes + +Layer-local wipe shaders (plasma, checkerboard, curtain, and similar) when a layer changes preset, beyond projectM's soft crossfade. Implement in the OpenGL compositor during A-to-B preset changes. + +## Preset rotation history + +Never-repeat (or short cooldown) in shuffle/random rotation, plus a "previous preset" step for browsing. Small UX win for long live sessions and offline renders. + +## Pattern-mask dual blend + +Spatial mask blends between two presets or layers (plasma, checker, radial), as a Cleave-native take on MilkDrop3 `.milk2` double-presets. Complements black-key / add; stronger with stem-driven layers than same-audio mashups. + ## Web / browser port Port playback and compositing to the browser. `signals.json` is already portable JSON; [Butterchurn](https://github.com/jberg/butterchurn) is a JS Milkdrop renderer that could replace libprojectM for a shareable viewer. From 78947a8b9cdeadc8444a423e4062e611cd68bfa7 Mon Sep 17 00:00:00 2001 From: SpoddyCoder Date: Fri, 7 Aug 2026 23:49:12 +0100 Subject: [PATCH 5/5] Add Ctrl + num for adding a cue marker in record mode --- .cursor/rules/live-tuning-ui.mdc | 2 +- cleave/config_schema.py | 4 ++ cleave/timeline.py | 2 + cleave/viz/help_content.py | 15 +++++- cleave/viz/help_overlay.py | 3 ++ cleave/viz/help_panel_cache.py | 4 ++ cleave/viz/overlay_draw.py | 1 + cleave/viz/timeline_controls.py | 26 ++++++++++ tests/cleave/test_timeline.py | 15 ++++++ tests/cleave/viz/test_help_overlay.py | 25 +++++++-- tests/cleave/viz/test_timeline_controls.py | 59 ++++++++++++++++++++++ 11 files changed, 148 insertions(+), 8 deletions(-) diff --git a/.cursor/rules/live-tuning-ui.mdc b/.cursor/rules/live-tuning-ui.mdc index a9a82e0..58f4d07 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?, cut?)` 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 cue on the focused lane (all cues, including offs). First press (or when selection is missing) 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** cycles the selected on cue's blend through inherit (`None`) then `BLEND_MODES` (no-op on offs); **o** cycles its role through `None` then `CUE_ROLES` (no-op on offs; used by populate-from-cue-roles); **c** cycles cut type through `none` / `hard` / `soft` on any cue. Blend and role are authored for the next on / visible period (like preset switching at on-transition); `canonicalize` strips `blend` and `role` from off cues but keeps `cut`. 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). Hard/soft cut 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% cut: soft blend: add role: lead` for on cues; `[mm:ss] cut: hard` for offs; LABEL prefixes and VALUE for time/values; `-` for unset blend/role; unset cut displays as `none`). +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?, cut?, anchor?)` 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//`; optional `anchor` keeps same-level hold keyframes through `canonicalize` (manual record drops). 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**-**N** (N = layer count, up to eight; 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. **Ctrl+1**-**N** during arm+record drops an anchored hold cue at the current level without toggling (nudge opacity after stop to build ramps). **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 cue on the focused lane (all cues, including offs). First press (or when selection is missing) 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** cycles the selected on cue's blend through inherit (`None`) then `BLEND_MODES` (no-op on offs); **o** cycles its role through `None` then `CUE_ROLES` (no-op on offs; used by populate-from-cue-roles); **c** cycles cut type through `none` / `hard` / `soft` on any cue. Blend and role are authored for the next on / visible period (like preset switching at on-transition); `canonicalize` strips `blend` and `role` from off cues but keeps `cut`. 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). Hard/soft cut 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% cut: soft blend: add role: lead` for on cues; `[mm:ss] cut: hard` for offs; LABEL prefixes and VALUE for time/values; `-` for unset blend/role; unset cut displays as `none`). ## Header rows section diff --git a/cleave/config_schema.py b/cleave/config_schema.py index 0b51b09..0e06aa9 100644 --- a/cleave/config_schema.py +++ b/cleave/config_schema.py @@ -2482,6 +2482,7 @@ def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None: cue_map["cut"], path=f"timeline.lanes.{slot}.cues[{index}].cut", ) + anchor = bool(cue_map.get("anchor", False)) cues.append( SlotCue( t=t, @@ -2489,6 +2490,7 @@ def parse_timeline_section(data: dict[str, Any], ctx: ParseCtx) -> Any | None: blend=blend, role=role, cut=cut, + anchor=anchor, ) ) lanes[str(slot)] = TimelineLane( @@ -2563,6 +2565,8 @@ def persist_timeline(ctx: PersistCtx) -> dict[str, Any]: cue_out["role"] = cue.role if cue.cut is not None: cue_out["cut"] = cue.cut + if cue.anchor: + cue_out["anchor"] = True cues_out.append(cue_out) entry["cues"] = cues_out lanes_out[slot] = entry diff --git a/cleave/timeline.py b/cleave/timeline.py index 09c34f5..2040d86 100644 --- a/cleave/timeline.py +++ b/cleave/timeline.py @@ -40,6 +40,7 @@ class SlotCue: blend: BlendMode | None = None role: CueRole | None = None cut: CutType | None = None + anchor: bool = False @dataclass @@ -159,6 +160,7 @@ def canonicalize( current_level is not None and levels_equal(cue.level, current_level) and cue.blend == current_blend + and not cue.anchor ): continue result.append(cue) diff --git a/cleave/viz/help_content.py b/cleave/viz/help_content.py index be4bcbe..dc58fba 100644 --- a/cleave/viz/help_content.py +++ b/cleave/viz/help_content.py @@ -180,21 +180,30 @@ def _preset_section(*, switching_on: bool = False) -> HelpSection: ) +def _layer_key_range_label(layer_count: int) -> str: + if layer_count <= 1: + return "1" + return f"1-{layer_count}" + + def timeline_strip_section( *, paused: bool, recording: bool, override_active: bool, + layer_count: int = 4, ) -> HelpSection: + layer_keys = _layer_key_range_label(layer_count) entries: list[tuple[str, str]] = [("A", "toggle arm track")] if not recording: entries.append(("Shift + Enter", "toggle override")) if paused or override_active: - entries.append(("1-4", "toggle layer visibility")) + entries.append((layer_keys, "toggle layer visibility")) if recording: - entries.append(("1-4", "toggle layer visibility")) + entries.append((layer_keys, "toggle layer visibility")) + entries.append((f"Ctrl + {layer_keys}", "drop cue (keep level)")) if recording: entries.append(("R", "stop record")) @@ -302,6 +311,7 @@ def sections_for( timeline_override_active: bool = False, preset_switching: str | None = None, preset_curation: bool = False, + layer_count: int = 4, ) -> tuple[HelpContent, ...]: nav = navigation_section(preset_curation=preset_curation) if timeline_submenu_focused: @@ -309,6 +319,7 @@ def sections_for( paused=paused, recording=timeline_recording, override_active=timeline_override_active, + layer_count=layer_count, ) description = _description_section(RowKind.RENDER_TIMELINE_HEADER) if description is not None: diff --git a/cleave/viz/help_overlay.py b/cleave/viz/help_overlay.py index 29abd05..6315721 100644 --- a/cleave/viz/help_overlay.py +++ b/cleave/viz/help_overlay.py @@ -304,6 +304,7 @@ def compose_panel( timeline_override_active: bool = False, preset_switching: str | None = None, preset_curation: bool = False, + layer_count: int = 4, ) -> ComposedHelpPanel | None: self._panel_rect = None font = self._font_get() @@ -316,6 +317,7 @@ def compose_panel( timeline_override_active=timeline_override_active, preset_switching=preset_switching, preset_curation=preset_curation, + layer_count=layer_count, ) sections = sections_for( focus.kind, @@ -327,6 +329,7 @@ def compose_panel( timeline_override_active=timeline_override_active, preset_switching=preset_switching, preset_curation=preset_curation, + layer_count=layer_count, ) panel_w, panel_h = compute_help_panel_size( font, diff --git a/cleave/viz/help_panel_cache.py b/cleave/viz/help_panel_cache.py index 045792a..52c2bc4 100644 --- a/cleave/viz/help_panel_cache.py +++ b/cleave/viz/help_panel_cache.py @@ -31,6 +31,7 @@ class HelpContentSignature: timeline_override_active: bool preset_switching: str | None preset_curation: bool = False + layer_count: int = 4 @dataclass @@ -51,6 +52,7 @@ def help_content_signature( timeline_override_active: bool, preset_switching: str | None, preset_curation: bool = False, + layer_count: int = 4, ) -> HelpContentSignature: return HelpContentSignature( kind=focus.kind, @@ -62,6 +64,7 @@ def help_content_signature( timeline_override_active=timeline_override_active, preset_switching=preset_switching, preset_curation=preset_curation, + layer_count=layer_count, ) @@ -250,6 +253,7 @@ def help_panel_max_dimensions( row_kind, effect_id=effect_id, preset_switching=preset_switching, + layer_count=8, **flags, ) panel_w, panel_h = compute_help_panel_size( diff --git a/cleave/viz/overlay_draw.py b/cleave/viz/overlay_draw.py index 1e6f9f1..bbf5620 100644 --- a/cleave/viz/overlay_draw.py +++ b/cleave/viz/overlay_draw.py @@ -109,6 +109,7 @@ def _help_compose_kwargs(view_state: TuningViewState) -> dict[str, object]: "timeline_override_active": view_state.timeline_override_active, "preset_switching": preset_switching, "preset_curation": view_state.settings.editor_mode == "preset_curation", + "layer_count": len(view_state.layer_z_order), } diff --git a/cleave/viz/timeline_controls.py b/cleave/viz/timeline_controls.py index c9f4d35..2b94dcf 100644 --- a/cleave/viz/timeline_controls.py +++ b/cleave/viz/timeline_controls.py @@ -154,6 +154,15 @@ def handle_keydown(self, event: pygame.event.Event) -> bool: if event.key in _LAYER_KEY_INDEX: tl = self.session.timeline + if mod_ctrl(event.mod): + if tl.recording: + slot = self._slot_for_layer_index(_LAYER_KEY_INDEX[event.key]) + if slot is not None: + self._drop_hold_cue_at( + slot, current_sec(self.playback, self.duration_sec) + ) + return True + if tl.recording: slot = self._slot_for_layer_index(_LAYER_KEY_INDEX[event.key]) if slot is not None: @@ -586,6 +595,23 @@ def _toggle_armed_layer_at(self, slot: str, t_sec: float) -> None: if self._on_visibility_change is not None: self._on_visibility_change() + def _drop_hold_cue_at(self, slot: str, t_sec: float) -> None: + tl = self.session.timeline + if slot not in tl.armed_slots or slot not in tl.record_baseline: + return + if not should_accept_toggle(self._last_toggle_t.get(slot), t_sec): + return + + level = armed_recording_level(self.session, slot, t_sec) + snapped = self._snap_placement(t_sec) + tl.record_buffer.setdefault(slot, []).append( + SlotCue(t=snapped, level=level, anchor=True) + ) + self._last_toggle_t[slot] = t_sec + + if self._on_visibility_change is not None: + self._on_visibility_change() + def _fill_record_at_seek(self, old_t: float, new_t: float) -> None: tl = self.session.timeline skip_start = min(old_t, new_t) diff --git a/tests/cleave/test_timeline.py b/tests/cleave/test_timeline.py index 0d101c0..61c9f1e 100644 --- a/tests/cleave/test_timeline.py +++ b/tests/cleave/test_timeline.py @@ -167,6 +167,21 @@ def test_canonicalize_drops_redundant_transitions() -> None: assert cues == [SlotCue(t=2.0, level=1.0)] +def test_canonicalize_keeps_anchored_same_level_cue() -> None: + cues = canonicalize( + 1.0, + [ + SlotCue(t=5.0, level=1.0), + SlotCue(t=10.0, level=1.0, anchor=True), + SlotCue(t=15.0, level=0.75), + ], + ) + assert cues == [ + SlotCue(t=10.0, level=1.0, anchor=True), + SlotCue(t=15.0, level=0.75), + ] + + def test_canonicalize_keeps_blend_only_change() -> None: cues = canonicalize( 0.0, diff --git a/tests/cleave/viz/test_help_overlay.py b/tests/cleave/viz/test_help_overlay.py index 4693f77..3281cc3 100644 --- a/tests/cleave/viz/test_help_overlay.py +++ b/tests/cleave/viz/test_help_overlay.py @@ -544,7 +544,9 @@ def test_curation_navigation_omits_disabled_global_hotkeys() -> None: def test_timeline_strip_help_paused() -> None: - section = timeline_strip_section(paused=True, recording=False, override_active=False) + section = timeline_strip_section( + paused=True, recording=False, override_active=False, layer_count=4 + ) keys = [key for key, _ in section.entries] assert keys.index("Shift + Enter") + 1 == keys.index("1-4") entries = dict(section.entries) @@ -562,13 +564,23 @@ def test_timeline_strip_help_paused() -> None: assert "Left/Right" in entries +def test_timeline_strip_help_single_layer() -> None: + entries = dict( + timeline_strip_section( + paused=True, recording=False, override_active=False, layer_count=1 + ).entries + ) + assert entries["1"] == "toggle layer visibility" + assert "1-4" not in entries + + def test_timeline_strip_help_playing_without_override() -> None: keys = _timeline_keys(paused=False, timeline_recording=False, timeline_override_active=False) assert "1-4" not in keys assert "Shift + Enter" in keys assert dict( timeline_strip_section( - paused=False, recording=False, override_active=False + paused=False, recording=False, override_active=False, layer_count=4 ).entries )["Space"] == "pause" @@ -576,18 +588,21 @@ def test_timeline_strip_help_playing_without_override() -> None: def test_timeline_strip_help_playing_with_override() -> None: entries = dict( timeline_strip_section( - paused=False, recording=False, override_active=True + paused=False, recording=False, override_active=True, layer_count=6 ).entries ) - assert entries["1-4"] == "toggle layer visibility" + assert entries["1-6"] == "toggle layer visibility" def test_timeline_strip_help_recording_while_playing() -> None: entries = dict( - timeline_strip_section(paused=False, recording=True, override_active=False).entries + timeline_strip_section( + paused=False, recording=True, override_active=False, layer_count=4 + ).entries ) assert "Ctrl + Enter" not in entries assert entries["1-4"] == "toggle layer visibility" + assert entries["Ctrl + 1-4"] == "drop cue (keep level)" assert "Shift + Enter" not in entries assert entries["Left/Right"] == "skip 10s, fills range" assert entries["Shift + Left/Right"] == "skip 2s, fills range" diff --git a/tests/cleave/viz/test_timeline_controls.py b/tests/cleave/viz/test_timeline_controls.py index ed21abc..a29e2e6 100644 --- a/tests/cleave/viz/test_timeline_controls.py +++ b/tests/cleave/viz/test_timeline_controls.py @@ -578,6 +578,65 @@ def test_layer_keys_only_affect_armed_stems() -> None: assert session.timeline.record_buffer == {"layer_1": [SlotCue(t=2.0, level=0.0)]} +def test_ctrl_layer_key_drops_anchored_hold_cue() -> None: + controls, session, _, _, _, _ = _make_timeline_controls( + armed_slots={"layer_1"}, + position_sec=5.0, + lanes={"layer_1": _lane(True)}, + ) + session.layers["layer_1"].enabled = True + + controls.handle_keydown(keydown(pygame.K_r)) + controls.handle_keydown(keydown(pygame.K_1, mod=pygame.KMOD_CTRL)) + + assert session.timeline.record_buffer == { + "layer_1": [SlotCue(t=5.0, level=1.0, anchor=True)] + } + + +def test_ctrl_layer_key_ignored_when_unarmed() -> None: + controls, session, _, _, _, _ = _make_timeline_controls( + armed_slots={"layer_1"}, + position_sec=5.0, + ) + session.layers["layer_1"].enabled = True + session.layers["layer_2"].enabled = True + + controls.handle_keydown(keydown(pygame.K_r)) + controls.handle_keydown(keydown(pygame.K_2, mod=pygame.KMOD_CTRL)) + + assert session.timeline.record_buffer == {} + + +def test_ctrl_layer_key_noop_when_not_recording() -> None: + controls, session, visibility_calls, _, _, _ = _make_timeline_controls( + armed_slots={"layer_1"}, + position_sec=5.0, + ) + session.layers["layer_1"].enabled = True + + controls.handle_keydown(keydown(pygame.K_1, mod=pygame.KMOD_CTRL)) + + assert session.timeline.record_buffer == {} + assert visibility_calls == [] + + +def test_stop_record_preserves_anchored_hold_cue() -> None: + controls, session, _, _, _, _ = _make_timeline_controls( + armed_slots={"layer_1"}, + position_sec=5.0, + lanes={"layer_1": _lane(True)}, + ) + session.layers["layer_1"].enabled = True + + controls.handle_keydown(keydown(pygame.K_r)) + controls.handle_keydown(keydown(pygame.K_1, mod=pygame.KMOD_CTRL)) + controls.handle_keydown(keydown(pygame.K_r)) + + lane = session.timeline.lanes["layer_1"] + assert any(cue.anchor and cue.t == 5.0 and cue.level == 1.0 for cue in lane.cues) + + def test_numpad_layer_keys_work_while_recording() -> None: controls, session, _, _, _, _ = _make_timeline_controls( armed_slots={"layer_1"},