Files
feedBack/tests/test_tones.py
T
gionnibgudandGitHub eef58c88c3
ship-ci / ci (push) Has been cancelled
feat(sloppak): core reader for source rigs (feedpak 1.18.0) (#1040)
* Carry rig bindings through the sloppak tone payload

`sloppak_tone_changes` emitted `{t, name}` only, so a chart's declared
sound never reached the client: `base_rig` was never read and each
change's `rig` was dropped at the wire boundary. Both survive load
intact (`Arrangement.tones` is an opaque passthrough) — the strip
happened here, at the last step before send.

That left the rig model (feedpak-spec 1.18.0 §6.9/§7.9) unreachable
from core: a pack could declare which rig voices a part, and nothing
downstream could ever see it. First step of the core reader for source
rigs; the rig library itself and the manifest precedence cascade follow.

Return `(base, base_rig, changes)` and keep `rig` on each change. Both
ids are validated as non-blank strings and stripped — anything else is
dropped rather than forwarded, so presence of the key means the change
binds a rig. Resolution against `rigs.json` deliberately does NOT happen
here: this builder preserves the declared binding, while realization
selection and the `intent.gm` fallback belong to whatever voices the
part.

On the wire `base_rig` is omitted entirely when empty, so packs that
bind no rig produce the byte-identical `tone_changes` message they
always did.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>

* 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>

* Resolve which tones block binds a part

feedpak 1.18.0 lets a sound binding arrive from three places, and core
honoured none of them: the manifest arrangement entry, the arrangement
JSON, and the top-level drum_tones. Reading them needs a precedence
rule, because two of the three can be present at once.

Arrangement entries: the entry's `tones` replaces the arrangement JSON's
WHOLESALE (spec 5.2), unlike name/tuning/capo/centOffset beside it,
which override field by field. A merge would produce a sound nobody
authored -- one source's base under the other's changes -- which is
worse than either block alone. This is also what makes a notation-only
keys entry bindable at all, since it has no arrangement JSON to carry
tones in the first place.

Drums: the top-level drum_tones binds the song-level primary part, and
a `type: drums` entry's own tones takes precedence, with a Reader
forbidden from applying both to the same part (5.1). That is the same
shape as the drum_tab alias rule, so it lives inside
_resolve_drum_parts next to it rather than beside it -- one precedence
resolver, not two that drift. drum_tones is the PRIMARY's fallback
only: a second drummer with no binding gets None, never the primary's
kit.

An empty `tones: {}` reads as absent rather than as an override to
silence, matching how arrangement_from_wire already normalizes the
in-JSON empty dict, so a stray empty object cannot quietly unbind a
part.

Spec-conformance gate passes with drum_tones added to the keys core
reads (22 of the spec's 32, all declared).

Signed-off-by: gionnibgud <gionnibgud@gmail.com>

* Document the rig bindings on the tone_changes wire message

CHANGELOG entry for the core rig reader, plus the WS protocol table in
CLAUDE.md, which described `tone_changes` as carrying only base + name.

While in that row: its time key was documented as `time`, but every
producer emits `t` — both the sloppak builder and the legacy XML path.
The 3D highway already carries a comment warning readers about exactly
this discrepancy. Corrected here rather than left sitting next to the
newly-added keys, where a reader would reasonably assume both were
equally reliable.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>

---------

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
2026-07-23 11:27:21 +02:00

96 lines
3.6 KiB
Python

"""Tests for lib/tones.py — sloppak tone-change payload builder."""
from tones import sloppak_tone_changes
# ── sloppak_tone_changes (highway payload builder) ───────────────────────────
def test_sloppak_tone_changes_sorts_and_returns_base():
base, base_rig, changes = sloppak_tone_changes({
"base": "Clean",
"changes": [{"t": 12.5, "name": "Drive"}, {"t": 3.0, "name": "Clean"}],
})
assert base == "Clean"
assert base_rig == ""
assert changes == [{"t": 3.0, "name": "Clean"}, {"t": 12.5, "name": "Drive"}]
def test_sloppak_tone_changes_skips_malformed_markers():
_, _, changes = sloppak_tone_changes({
"changes": [
{"t": "nan", "name": "BadStr"},
{"t": float("inf"), "name": "Inf"},
{"t": 5.0, "name": 123}, # non-string name
{"t": None, "name": "NoTime"},
"not-a-dict",
{"t": 7.0, "name": "Good"},
],
})
assert changes == [{"t": 7.0, "name": "Good"}]
def test_sloppak_tone_changes_handles_none_and_bad_base():
assert sloppak_tone_changes(None) == ("", "", [])
base, base_rig, changes = sloppak_tone_changes({"base": 123, "changes": []})
assert base == "" and base_rig == "" and changes == []
def test_sloppak_tone_changes_non_dict_input():
"""A truthy non-dict payload must not crash."""
assert sloppak_tone_changes(["not", "a", "dict"]) == ("", "", [])
assert sloppak_tone_changes("nope") == ("", "", [])
def test_sloppak_tone_changes_non_list_changes():
"""A truthy non-list `changes` value must not raise on iteration."""
base, _, changes = sloppak_tone_changes({"base": "Clean", "changes": 1})
assert base == "Clean" and changes == []
# ── rig bindings (feedpak-spec 1.18.0 §6.9) ──────────────────────────────────
def test_sloppak_tone_changes_carries_rig_bindings():
"""`base_rig` and per-change `rig` reach the wire — the binding a chart
declares is what core must hand the consumer that voices the part."""
base, base_rig, changes = sloppak_tone_changes({
"base": "Clean Rhythm",
"base_rig": "clean-rhythm",
"changes": [
{"t": 12.5, "name": "Lead Drive", "rig": "lead-drive"},
{"t": 48.0, "name": "Clean Rhythm", "rig": "clean-rhythm"},
],
})
assert base == "Clean Rhythm"
assert base_rig == "clean-rhythm"
assert changes == [
{"t": 12.5, "name": "Lead Drive", "rig": "lead-drive"},
{"t": 48.0, "name": "Clean Rhythm", "rig": "clean-rhythm"},
]
def test_sloppak_tone_changes_omits_unusable_rig_ids():
"""A non-string or blank `rig` is dropped rather than forwarded, so a
consumer can treat presence of the key as "this change binds a rig"."""
_, base_rig, changes = sloppak_tone_changes({
"base_rig": " ",
"changes": [
{"t": 1.0, "name": "A", "rig": 7},
{"t": 2.0, "name": "B", "rig": ""},
{"t": 3.0, "name": "C", "rig": None},
{"t": 4.0, "name": "D", "rig": " padded-id "},
],
})
assert base_rig == ""
assert changes == [
{"t": 1.0, "name": "A"},
{"t": 2.0, "name": "B"},
{"t": 3.0, "name": "C"},
{"t": 4.0, "name": "D", "rig": "padded-id"},
]
def test_sloppak_tone_changes_non_string_base_rig():
"""A non-string `base_rig` must not crash or leak a non-id onto the wire."""
_, base_rig, _ = sloppak_tone_changes({"base": "Clean", "base_rig": 42})
assert base_rig == ""