diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd5d1a..f7b0a27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **`chart-transform` capability domain (#952)** — plugins can now remap the - chart before rendering and scoring through a core-owned provider +- **Multiple drum parts (feedpak 1.17.0 "drums as arrangements").** The sloppak + loader now reads `type: drums` arrangement entries carrying per-arrangement + `drum_tab` file pointers — a song can ship several drum charts (a second + drummer, an aux-percussion layer). Parts surface as `LoadedSloppak.drum_parts` + (primary first; the entry aliasing the song-level `drum_tab:` key is the + primary and is never loaded twice), the highway WS `song_info` gains a + `drum_parts` name list, and `?drum_part=` on the WS URL selects which + part's tab streams (`drum_tab` messages carry `part_id` when a parts list + exists; unknown ids fall back to the primary). Pointer entries are **never** + loaded as fretted arrangements — the loader's file/notation gate keeps a drum + part out of the fretted pipeline (and out of note-detection grading), pinned + by test. Legacy single-drum packs read exactly as before, as a one-part list. coordinator. Synchronous transforms run after difficulty filtering; host data is isolated from providers, accepted timelines are time-sorted, and failures fall back to the original chart with a fixed public reason. diff --git a/lib/routers/ws_highway.py b/lib/routers/ws_highway.py index 7950a6c..c34653f 100644 --- a/lib/routers/ws_highway.py +++ b/lib/routers/ws_highway.py @@ -144,8 +144,15 @@ def _sanitize_authors(manifest: dict | None) -> list[dict]: @router.websocket("/ws/highway/{filename:path}") -async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, naming_mode: str = "legacy"): - """Stream song data for the highway renderer over WebSocket.""" +async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, + naming_mode: str = "legacy", drum_part: str = ""): + """Stream song data for the highway renderer over WebSocket. + + `drum_part` selects WHICH drum part's tab streams when the pack carries + several (feedpak 1.17.0 "drums as arrangements") — a part id from + song_info's `drum_parts`. Empty / unknown ids fall back to the primary, + so a stale or mistyped selection degrades to today's behavior instead of + silencing drums.""" await websocket.accept() structlog.contextvars.bind_contextvars(ws_conn_id=uuid.uuid4().hex[:8]) @@ -564,6 +571,15 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, "has_drum_tab": bool( is_slop and loaded_slop is not None and loaded_slop.drum_tab is not None ), + # The song's DRUM PARTS (feedpak 1.17.0 "drums as arrangements"), + # primary first — names only; the selected part's payload streams + # as the `drum_tab`/`drum_hits` messages below. Always a list + # (empty when the pack has no drums, and a single entry for a + # legacy one-drum pack), so a part picker can bind unconditionally. + "drum_parts": [ + {"id": p["id"], "name": p["name"]} + for p in (loaded_slop.drum_parts or []) + ] if is_slop and loaded_slop is not None else [], "has_notation": bool( is_slop and loaded_slop is not None @@ -587,18 +603,35 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, # client-side drums plugin keeps a fallback decoder for them. if is_slop and loaded_slop is not None and loaded_slop.drum_tab is not None: dt = loaded_slop.drum_tab + # Multiple drum parts: `?drum_part=` picks which part's tab + # streams; the default (and any unknown id) is the PRIMARY — + # exactly the pre-parts behavior, so legacy clients notice nothing. + _dt_part_id = None + if loaded_slop.drum_parts: + _dt_part_id = loaded_slop.drum_parts[0]["id"] + if drum_part: + for _p in loaded_slop.drum_parts: + if _p["id"] == drum_part: + dt = _p["drum_tab"] + _dt_part_id = _p["id"] + break kit = drums_mod.normalise_kit(dt.get("kit")) hits_wire = drums_mod.hits_to_wire(dt.get("hits") or []) _dt_name = dt.get("name") _dt_name = _dt_name if isinstance(_dt_name, str) and _dt_name else "Drums" + _dt_msg = { + "type": "drum_tab", + "version": int(dt.get("version", drums_mod.SCHEMA_VERSION)), + "name": _dt_name, + "kit": kit, + "total": len(hits_wire), + } + # Which part this stream carries — present only when the pack has + # a parts list, so the legacy frame stays byte-identical. + if _dt_part_id is not None: + _dt_msg["part_id"] = _dt_part_id try: - await websocket.send_json({ - "type": "drum_tab", - "version": int(dt.get("version", drums_mod.SCHEMA_VERSION)), - "name": _dt_name, - "kit": kit, - "total": len(hits_wire), - }) + await websocket.send_json(_dt_msg) for i in range(0, len(hits_wire), 500): await websocket.send_json({ "type": "drum_hits", diff --git a/lib/sloppak.py b/lib/sloppak.py index dd806c4..2177887 100644 --- a/lib/sloppak.py +++ b/lib/sloppak.py @@ -730,6 +730,50 @@ class LoadedSloppak: # separated stems the moment one drops below 100% — demucs recombination is # lossy, so the mixdown is strictly the better audio when nothing is muted. full_mix: str | None = None + # The song's DRUM PARTS (feedpak 1.17.0 "drums as arrangements"): one dict + # {"id", "name", "drum_tab"} per part, primary FIRST. A part comes from a + # `type: drums` arrangement entry carrying a per-arrangement `drum_tab` + # file pointer and NO note `file` — entries this loader deliberately never + # turns into fretted Arrangements (see the file/notation gate in + # load_song; that skip IS the grading invariant). The primary part's + # payload is the SAME object as `drum_tab` above (the song-level key is + # its back-compat alias). None when the pack has no drums at all; a + # single-part list for a legacy pack with only the song-level key. + drum_parts: list[dict] | None = None + + +def _load_drum_tab_file(source_dir: Path, rel: str, label: str) -> dict | None: + """Load + schema-validate one drum-tab JSON named by a manifest-relative + path. Shared by the song-level `drum_tab:` key and the per-arrangement + drum-part pointers (feedpak 1.17.0), so every tab gets the same posture: + permissive — a missing file disables that part silently; a traversal, + parse, or validation failure disables it with a warning, never aborting + the load.""" + # Constrain to source_dir to prevent a crafted manifest from reading + # files outside the sloppak directory via path traversal (e.g. ../../etc). + # Wrap both resolve() calls in a broad handler: symlink loops and + # permission errors on .resolve() should disable drums, not abort load. + try: + dt_path = (source_dir / rel).resolve() + dt_path.relative_to(source_dir.resolve()) + except ValueError: + log.warning("sloppak: %s path %r escapes source_dir — skipped", label, rel) + return None + except OSError as e: + log.warning("sloppak: %s path resolution failed (%s) — skipped", label, e) + return None + if not dt_path.exists(): + return None + try: + raw = load_json(dt_path) + except Exception as e: + log.warning("sloppak: failed to parse %s %r: %s", label, rel, e) + return None + ok, reason = drums_mod.validate_drum_tab(raw) + if not ok: + log.warning("sloppak: %s %r failed validation: %s", label, rel, reason) + return None + return raw def load_song( @@ -754,6 +798,7 @@ def load_song( notation_acc: dict[str, dict] = {} any_notation = False arrangement_ids_acc: list[str | None] = [] # parallel to song.arrangements + drum_pointer_entries: list[dict] = [] # feedpak 1.17.0 drum-part pointers for entry in manifest.get("arrangements", []) or []: if not isinstance(entry, dict): log.warning("sloppak: non-dict arrangement entry skipped (%r)", type(entry).__name__) @@ -763,6 +808,15 @@ def load_song( notation_raw = entry.get("notation") has_notation_key = isinstance(notation_raw, str) and bool(notation_raw.strip()) if not rel and not has_notation_key: + # A DRUM-PART POINTER entry (feedpak 1.17.0 "drums as + # arrangements"): `type: drums` with a per-arrangement `drum_tab` + # file and no note file. Collect it for the drum-parts load after + # this loop — but NEVER turn it into a fretted Arrangement: this + # skip is the grading invariant (a drum part must not reach the + # fretted pipeline, where its empty chart would grade as garbage). + _etype = str(entry.get("type") or "").strip().lower() + if _etype in ("drums", "drum") and isinstance(entry.get("drum_tab"), str): + drum_pointer_entries.append(entry) continue data = None if rel: @@ -868,32 +922,61 @@ def load_song( drum_tab_data: dict | None = None drum_tab_rel = manifest.get("drum_tab") if isinstance(drum_tab_rel, str) and drum_tab_rel: - # Constrain to source_dir to prevent a crafted manifest from reading - # files outside the sloppak directory via path traversal (e.g. ../../etc). - # Wrap both resolve() calls in a broad handler: symlink loops and - # permission errors on .resolve() should disable drums, not abort load. - try: - dt_path = (source_dir / drum_tab_rel).resolve() - dt_path.relative_to(source_dir.resolve()) - except ValueError: - log.warning("sloppak: drum_tab path %r escapes source_dir — skipped", drum_tab_rel) - dt_path = None - except OSError as e: - log.warning("sloppak: drum_tab path resolution failed (%s) — skipped", e) - dt_path = None - if dt_path is not None and dt_path.exists(): - try: - raw = load_json(dt_path) - except Exception as e: - log.warning("sloppak: failed to parse drum_tab %r: %s", drum_tab_rel, e) - raw = None - if raw is not None: - ok, reason = drums_mod.validate_drum_tab(raw) - if ok: - drum_tab_data = raw - else: - log.warning("sloppak: drum_tab %r failed validation: %s", - drum_tab_rel, reason) + drum_tab_data = _load_drum_tab_file(source_dir, drum_tab_rel, "drum_tab") + + # DRUM PARTS (feedpak 1.17.0 "drums as arrangements"): resolve the + # `type: drums` pointer entries collected in the arrangements loop into a + # primary-first parts list. The entry whose pointer names the SAME file as + # the song-level `drum_tab:` key is the PRIMARY's alias — it contributes + # its id/name but is never loaded twice. Extra parts load their own files + # with the same permissive posture (a bad part disables that part only). + drum_parts: list[dict] | None = None + if drum_tab_data is not None or drum_pointer_entries: + _primary_id = "drums" + _primary_name = None + _extra_parts: list[dict] = [] + _seen_rels: set[str] = set() + for _entry in drum_pointer_entries: + _rel = str(_entry.get("drum_tab") or "").strip() + if not _rel or _rel in _seen_rels: + continue + _seen_rels.add(_rel) + _eid = str(_entry.get("id") or "").strip() + _ename = str(_entry.get("name") or "").strip() + if isinstance(drum_tab_rel, str) and _rel == drum_tab_rel.strip(): + # The primary's alias entry — adopt its identity only. + if _eid: + _primary_id = _eid + if _ename: + _primary_name = _ename + continue + _tab = _load_drum_tab_file(source_dir, _rel, "drum part") + if _tab is None: + continue + _tab_name = _tab.get("name") + _extra_parts.append({ + "id": _eid or f"drums-{len(_extra_parts) + 2}", + "name": _ename + or (_tab_name if isinstance(_tab_name, str) and _tab_name else "Drums"), + "drum_tab": _tab, + }) + _parts: list[dict] = [] + if drum_tab_data is not None: + if _primary_name is None: + _dt_name = drum_tab_data.get("name") + _primary_name = _dt_name if isinstance(_dt_name, str) and _dt_name else "Drums" + # The primary's payload IS the song-level tab (same object). + _parts.append({"id": _primary_id, "name": _primary_name, "drum_tab": drum_tab_data}) + _parts.extend(_extra_parts) + if _parts: + if drum_tab_data is None: + # Pointer-only pack (a writer omitted the song-level alias — + # spec writers keep it, but a reader must cope): the first + # part becomes the primary for every legacy consumer + # (has_drum_tab, the default drum_tab stream, the drum-only + # placeholder arrangement below). + drum_tab_data = _parts[0]["drum_tab"] + drum_parts = _parts # Drum-only sloppak: every GP track was percussion, so it ships a # drum_tab but no pitched arrangements. The highway WS rejects an empty @@ -1221,6 +1304,7 @@ def load_song( manifest=manifest, feedpak_version=_fpv if isinstance(_fpv, str) and _fpv else None, drum_tab=drum_tab_data, + drum_parts=drum_parts, song_timeline=song_timeline_data, tempos=tempos_data, time_signatures=time_sigs_data, diff --git a/tests/test_sloppak_drum_parts.py b/tests/test_sloppak_drum_parts.py new file mode 100644 index 0000000..64abe98 --- /dev/null +++ b/tests/test_sloppak_drum_parts.py @@ -0,0 +1,216 @@ +"""Loader coverage for MULTIPLE drum parts (feedpak 1.17.0 "drums as +arrangements"). + +A drum part rides the manifest as a `type: drums` arrangement entry carrying +a per-arrangement `drum_tab` file pointer and NO note `file`. The loader: + + - NEVER turns a pointer entry into a fretted Arrangement — that skip is + the grading invariant (an empty drum chart must not reach the fretted + pipeline, where note detection would grade it as garbage); + - resolves the parts into `LoadedSloppak.drum_parts`, primary FIRST: the + entry aliasing the song-level `drum_tab:` file contributes its id/name + but is never loaded twice (its payload IS `loaded.drum_tab`); + - loads each extra part's file with the same permissive posture as the + song-level tab (a bad part disables that part only, never the load); + - copes with a pointer-only pack (no song-level key): the first part + becomes the primary so every legacy consumer keeps working. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import yaml + +import sloppak as sloppak_mod + + +def _tab(name: str, hits: list[dict] | None = None) -> dict: + return { + "version": 1, + "name": name, + "kit": [{"id": "kick", "name": "Kick"}], + "hits": hits if hits is not None else [{"t": 1.0, "p": "kick", "v": 100}], + } + + +def _write_pak(root: Path, manifest_extras: dict, files: dict[str, dict | str]) -> Path: + """A minimal directory-form sloppak with one Lead arrangement plus the + given extra files ({relpath: json-dict-or-raw-text}).""" + pak = root / f"{root.name}.sloppak" + pak.mkdir() + arr_dir = pak / "arrangements" + arr_dir.mkdir() + arr = { + "name": "Lead", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0, + "notes": [], "chords": [], "anchors": [], "handshapes": [], + "templates": [], "beats": [], "sections": [], + } + (arr_dir / "lead.json").write_text(json.dumps(arr)) + manifest = { + "title": "Test", "artist": "Tester", "album": "", "year": 2026, + "duration": 10.0, + "arrangements": [ + {"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}, + ], + "stems": [{"id": "full", "file": "stems/full.ogg", "default": True}], + } + manifest.update(manifest_extras) + (pak / "manifest.yaml").write_text(yaml.safe_dump(manifest, sort_keys=False)) + for rel, payload in files.items(): + text = payload if isinstance(payload, str) else json.dumps(payload) + (pak / rel).write_text(text) + return pak + + +def _load(pak_path: Path, tmp_path: Path): + dlc_root = pak_path.parent + cache = tmp_path / "cache" + cache.mkdir() + return sloppak_mod.load_song(pak_path.name, dlc_root, cache) + + +def _two_part_manifest() -> dict: + """The exact shape the editor writes: primary alias entry + one extra.""" + return { + "drum_tab": "drum_tab.json", + "arrangements": [ + {"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}, + {"id": "drums", "name": "Drums", "type": "drums", + "drum_tab": "drum_tab.json"}, + {"id": "drums-2", "name": "Drums (Live)", "type": "drums", + "drum_tab": "drum_tab_drums-2.json"}, + ], + } + + +# ── The grading invariant ──────────────────────────────────────────────────── + +def test_pointer_entries_never_become_fretted_arrangements(tmp_path: Path): + pak = _write_pak(tmp_path, _two_part_manifest(), { + "drum_tab.json": _tab("Drums"), + "drum_tab_drums-2.json": _tab("Drums (Live)"), + }) + loaded = _load(pak, tmp_path) + # Only the Lead chart is an Arrangement — neither drum part enters the + # fretted pipeline (song.arrangements is what note detection grades). + assert [a.name for a in loaded.song.arrangements] == ["Lead"] + # And the ids list stays parallel to song.arrangements (skipped entries + # contribute nothing) — a misalignment here would remap every chart edit. + assert loaded.arrangement_ids == ["lead"] + + +# ── Parts resolution ───────────────────────────────────────────────────────── + +def test_two_parts_resolve_primary_first_with_alias_identity(tmp_path: Path): + pak = _write_pak(tmp_path, _two_part_manifest(), { + "drum_tab.json": _tab("Drums"), + "drum_tab_drums-2.json": _tab("Drums (Live)", [{"t": 2.0, "p": "kick", "v": 90}]), + }) + loaded = _load(pak, tmp_path) + assert loaded.drum_parts is not None + assert [(p["id"], p["name"]) for p in loaded.drum_parts] == [ + ("drums", "Drums"), ("drums-2", "Drums (Live)"), + ] + # The primary's payload IS the song-level tab — same object, loaded once. + assert loaded.drum_parts[0]["drum_tab"] is loaded.drum_tab + assert loaded.drum_parts[1]["drum_tab"]["hits"][0]["t"] == 2.0 + + +def test_legacy_single_drum_pack_gets_a_one_part_list(tmp_path: Path): + pak = _write_pak(tmp_path, {"drum_tab": "drum_tab.json"}, { + "drum_tab.json": _tab("Drums"), + }) + loaded = _load(pak, tmp_path) + assert loaded.drum_parts is not None and len(loaded.drum_parts) == 1 + assert loaded.drum_parts[0]["id"] == "drums" + assert loaded.drum_parts[0]["drum_tab"] is loaded.drum_tab + + +def test_no_drums_means_no_parts(tmp_path: Path): + pak = _write_pak(tmp_path, {}, {}) + loaded = _load(pak, tmp_path) + assert loaded.drum_parts is None + assert loaded.drum_tab is None + + +def test_pointer_only_pack_promotes_the_first_part_to_primary(tmp_path: Path): + # A writer that omitted the song-level alias: readers must cope (the + # spec keeps the alias, but a reader never crashes on its absence). + pak = _write_pak(tmp_path, { + "arrangements": [ + {"id": "lead", "name": "Lead", "file": "arrangements/lead.json"}, + {"id": "kit", "name": "Kit", "type": "drums", + "drum_tab": "drum_tab_kit.json"}, + ], + }, {"drum_tab_kit.json": _tab("Kit")}) + loaded = _load(pak, tmp_path) + assert loaded.drum_parts is not None and len(loaded.drum_parts) == 1 + # The part's tab becomes THE drum tab, so has_drum_tab / the default + # stream / the drum-only placeholder all keep working. + assert loaded.drum_tab is loaded.drum_parts[0]["drum_tab"] + assert loaded.drum_parts[0]["id"] == "kit" + + +# ── Permissive per-part failure ────────────────────────────────────────────── + +def test_a_bad_extra_part_disables_that_part_only(tmp_path: Path): + manifest = _two_part_manifest() + manifest["arrangements"].append( + {"id": "drums-3", "name": "Broken", "type": "drums", + "drum_tab": "drum_tab_broken.json"}) + pak = _write_pak(tmp_path, manifest, { + "drum_tab.json": _tab("Drums"), + "drum_tab_drums-2.json": _tab("Drums (Live)"), + "drum_tab_broken.json": "not json {{{", + }) + loaded = _load(pak, tmp_path) + assert [p["id"] for p in loaded.drum_parts] == ["drums", "drums-2"] + + +def test_a_traversal_part_path_is_skipped(tmp_path: Path): + manifest = _two_part_manifest() + manifest["arrangements"][2]["drum_tab"] = "../outside.json" + (tmp_path / "outside.json").write_text(json.dumps(_tab("Evil"))) + pak = _write_pak(tmp_path, manifest, {"drum_tab.json": _tab("Drums")}) + loaded = _load(pak, tmp_path) + assert [p["id"] for p in loaded.drum_parts] == ["drums"] + + +def test_duplicate_pointer_rels_load_once(tmp_path: Path): + manifest = _two_part_manifest() + manifest["arrangements"].append( + {"id": "drums-dup", "name": "Dup", "type": "drums", + "drum_tab": "drum_tab_drums-2.json"}) + pak = _write_pak(tmp_path, manifest, { + "drum_tab.json": _tab("Drums"), + "drum_tab_drums-2.json": _tab("Drums (Live)"), + }) + loaded = _load(pak, tmp_path) + assert [p["id"] for p in loaded.drum_parts] == ["drums", "drums-2"] + + +# ── Drum-only pack with parts ──────────────────────────────────────────────── + +def test_drum_only_pack_with_pointer_entries_still_synthesizes_placeholder(tmp_path: Path): + # No pitched arrangements at all, drums via pointer entries only: the + # placeholder "Drums" arrangement must still appear so the highway WS + # proceeds and the tab reaches the drum highway. + pak = _write_pak(tmp_path, { + "arrangements": [ + {"id": "kit", "name": "Kit", "type": "drums", + "drum_tab": "drum_tab_kit.json"}, + ], + }, {"drum_tab_kit.json": _tab("Kit", [{"t": 5.0, "p": "kick", "v": 100}])}) + # Remove the Lead arrangement _write_pak added to the manifest. + manifest_path = pak / "manifest.yaml" + manifest = yaml.safe_load(manifest_path.read_text()) + manifest["arrangements"] = [e for e in manifest["arrangements"] if e.get("id") != "lead"] + manifest.pop("duration", None) + manifest_path.write_text(yaml.safe_dump(manifest, sort_keys=False)) + loaded = _load(pak, tmp_path) + assert [a.name for a in loaded.song.arrangements] == ["Drums"] + assert loaded.drum_parts is not None and loaded.drum_parts[0]["id"] == "kit" + # Song length derived from the last hit (the drum-only path's rule). + assert loaded.song.song_length > 5.0