Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .cursor/rules/live-tuning-ui.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,26 @@ Confirm, save-choice, and unsaved-quit prompts are drawn by [cleave/viz/modal_ov

The scrollable block below the pinned header: all rows where `row_kind` is not in `HEADER_ROW_KINDS` from [cleave/viz/row_semantics.py](cleave/viz/row_semantics.py) (`track_row_count` counts these rows from `build_row_layout`). Row interaction groups (sub-row frozensets, repeat keys, help affordances) are defined in the same module.

- One **track block** per stem in `layer_z_order` (layer slot keys `layer_1`..`layer_4`; use `row_slot()` in [cleave/viz/overlay.py](cleave/viz/overlay.py) to read the slot from a row index).
- One **track block** per slot in `layer_z_order` (dynamic slots `layer_1`..`layer_8`; default four; use `row_slot()` in [cleave/viz/overlay.py](cleave/viz/overlay.py) to read the slot from a row index).
- **Base rows** (always present per stem): header, preset dir, preset, stem, blend, opacity, beat sensitivity, then **cleave effects** header (`RowKind.TRACK_EFFECTS_HEADER`).
- **Stem row** (`RowKind.TRACK_STEM`): Left/Right cycles `STEM_SOURCES`; follows standard locked sub-row rules (blocked when layer locked; only **cleave effects** header remains navigable among locked sub-rows).
- **Effect sub-rows** (`RowKind.TRACK_EFFECT`): one per entry in [cleave/effects/registry.py](cleave/effects/registry.py) for that stem; visible only when both the track block and `TrackBlock.effects_expanded` are expanded.
- **Layer management rows** (after the last track block's effect sub-rows, before the render section gap): `RowKind.LAYER_MANAGEMENT_DELETE` (one per track block; Enter deletes that layer when more than one remains) and `RowKind.LAYER_MANAGEMENT_ADD` (single row at the bottom of the track section when fewer than eight layers; Enter adds a layer). Both are action rows (`RowAffordance.ACTION`); delete stays navigable when the layer is locked.
- Per-stem state: `TrackBlock` in `TuningViewState.tracks` (`effects`, `effects_expanded`, plus preset/blend/opacity/beat fields).
- On-screen header label: `Layer N: STEM` (user-facing "layer"; code often says "track").
- **cleave effects** header: lowercase label `└─ cleave effects` plus expand arrow; collapsed by default; sits after beat sensitivity; Left/Right toggles `effects_expanded`.

Say **track rows section** for the whole upper block; **track row** for a single line.

After the four stem blocks, a blank **render section gap** row (`RowKind.RENDER_SECTION_GAP`) separates stems from render blocks. Then **Render: OVERLAY** (`RowKind.RENDER_OVERLAY_HEADER`) is always present. It is not reorderable (Enter does not enter move mode). Header label: `Render:` in `LABEL`, `OVERLAY` and expand arrow in value color. Sub-rows when expanded: position, opacity, border width, start delay, display time, title (expandable; font, font size, and margin-bottom sub-rows when expanded), body (expandable; font and font size sub-rows when expanded). State: `RenderOverlayBlock` / `RenderOverlayRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)); solo via `render_overlay_solo` (no audio impact). Disable clears solo and collapses.
After the last track block (and its layer management rows), a blank **render section gap** row (`RowKind.RENDER_SECTION_GAP`) separates stems from render blocks. Then **Render: OVERLAY** (`RowKind.RENDER_OVERLAY_HEADER`) is always present. It is not reorderable (Enter does not enter move mode). Header label: `Render:` in `LABEL`, `OVERLAY` and expand arrow in value color. Sub-rows when expanded: position, opacity, border width, start delay, display time, title (expandable; font, font size, and margin-bottom sub-rows when expanded), body (expandable; font and font size sub-rows when expanded). State: `RenderOverlayBlock` / `RenderOverlayRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)); solo via `render_overlay_solo` (no audio impact). Disable clears solo and collapses.

