Skip to content
3 changes: 2 additions & 1 deletion .cursor/rules/live-tuning-ui.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Below overlay, **Render: POST FX** (`RowKind.RENDER_POST_FX_HEADER`) is always p

**Section lock** (overlay, post-FX, timeline, and layer tracks share one mechanism in [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py)): persisted `locked` on `RenderOverlayConfig` / `RenderPostFxConfig` / `TimelineConfig` and the matching runtimes. **l** on `RENDER_OVERLAY_HEADER`, `RENDER_POST_FX_HEADER`, `RENDER_TIMELINE_HEADER` (or `TRACK_HEADER`) toggles it and draws a red `LOCK_ICON` on the header. When locked: the header still expands/opens and expandable child headers stay navigable, but value and action children are drawn in `LOCKED` and skipped in navigation (`section_locked(state, desc)` with `row_navigable_when_section_locked` / `row_blocked_by_section_lock`); **Ctrl** enable/disable is refused while solo stays allowed. `section_locked` accepts either a `TuningViewState` (tracks / `render_timeline`) or a `TuningSession` (layers / `timeline`).

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; `panel_open` is UI-only. No sub-rows in v1. When the timeline is locked, its main-panel children (`TIMELINE_PRESETS`, bar phase, bar grid, snap to beats/bars) are blocked and skipped in navigation, and the strip stays openable and seekable (Seek Left/Right) but blocks arm, record, override, number-key visibility, Space preview, and Ctrl+Space record ([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; `panel_open` is UI-only. No sub-rows in v1. When the timeline is locked, its main-panel children (`TIMELINE_PRESETS`, bar phase, bar grid, snap to beats/bars/song markers, marker proximity) are blocked and skipped in navigation, and the strip stays openable and seekable (Seek Left/Right) but blocks arm, record, override, number-key visibility, Space preview, and Ctrl+Space record ([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

Expand Down Expand Up @@ -78,6 +78,7 @@ Colors live in [cleave/viz/theme.py](cleave/viz/theme.py). Label truncation uses
| Label | `LABEL` | `(170, 210, 255)` | Light blue prefixes and section titles |
| Value | `VALUE` | `(255, 255, 255)` | Default white for values and state indicators |
| Action | `ACTION` | `(80, 190, 125)` | Modal confirm rows (save config, add/delete layer) |
| Action parameter | `ACTION` / `VALUE` | `(80, 190, 125)` / `(255, 255, 255)` | Label prefix in ACTION green; value in white VALUE |
| Disabled | `DISABLED` | `(140, 140, 140)` | Inactive rows and controls |
| Locked | `LOCKED` | `(235, 150, 150)` | Locked sub-rows that cannot be edited |

Expand Down
36 changes: 26 additions & 10 deletions cleave/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
from __future__ import annotations

import sys
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from pathlib import Path
from typing import Sequence

import yaml

Expand All @@ -21,6 +22,7 @@ class ProjectManifest:
separated_at: str
demucs_model: str
restored_from: str | None = None
song_markers: tuple[float, ...] = ()

@classmethod
def from_dict(cls, data: dict) -> ProjectManifest:
Expand All @@ -33,6 +35,13 @@ 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"]),
Expand All @@ -41,6 +50,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,
)

def to_dict(self) -> dict:
Expand All @@ -56,6 +66,8 @@ 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]
return data


Expand All @@ -71,15 +83,7 @@ def rewrite_manifest_slug(
) -> Path:
"""Update ``project.yaml`` *slug* and optional ``restored-from`` provenance."""
manifest = load_manifest(project_dir)
updated = ProjectManifest(
version=manifest.version,
slug=slug,
mix_filename=manifest.mix_filename,
original_path=manifest.original_path,
separated_at=manifest.separated_at,
demucs_model=manifest.demucs_model,
restored_from=restored_from,
)
updated = replace(manifest, slug=slug, restored_from=restored_from)
path = manifest_path(project_dir)
with path.open("w", encoding="utf-8") as handle:
yaml.safe_dump(updated.to_dict(), handle, sort_keys=False)
Expand All @@ -105,6 +109,7 @@ def write_manifest(
original_path: Path,
demucs_model: str,
separated_at: datetime | None = None,
song_markers: Sequence[float] = (),
) -> Path:
when = separated_at or datetime.now(timezone.utc)
manifest = ProjectManifest(
Expand All @@ -114,13 +119,24 @@ def write_manifest(
original_path=str(original_path.resolve()),
separated_at=when.isoformat(),
demucs_model=demucs_model,
song_markers=tuple(float(t) for t in song_markers),
)
path = manifest_path(project_dir)
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:
"""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))
path = manifest_path(project_dir)
with path.open("w", encoding="utf-8") as handle:
yaml.safe_dump(updated.to_dict(), handle, sort_keys=False)
return path


def mix_path(project_dir: Path) -> Path:
manifest = load_manifest(project_dir)
return project_dir / manifest.mix_filename
Expand Down
64 changes: 64 additions & 0 deletions cleave/song_markers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Pure domain helpers for project-scoped song markers."""

from __future__ import annotations

import bisect
from typing import Sequence


def nearest_index(times: Sequence[float], t: float) -> int:
"""Return the index of the song marker nearest to ``t``.

On an exact distance tie, prefers the earlier marker (lower index).
"""
if not times:
raise ValueError("nearest_index requires at least one song marker")
best_i = 0
best_d = abs(times[0] - t)
for i in range(1, len(times)):
d = abs(times[i] - t)
if d < best_d:
best_i = i
best_d = d
return best_i


def place_marker(
times: Sequence[float],
t: float,
window: float = 2.0,
) -> tuple[tuple[float, ...], 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.

Returns ``(new_times, replaced_index, replaced_time)``. On replace,
``replaced_index`` is the index of the new marker in ``new_times`` and
``replaced_time`` is the previous time. On insert, both are ``None``.
"""
if not times:
return (float(t),), None, None

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))
return tuple(updated), new_idx, old

updated = [float(x) for x in times]
bisect.insort(updated, float(t))
return tuple(updated), None, None


def format_marker_time(t: float) -> str:
"""Format a song marker time as ``mm:ss.cc`` (minutes, seconds, hundredths)."""
total_hundredths = max(0, int(round(float(t) * 100.0)))
minutes = total_hundredths // 6000
seconds = (total_hundredths % 6000) // 100
hundredths = total_hundredths % 100
return f"{minutes:02d}:{seconds:02d}.{hundredths:02d}"
95 changes: 95 additions & 0 deletions cleave/timeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Literal

import numpy as np

Expand Down Expand Up @@ -305,3 +306,97 @@ def snap_t(t: float) -> float:
baseline=lane.baseline,
cues=canonicalize(lane.baseline, snapped),
)


SongMarkerSnapMode = Literal["each_layer", "closest_wins"]


def snap_lanes_to_song_markers(
lanes: Mapping[str, TimelineLane],
song_marker_times: Sequence[float],
*,
proximity: float,
layer_z_order: Sequence[str],
slots: Sequence[str],
mode: SongMarkerSnapMode = "each_layer",
) -> tuple[dict[str, TimelineLane], int]:
"""Move closest cues within ``proximity`` onto song markers; exclusive claims.

Markers are processed in ascending time. Each cue moves at most once.
``each_layer`` claims per lane; ``closest_wins`` shares one claim set across
``slots`` (tie: earlier cue time, then earlier ``layer_z_order`` index).

Returns updated lanes for ``slots`` (other keys unchanged) and move count.
"""
result = {slot: copy_lane(lane) for slot, lane in lanes.items()}
if proximity <= 0 or not song_marker_times or not slots:
return result, 0

markers = sorted(float(t) for t in song_marker_times)
target_slots = [slot for slot in slots if slot]
if not target_slots:
return result, 0

for slot in target_slots:
if slot not in result:
result[slot] = empty_lane()

working: dict[str, list[SlotCue]] = {
slot: list(result[slot].cues) for slot in target_slots
}
moved = 0

if mode == "each_layer":
for slot in target_slots:
claimed: set[int] = set()
cues = working[slot]
for marker in markers:
best_i: int | None = None
best_key: tuple[float, float] | None = None
for i, cue in enumerate(cues):
if i in claimed:
continue
dist = abs(cue.t - marker)
if dist > proximity:
continue
key = (dist, cue.t)
if best_key is None or key < best_key:
best_i = i
best_key = key
if best_i is None:
continue
old = cues[best_i]
cues[best_i] = SlotCue(t=marker, visible=old.visible)
claimed.add(best_i)
moved += 1
else:
z_index = {slot: i for i, slot in enumerate(layer_z_order)}
claimed_pairs: set[tuple[str, int]] = set()
for marker in markers:
best: tuple[float, float, int, str, int] | None = None
for slot in target_slots:
cues = working[slot]
for i, cue in enumerate(cues):
if (slot, i) in claimed_pairs:
continue
dist = abs(cue.t - marker)
if dist > proximity:
continue
key = (dist, cue.t, z_index.get(slot, len(layer_z_order)), slot, i)
if best is None or key < best:
best = key
if best is None:
continue
_dist, _t, _z, slot, cue_i = best
old = working[slot][cue_i]
working[slot][cue_i] = SlotCue(t=marker, visible=old.visible)
claimed_pairs.add((slot, cue_i))
moved += 1

for slot in target_slots:
baseline = result[slot].baseline
result[slot] = TimelineLane(
baseline=baseline,
cues=canonicalize(baseline, working[slot]),
)
return result, moved
47 changes: 42 additions & 5 deletions cleave/timeline_presets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
DIALOGUE,
PULSE,
)
from cleave.timeline_presets.motifs import MIN_SWITCH_GAP_BARS, MIN_SWITCH_GAP_SEC
from cleave.timeline_presets.motifs import (
MIN_SWITCH_GAP_BARS,
MIN_SWITCH_GAP_SEC,
SOFT_LATCH_PROXIMITY_SEC,
)

_RESET_HELP_ENTRIES: tuple[tuple[str, str], ...] = (
("Reset (All Off)", "clear cues; every layer off for the whole track."),
Expand All @@ -30,6 +34,7 @@
"MIN_SWITCH_GAP_BARS",
"MIN_SWITCH_GAP_SEC",
"PHRASE_SEC_MIN",
"SOFT_LATCH_PROXIMITY_SEC",
"TIMELINE_PRESET_HELP_ENTRIES",
"build_arc_cues",
"build_breathing_cues",
Expand All @@ -47,35 +52,67 @@ def build_breathing_cues(
duration_sec: float,
rng: random.Random | None = None,
bar_times: Sequence[float] = (),
song_marker_times: Sequence[float] = (),
) -> dict[str, TimelineLane]:
return compose_timeline(slots, duration_sec, BREATHING, _rng(rng), bar_times)
return compose_timeline(
slots,
duration_sec,
BREATHING,
_rng(rng),
bar_times,
song_marker_times=song_marker_times,
)


def build_dialogue_cues(
slots: Sequence[str],
duration_sec: float,
rng: random.Random | None = None,
bar_times: Sequence[float] = (),
song_marker_times: Sequence[float] = (),
) -> dict[str, TimelineLane]:
return compose_timeline(slots, duration_sec, DIALOGUE, _rng(rng), bar_times)
return compose_timeline(
slots,
duration_sec,
DIALOGUE,
_rng(rng),
bar_times,
song_marker_times=song_marker_times,
)


def build_arc_cues(
slots: Sequence[str],
duration_sec: float,
rng: random.Random | None = None,
bar_times: Sequence[float] = (),
song_marker_times: Sequence[float] = (),
) -> dict[str, TimelineLane]:
return compose_timeline(slots, duration_sec, ARC, _rng(rng), bar_times)
return compose_timeline(
slots,
duration_sec,
ARC,
_rng(rng),
bar_times,
song_marker_times=song_marker_times,
)


def build_pulse_cues(
slots: Sequence[str],
duration_sec: float,
rng: random.Random | None = None,
bar_times: Sequence[float] = (),
song_marker_times: Sequence[float] = (),
) -> dict[str, TimelineLane]:
return compose_timeline(slots, duration_sec, PULSE, _rng(rng), bar_times)
return compose_timeline(
slots,
duration_sec,
PULSE,
_rng(rng),
bar_times,
song_marker_times=song_marker_times,
)


ALL_BUILDERS = (
Expand Down
Loading
Loading