Load the pack's rig library from the manifest

feedpak 1.18.0 lets a chart declare what a MIDI part should sound like
by binding a rig id, but core had nothing to bind to: `rigs`, `base_rig`
and `drum_tones` appeared nowhere in lib/, server.py or static/. The
preceding commit carries the reference onto the wire; this adds the
library it references.

Read the manifest `rigs:` key into a new `LoadedSloppak.rigs`, alongside
the other side-files rather than on Song — every side-file (drum_tab,
song_timeline, keys, notation) hangs off the load result, and rigs is
pack-level, not per-arrangement. Same permissive posture as its
neighbours: missing, unreadable, malformed or traversing disables rigs
with a warning and never fails the pack, which §7.9 requires outright.

Rig objects pass through VERBATIM. §7.9 obliges a Reader to preserve
unknown role/engine/kind values and `ext` namespaces, so validating
block structure here would be wrong as well as premature — realization
selection and the `intent.gm` floor belong to whatever voices the part.
The only entries dropped are ones unreachable by construction: a rig is
addressable solely by `id`, so a non-dict entry or one without a usable
string id can never be referenced. Ids are stripped to match the
reference side, and a duplicate id resolves first-wins with a warning,
since ambiguity there would surface as the wrong sound rather than an
error.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
This commit is contained in:
gionnibgud
2026-07-23 10:30:59 +02:00
parent 435e273927
commit 9075939668
2 changed files with 295 additions and 0 deletions
+88
View File
@@ -698,6 +698,14 @@ class LoadedSloppak:
# absent / unreadable / malformed. Streamed over the highway WS as a
# `keys` message; consumers (renderers, plugins) read it from there.
keys: dict | None = None
# Parsed `rigs.json` payload (manifest `rigs:` key, spec §7.9) — the pack's
# library of engine-agnostic signal chains: effect chains and, since
# feedpak 1.18.0, MIDI-voiced sound sources. Arrangements bind rigs to time
# by referencing a rig `id` from `tones.base_rig` / `tones.changes[].rig`
# (§6.9), which `lib/tones.py` carries onto the wire. None when absent /
# unreadable / malformed. Rig objects are kept verbatim — this loader does
# not select realizations or apply the `intent.gm` floor.
rigs: dict | None = None
# Sanitized song-level tempo + time-signature maps from `song_timeline.json`
# (feedpak 1.2.0). `tempos`: [{time, bpm}]; `time_signatures`: [{time, ts}].
# None when absent/empty. Streamed over the highway WS (`tempos` /
@@ -776,6 +784,77 @@ def _load_drum_tab_file(source_dir: Path, rel: str, label: str) -> dict | None:
return raw
def _load_rigs_file(source_dir: Path, rel: str) -> dict | None:
"""Load the pack's rig library (manifest `rigs:` key, spec §7.9).
Returns `{"version": int, "rigs": [...]}` or None. Same permissive posture
as every other side-file: missing / unreadable / malformed -> None, never
fatal — spec §7.9 is explicit that a rig library a Reader can't use MUST NOT
fail the pack.
Rig objects are kept **verbatim**. Only entries that could never be
addressed are dropped — a rig is reachable solely by `id` (from
`tones.base_rig` / `changes[].rig`), so a non-dict entry or one without a
usable string id is unreferenceable by construction. Everything else,
including unknown `role` / `engine` / `kind` values and `ext` namespaces,
passes through untouched, because this loader does not interpret rigs:
realization selection and the `intent.gm` fallback belong to whatever
voices the part.
"""
try:
r_path = (source_dir / rel).resolve()
r_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: rigs path %r escapes source_dir — skipped", rel)
return None
except OSError as e:
log.warning("sloppak: rigs path resolution failed (%s) — skipped", e)
return None
if not r_path.exists():
return None
try:
raw = load_json(r_path)
except Exception as e:
log.warning("sloppak: failed to parse rigs %r: %s", rel, e)
return None
if not isinstance(raw, dict):
log.warning("sloppak: rigs %r ignored — expected dict, got %s",
rel, type(raw).__name__)
return None
if not isinstance(raw.get("rigs"), list):
log.warning("sloppak: rigs %r ignored — 'rigs' must be a list", rel)
return None
clean_rigs: list[dict] = []
seen: set[str] = set()
for rig in raw["rigs"]:
if not isinstance(rig, dict):
continue
rid = rig.get("id")
if not isinstance(rid, str) or not rid.strip():
continue
# Normalize the library side of the lookup the same way the reference
# side is normalized in lib/tones.py — otherwise a pack with padded ids
# fails to resolve against a stripped `base_rig` / `rig`.
rid = rid.strip()
# A duplicate id makes `tones.base_rig` ambiguous, which would surface
# as the wrong sound rather than an error. First wins, loudly.
if rid in seen:
log.warning("sloppak: rigs %r has duplicate rig id %r — later one ignored",
rel, rid)
continue
seen.add(rid)
clean_rigs.append({**rig, "id": rid})
# int only — a float version (incl. NaN/Inf, which json.loads accepts)
# would raise on int(); default rather than abort an optional side-file.
_ver = raw.get("version")
return {
"version": _ver if isinstance(_ver, int) and not isinstance(_ver, bool) else 1,
"rigs": clean_rigs,
}
def _resolve_drum_parts(
source_dir: Path,
drum_tab_rel: object,
@@ -1325,6 +1404,14 @@ def load_song(
"events": clean_events,
}
# Optional rigs.json — the pack's rig library (manifest `rigs:` key,
# spec §7.9). Loaded here so the highway WS can hand it to whatever voices
# the part; the bindings that reference it ride the arrangement's `tones`.
rigs_data: dict | None = None
rigs_rel = manifest.get("rigs")
if isinstance(rigs_rel, str) and rigs_rel:
rigs_data = _load_rigs_file(source_dir, rigs_rel)
_fpv = manifest.get("feedpak_version")
# The pack's full mix. Normally the RESERVED `full` stem partitioned out
# above (spec §5.3) — no path work needed, it was validated with the other
@@ -1355,6 +1442,7 @@ def load_song(
tempos=tempos_data,
time_signatures=time_sigs_data,
keys=keys_data,
rigs=rigs_data,
notation_by_id=notation_by_id_data,
arrangement_ids=arrangement_ids_acc,
full_mix=full_mix_data,