Below overlay, **Render: POST FX** (`RowKind.RENDER_POST_FX_HEADER`) is always present. Same eye / expand / solo semantics as overlay (`render_post_fx_solo`; solo is not persisted). Sub-rows when expanded: fade in, fade out (seconds). State: `RenderPostFxBlock` / `RenderPostFxRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). Disable clears solo and collapses.

Below post-FX, **Render: TIMELINE** (`RowKind.RENDER_TIMELINE_HEADER`) is always present. 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. State: `RenderTimelineBlock` / `TimelineRuntime` on session ([cleave/viz/session.py](cleave/viz/session.py)). `enabled` persists via config snapshot; `panel_open` is UI-only. No sub-rows in v1.

## 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** when `timeline.enabled` (strip only; **Down** enters submenu). **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 and enabled, **Up**/**Down** treat timeline rows as part of the main focus ring: order is main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, wrapping **Down** from the last timeline row to **TRANSPORT** and **Up** from **TRANSPORT** 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 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_stems` or when recording and the row is armed). Armed rows during record use `record_baseline` + `record_buffer` toggles only, not committed cues. Record start baseline is not drawn on the bar; only transition cues in `record_buffer` show ticks., **right** = committed cues at playhead only (`timeline_committed_visible`; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **Enter** 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**-**4** 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_stems` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_stems` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip does not use **Shift+Left** / **Shift+Right** for solo (transport seek). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed 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_stems` / `override_visible` are session-only on `TimelineRuntime`. Cue list persists under root `timeline:` in YAML (written last in snapshots). See [docs/timeline-idea.md](docs/timeline-idea.md).
Separate bottom overlay ([cleave/viz/timeline_overlay.py](cleave/viz/timeline_overlay.py), [cleave/viz/timeline_controls.py](cleave/viz/timeline_controls.py)). Open with **t** (opens strip and enters submenu on row 0) or **Right** on **Render: TIMELINE** when `timeline.enabled` (strip only; **Down** enters submenu). **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 and enabled, **Up**/**Down** treat timeline rows as part of the main focus ring: order is main navigable rows (ending at **Render: TIMELINE**), then timeline rows 0..N-1, wrapping **Down** from the last timeline row to **TRANSPORT** and **Up** from **TRANSPORT** 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 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_stems` or when recording and the row is armed). Armed rows during record use `record_baseline` + `record_buffer` toggles only, not committed cues. Record start baseline is not drawn on the bar; only transition cues in `record_buffer` show ticks., **right** = committed cues at playhead only (`timeline_committed_visible`; updates on seek while paused preview leaves left eye on `TimelineRuntime.monitor`). **Enter** 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_stems` only. **Shift+Enter** toggles override on the focused row while playing or paused (manual override via `override_stems` / `override_visible`; clears preview on enter; distinct from main-panel `session.solo_slot`; ignored when recording). Timeline strip does not use **Shift+Left** / **Shift+Right** for solo (transport seek). **r** start captures WYSIWYG baseline for armed stems, preserves override on unarmed stems, clears preview, unpauses if needed; stop punch-overwrites armed 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_stems` / `override_visible` are session-only on `TimelineRuntime`. Cue list persists under root `timeline:` in YAML (written last in snapshots). See [docs/timeline-idea.md](docs/timeline-idea.md).

## Header rows section

Expand Down
2 changes: 1 addition & 1 deletion .cursor/rules/project-context.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ alwaysApply: true

# Cleave project context

