feat(sloppak): load multiple drum parts (feedpak 1.17.0 drums-as-arrangements) (#1020)

* feat(sloppak): load multiple drum parts (feedpak 1.17.0 drums-as-arrangements)

A song can now ship SEVERAL drum charts (a second drummer, an aux-percussion
layer). The Arrangement Editor already writes them per the feedpak 1.17.0 FEP
(feedpak-spec#63): the primary stays the song-level `drum_tab:` key (what this
app has always played), and each part rides the manifest as a `type: drums`
arrangement entry carrying a per-arrangement `drum_tab` file pointer and NO
note `file` — an entry this loader's file/notation gate already skips, which
is exactly why old builds are unaffected by such packs.

lib/sloppak.py:
- The arrangements loop collects drum-part pointer entries instead of merely
  skipping them — but still NEVER turns one into a fretted Arrangement. That
  skip is the grading invariant (an empty drum chart must not reach the
  fretted pipeline / note-detection grading) and is now pinned by test.
- New `LoadedSloppak.drum_parts`: [{id, name, drum_tab}], primary FIRST. The
  entry aliasing the song-level file contributes its id/name but is never
  loaded twice (the primary's payload IS `loaded.drum_tab`, same object).
  Legacy single-drum packs read as a one-part list; a pointer-only pack (a
  writer omitted the alias) promotes its first part so has_drum_tab, the
  default stream, and the drum-only placeholder keep working.
- The song-level drum_tab loading block is extracted verbatim into
  `_load_drum_tab_file()` and shared by both paths, so every part gets the
  same permissive posture: missing file → that part silently absent;
  traversal / parse / validation failure → that part skipped with a warning,
  never an aborted load. (The 9 pinned drumtab-load tests pass unchanged.)

lib/routers/ws_highway.py:
- `song_info` gains `drum_parts` (names only; always a list, empty without
  drums) so a part picker can bind unconditionally.
- `?drum_part=<id>` on the WS URL selects which part's tab streams as the
  `drum_tab`/`drum_hits` messages; the default and any unknown id fall back
  to the primary — byte-identical legacy behavior. The `drum_tab` message
  carries `part_id` only when a parts list exists, keeping the legacy frame
  unchanged.

Tests: tests/test_sloppak_drum_parts.py (9) — the grading invariant +
parallel-ids pin, primary-first resolution with alias identity, legacy
one-part list, pointer-only promotion, per-part failure isolation (bad JSON,
path traversal, duplicate rels), and the drum-only placeholder with pointer
entries. Full suite: the only failures are 9 machine-environmental tests
(installed desktop plugins under LOCALAPPDATA, CRLF/path-shape assertions)
that fail identically on an untouched origin/main checkout on this box.
tools/check_spec_conformance.py passes against the spec's current HEAD
(`drum_tab` and `type` are declared keys); the semantics of the
per-arrangement placement land in feedpak-spec#63 — this PR should merge
after it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xGPjDBF8NTwTK7VQvizix
Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* Fix drum-part review findings

* Normalize drum part pointer identities

* fix(sloppak): enforce drums grading invariant + green the suite

- Gate the drum-pointer skip on type FIRST: a type:drums/drum entry never becomes
  a fretted Arrangement even if it carries a note file/notation (with drum_tab it
  is collected as a drum part, without it dropped+warned). Closes the spec
  §5.2/§7.5 MUST-NOT hole (a malformed drums+file entry was being fretted-graded).
- Make test_drum_pointer_with_wrong_type_logs_warning robust (attach handler to the
  feedBack logger + set WARNING, restore in finally) and fix the root-cause level
  leak in test_tuning_provider_isolation.py (finally restored the handler but not
  the level, leaking ERROR onto the feedBack tree and turning the suite red under
  full ordering).
- Restore the chart-transform CHANGELOG bullet (#952) the drum entry had truncated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-07-21 13:27:41 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent a9be210f77
commit 605dbdfd25
6 changed files with 526 additions and 36 deletions
+48 -9
View File
@@ -143,9 +143,21 @@ def _sanitize_authors(manifest: dict | None) -> list[dict]:
return out
def _drum_part_id_for_wire(drum_parts: list[dict] | None, selected_id: str | None) -> str | None:
"""Expose a part id only when the pack genuinely has multiple parts."""
return selected_id if selected_id is not None and len(drum_parts or []) > 1 else None
@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 +576,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 +608,36 @@ 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=<id>` 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),
}
# Only multi-part packs identify a part on the wire. Legacy packs
# synthesize a one-item list internally but keep their old frame.
_wire_part_id = _drum_part_id_for_wire(loaded_slop.drum_parts, _dt_part_id)
if _wire_part_id is not None:
_dt_msg["part_id"] = _wire_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",
+152 -27
View File
@@ -730,6 +730,125 @@ 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 _resolve_drum_parts(
source_dir: Path,
drum_tab_rel: object,
drum_tab_data: dict | None,
drum_pointer_entries: list[dict],
) -> tuple[dict | None, list[dict] | None]:
"""Resolve drum pointers into a primary-first list with unique ids."""
if drum_tab_data is None and not drum_pointer_entries:
return drum_tab_data, None
primary_id = "drums"
primary_name = None
extra_parts: list[dict] = []
seen_rels: set[str] = set()
# Use the same canonical, traversal-safe identity as zip member lookup so
# equivalent spellings ("x.json", "./x.json", or backslashes) identify
# one file. Otherwise an alias pointer can reload and duplicate the primary.
primary_rel_key = (
_zip_member_key(drum_tab_rel.strip())
if isinstance(drum_tab_rel, str) and drum_tab_rel.strip() else None
)
for entry in drum_pointer_entries:
rel = str(entry.get("drum_tab") or "").strip()
rel_key = _zip_member_key(rel) if rel else None
rel_identity = rel_key or rel
if not rel or rel_identity in seen_rels:
continue
seen_rels.add(rel_identity)
entry_id = str(entry.get("id") or "").strip()
entry_name = str(entry.get("name") or "").strip()
if primary_rel_key is not None and rel_key == primary_rel_key:
if entry_id:
primary_id = entry_id
if entry_name:
primary_name = entry_name
continue
tab = _load_drum_tab_file(source_dir, rel, f"drum part {entry_id or rel}")
if tab is None:
continue
tab_name = tab.get("name")
extra_parts.append({
"id": entry_id,
"name": entry_name
or (tab_name if isinstance(tab_name, str) and tab_name else "Drums"),
"drum_tab": tab,
})
parts: list[dict] = []
used_ids: set[str] = set()
if drum_tab_data is not None:
if primary_name is None:
tab_name = drum_tab_data.get("name")
primary_name = tab_name if isinstance(tab_name, str) and tab_name else "Drums"
parts.append({"id": primary_id, "name": primary_name, "drum_tab": drum_tab_data})
used_ids.add(primary_id)
next_generated_id = 2
for part in extra_parts:
part_id = part["id"]
if not part_id or part_id in used_ids:
while f"drums-{next_generated_id}" in used_ids:
next_generated_id += 1
part_id = f"drums-{next_generated_id}"
next_generated_id += 1
part["id"] = part_id
used_ids.add(part_id)
parts.append(part)
if not parts:
return drum_tab_data, None
if drum_tab_data is None:
drum_tab_data = parts[0]["drum_tab"]
return drum_tab_data, parts
def load_song(
@@ -754,6 +873,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__)
@@ -762,7 +882,30 @@ def load_song(
rel = rel_raw.strip() if isinstance(rel_raw, str) else ""
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:
_etype = str(entry.get("type") or "").strip().lower()
is_drums = _etype in ("drums", "drum")
# A drums-typed entry MUST NEVER become a fretted Arrangement (grading
# invariant, spec §5.2/§7.5): route on `type` FIRST, not on file
# absence — a malformed drums entry that also carries a note file/
# notation would otherwise fall through and grade as garbage.
if is_drums or (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. Collect it for the drum-parts load after this loop.
if is_drums and isinstance(entry.get("drum_tab"), str):
drum_pointer_entries.append(entry)
elif is_drums:
# Drums-typed but no drum_tab pointer — drop it (any note
# file/notation it carries is ignored), never fret it.
log.warning(
"sloppak: drums-typed arrangement entry %r has no drum_tab pointer — dropped",
entry.get("id"),
)
elif isinstance(entry.get("drum_tab"), str):
log.warning(
"sloppak: arrangement entry has drum_tab %r but type=%r — ignored",
entry.get("drum_tab"), entry.get("type"),
)
continue
data = None
if rel:
@@ -868,32 +1011,13 @@ 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")
# Keep the dense compatibility logic independently testable and guarantee
# ids are unique before the highway exposes them as selectors.
drum_tab_data, drum_parts = _resolve_drum_parts(
source_dir, drum_tab_rel, drum_tab_data, drum_pointer_entries,
)
# 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 +1345,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,