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
6 changes: 3 additions & 3 deletions .cursor/rules/live-tuning-ui.mdc

Large diffs are not rendered by default.

22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 1 addition & 3 deletions cleave/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = (
Expand Down
19 changes: 4 additions & 15 deletions cleave/config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -2496,13 +2482,15 @@ 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,
level=clamp_level(float(cue_map["level"])),
blend=blend,
role=role,
cut=cut,
anchor=anchor,
)
)
lanes[str(slot)] = TimelineLane(
Expand Down Expand Up @@ -2550,7 +2538,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,
Expand Down Expand Up @@ -2578,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
Expand Down
75 changes: 60 additions & 15 deletions cleave/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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"]),
Expand All @@ -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:
Expand All @@ -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


Expand Down Expand Up @@ -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,
*,
Expand All @@ -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.

Expand All @@ -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
)
Expand All @@ -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)
Expand Down
Loading
Loading