- BAU iterative development. Visualizer: `python -m cleave play` via [cleave/cli.py](cleave/cli.py) calling `cleave.viz.launch()`; [cleave.py](cleave.py) is an alias; implementation in [cleave/viz/](cleave/viz/) (four libprojectM layers, 1280x720 / 30 fps default, stem PCM). Black-key stack in [cleave/gl_compositor.py](cleave/gl_compositor.py). Live tuning: session in [cleave/viz/session.py](cleave/viz/session.py), input in [cleave/viz/controls.py](cleave/viz/controls.py), view state in [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py), overlay draw in [cleave/viz/overlay.py](cleave/viz/overlay.py); shared live/offline frame finish in [cleave/viz/frame_finish.py](cleave/viz/frame_finish.py). Render credits overlay in [cleave/viz/render_overlay.py](cleave/viz/render_overlay.py) (`render.overlay` in YAML): live preview in play, burned in by [cleave/viz/render.py](cleave/viz/render.py) offline. Cleave effects: [cleave/effects/](cleave/effects/) (dispatch via [cleave/effects/handlers.py](cleave/effects/handlers.py)). Paths: [cleave/paths.py](cleave/paths.py) (repo-root data dir by default, `projects/<slug>/`; `CLEAVE_DATA` override). Config: parse and defaults in [cleave/config_schema.py](cleave/config_schema.py); repo-root [cleave-viz.yaml](cleave-viz.yaml) copied into projects; `unnamed-N.yaml` snapshots via [cleave/config_snapshot.py](cleave/config_snapshot.py). Shared easing: [cleave/easing.py](cleave/easing.py). Architecture conventions: [.cursor/rules/architecture-principles.mdc](.cursor/rules/architecture-principles.mdc).
- BAU iterative development. Visualizer: `python -m cleave play` via [cleave/cli.py](cleave/cli.py) calling `cleave.viz.launch()`; [cleave.py](cleave.py) is an alias; implementation in [cleave/viz/](cleave/viz/) (up to eight Milkdrop/libprojectM layers, default four; add/remove in live tuning, 1280x720 / 30 fps default, stem PCM). Black-key stack in [cleave/gl_compositor.py](cleave/gl_compositor.py). Live tuning: session in [cleave/viz/session.py](cleave/viz/session.py), input in [cleave/viz/controls.py](cleave/viz/controls.py), view state in [cleave/viz/tuning_view_state.py](cleave/viz/tuning_view_state.py), overlay draw in [cleave/viz/overlay.py](cleave/viz/overlay.py); shared live/offline frame finish in [cleave/viz/frame_finish.py](cleave/viz/frame_finish.py). Render credits overlay in [cleave/viz/render_overlay.py](cleave/viz/render_overlay.py) (`render.overlay` in YAML): live preview in play, burned in by [cleave/viz/render.py](cleave/viz/render.py) offline. Cleave effects: [cleave/effects/](cleave/effects/) (dispatch via [cleave/effects/handlers.py](cleave/effects/handlers.py)). Paths: [cleave/paths.py](cleave/paths.py) (repo-root data dir by default, `projects/<slug>/`; `CLEAVE_DATA` override). Config: parse and defaults in [cleave/config_schema.py](cleave/config_schema.py); repo-root [cleave-viz.yaml](cleave-viz.yaml) copied into projects; `unnamed-N.yaml` snapshots via [cleave/config_snapshot.py](cleave/config_snapshot.py). Shared easing: [cleave/easing.py](cleave/easing.py). Architecture conventions: [.cursor/rules/architecture-principles.mdc](.cursor/rules/architecture-principles.mdc).
- Must-do list: [docs/todos.md](docs/todos.md). Aspirational: [docs/roadmap.md](docs/roadmap.md).
- See [README.md](README.md) for usage.
- Project layout: `projects/<slug>/` under repo root (or `CLEAVE_DATA` override) with copied mix audio, `project.yaml`, `signals.json`, `stems/` (four stem wavs), and optional configs. `separate` copies the source file, writes the manifest, runs Demucs, and writes `signals.json`; the visualizer reads the mix from `project.yaml`. CLI: `python -m cleave separate|play` (or `python cleave.py`, same entry point).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Controls...

#### Compositing

* The visualizer is four libprojectM layers at tiered resolutions, composited to **1280x720 @ 30 fps** by default (editable `cleave-viz.yaml`)
* The visualizer supports up to eight libprojectM layers (default four; add/remove in live tuning) at tiered resolutions, composited to **1280x720 @ 30 fps** by default (editable `cleave-viz.yaml`)
* Each layer's libprojectM instance receives PCM from its assigned stem; stereo stems are fed as stereo (mono sources stay mono).
* Milkdrop draws on black, so cleave treats black as transparent and uses pixel brightness as blend weight (`black-key` default).

Expand Down
18 changes: 11 additions & 7 deletions cleave/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,13 @@ class TimelineConfig:
cues: tuple[TimelineCue, ...]


@dataclass(frozen=True)
@dataclass
class CleaveConfig:
paths: PathsConfig
layers: dict[str, LayerConfig]
visualizer: VisualizerConfig
config_path: Path
layer_z_order: tuple[str, ...] = DEFAULT_LAYER_Z_ORDER
layer_z_order: list[str] = field(default_factory=lambda: list(DEFAULT_LAYER_Z_ORDER))
render: RenderConfig | None = None
timeline: TimelineConfig | None = None

Expand Down Expand Up @@ -282,10 +282,14 @@ def _validate_presets(layers: dict[str, LayerConfig]) -> None:
)


def _parse_layers(data: dict[str, Any], preset_root: Path) -> dict[str, LayerConfig]:
def _parse_layers(
data: dict[str, Any], preset_root: Path
) -> tuple[dict[str, LayerConfig], "ParseCtx"]:
from cleave.config_schema import ParseCtx

return parse_layers_section(data, ParseCtx(preset_root=preset_root))
ctx = ParseCtx(preset_root=preset_root)
layers = parse_layers_section(data, ctx)
return layers, ctx


def load_config(
Expand All @@ -311,9 +315,9 @@ def load_config(
paths = _parse_paths(data)
visualizer = parse_visualizer_section(data)
render = parse_render_section(data)
timeline = parse_timeline_section(data)
layer_z_order = parse_layer_z_order_section(data)
layers = _parse_layers(data, paths.preset_root)
layers, parse_ctx = _parse_layers(data, paths.preset_root)
layer_z_order = parse_layer_z_order_section(data, parse_ctx)
timeline = parse_timeline_section(data, parse_ctx)
_validate_presets(layers)

return CleaveConfig(
Expand Down
Loading
Loading