Compare commits

..
Author SHA1 Message Date
gionnibgud 61d8122052 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>
2026-07-23 10:31:52 +02:00
gionnibgud e57ad16caa 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>
2026-07-23 10:31:23 +02:00
gionnibgud 9075939668 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>
2026-07-23 10:30:59 +02:00
gionnibgud 435e273927 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>
2026-07-23 10:30:59 +02:00
10 changed files with 699 additions and 744 deletions
+14
View File
@@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Core reader for source rigs (feedpak 1.18.0).** A pack can declare what a
MIDI part should sound like by binding a rig; core now reads that binding and
hands it to the client instead of dropping it. Three parts: the
`tone_changes` WS message carries the pack's rig bindings (`base_rig`, and
`rig` per change) alongside the tone names it already sent; the manifest
`rigs:` key loads the pack's rig library (`rigs.json`, spec §7.9) verbatim;
and the binding precedence is resolved per spec §5.1/§5.2 — a manifest
arrangement entry's `tones` replaces the arrangement JSON's **wholesale**
(no field-level merge), while top-level `drum_tones` binds the primary drum
part as the fallback a `type: drums` entry's own `tones` outranks. Core
deliberately stops there: it does not select a realization or apply the
`intent.gm` floor, which belong to whatever actually voices the part. Packs
that bind no rig produce a byte-identical `tone_changes` payload, so existing
consumers are unaffected.
- **Opt-in career venue packs (#122)** — higher-tier venue crowd media - **Opt-in career venue packs (#122)** — higher-tier venue crowd media
(`club`, `arena`) is no longer bundled; the app downloads each pack on demand (`club`, `arena`) is no longer bundled; the app downloads each pack on demand
from its release when you reach the venue (sha256-verified), keeping the from its release when you reach the venue (sha256-verified), keeping the
+1 -1
View File
@@ -690,7 +690,7 @@ The highway WebSocket at `/ws/highway/{filename}?arrangement={index}` streams th
| `anchors` | `{ type, data: [{ time, fret, width }] }` | Fret zoom anchors | | `anchors` | `{ type, data: [{ time, fret, width }] }` | Fret zoom anchors |
| `chord_templates` | `{ type, data: [{ name, frets: [6] }] }` | Named chord shapes | | `chord_templates` | `{ type, data: [{ name, frets: [6] }] }` | Named chord shapes |
| `lyrics` | `{ type, data: [{ w, t, d }], source }` | Syllables: `w`=word, `t`=time, `d`=duration. `-` joins to previous, `+` = line break. `source` is one of `"xml"`, `"whisperx"`, `"user"` — UI can use it to render an "auto-transcribed" badge for `whisperx`. Sloppaks always include `source` (legacy sloppaks without a `lyrics_source` manifest key default to `"xml"` at load time). Loose folders set it based on which extractor matched. Absent only when no lyrics fired the message at all | | `lyrics` | `{ type, data: [{ w, t, d }], source }` | Syllables: `w`=word, `t`=time, `d`=duration. `-` joins to previous, `+` = line break. `source` is one of `"xml"`, `"whisperx"`, `"user"` — UI can use it to render an "auto-transcribed" badge for `whisperx`. Sloppaks always include `source` (legacy sloppaks without a `lyrics_source` manifest key default to `"xml"` at load time). Loose folders set it based on which extractor matched. Absent only when no lyrics fired the message at all |
| `tone_changes` | `{ type: 'tone_changes', base, data: [{ time, name }] }` | Optional — tone change events relative to the arrangement base tone; only sent if tones were found | | `tone_changes` | `{ type: 'tone_changes', base, base_rig?, data: [{ t, name, rig? }] }` | Optional — tone change events relative to the arrangement base tone; only sent if tones were found. Note the time key is **`t`**, not `time` (both the sloppak path and the legacy XML path emit `t`). `base_rig` and each entry's `rig` are the pack's **rig bindings** — ids into [`rigs.json`](https://github.com/got-feedback/feedpak-spec/blob/main/spec/feedpak-v1.md#79-rigsjson) (feedpak §6.9/§7.9), carried through verbatim and **not** resolved by core: selecting a realization and applying the `intent.gm` floor belong to whatever voices the part. Both are **omitted entirely** when the chart binds no rig, so consumers predating the rig model see the payload they always did. |
| `notes` | `{ type, data: [{ t, s, f, sus, ho, po, sl, bn, ... }] }` | Single notes | | `notes` | `{ type, data: [{ t, s, f, sus, ho, po, sl, bn, ... }] }` | Single notes |
| `chords` | `{ type, data: [{ t, notes: [{ s, f, sus, ... }] }] }` | Chord events | | `chords` | `{ type, data: [{ t, notes: [{ s, f, sus, ... }] }] }` | Chord events |
| `phrases` | `{ type, data: [{ start_time, end_time, max_difficulty, levels: [{ difficulty, notes, chords, anchors, handshapes }] }], total }` | Optional — per-phrase difficulty ladder for master-difficulty slider (feedBack#48). Only sent when the source chart carries multi-level phrase data (phrase-aware sloppak). Sent in chunks (`data` is a batch, `total` is the full count across messages) to avoid multi-MB single frames. Absent for GP imports and legacy sloppak; consumers must treat missing message as "single fixed difficulty — slider disabled". | | `phrases` | `{ type, data: [{ start_time, end_time, max_difficulty, levels: [{ difficulty, notes, chords, anchors, handshapes }] }], total }` | Optional — per-phrase difficulty ladder for master-difficulty slider (feedBack#48). Only sent when the source chart carries multi-level phrase data (phrase-aware sloppak). Sent in chunks (`data` is a batch, `total` is the full count across messages) to avoid multi-MB single frames. Absent for GP imports and legacy sloppak; consumers must treat missing message as "single fixed difficulty — slider disabled". |
+2 -436
View File
@@ -1,6 +1,6 @@
"""MIDI file import — list tracks and convert tracks to sloppak payloads. """MIDI file import — list tracks and convert tracks to sloppak payloads.
Three parallel flows live here: Two parallel flows live here:
- **Keys path** (`list_midi_tracks` + `convert_midi_track_to_keys_wire`): - **Keys path** (`list_midi_tracks` + `convert_midi_track_to_keys_wire`):
filters channel-9 out and emits a standard guitar-style arrangement that filters channel-9 out and emits a standard guitar-style arrangement that
@@ -11,19 +11,12 @@ Three parallel flows live here:
`docs/sloppak-spec.md` §5.3, ready to drop alongside the sloppak `docs/sloppak-spec.md` §5.3, ready to drop alongside the sloppak
manifest's `drum_tab:` key. manifest's `drum_tab:` key.
- **Lyrics path** (`extract_midi_lyrics`): reads SMF Lyric (0x05) meta events The editor's track picker uses both for the +Drums and +Keys modals.
(with a Text-event fallback on vocal-ish tracks, covering karaoke `.kar`
files) and emits the `lyrics.json` / `vocal_pitch.json` sidecar payloads
documented in feedpak-spec §7.1 / §7.2, ready to drop alongside the
manifest's `lyrics:` / `lyrics_source:` / `vocal_pitch:` keys.
The editor's track picker uses the first two for the +Drums and +Keys modals.
""" """
from __future__ import annotations from __future__ import annotations
import math import math
import re
from bisect import bisect_right from bisect import bisect_right
from collections import deque from collections import deque
from typing import Callable from typing import Callable
@@ -784,430 +777,3 @@ def convert_drum_track_from_midi(
], ],
"hits": out_hits, "hits": out_hits,
} }
# ── Lyrics + vocal-melody extraction ─────────────────────────────────────────
# Vocal-track detection, mirroring the idiom in `lib/gp2rs_gpx.py`'s
# `_is_vocal_track` (GM voice/choir/lead-voice programs + name keywords).
# Kept as a local copy because that helper consumes gp2rs_gpx's own GP track
# dicts, not raw MIDI tracks. "melody" is added to the name hints: karaoke
# MIDIs commonly label the sung line "Melody" rather than "Vocals".
_VOCAL_MIDI_PROGRAMS = {52, 53, 54, 85, 86, 87} # Choir Aahs, Voice Oohs, Synth Voice, Lead 5-7 (voice)
_VOCAL_NAME_HINTS = ("vocal", "voice", "vox", "sing", "lyric", "choir", "melody")
# A dedicated karaoke *text* track (SMF 0x01 Text events, `.kar` convention)
# is usually noteless and named "Words" or "Soft Karaoke" — names the vocal
# hints above don't catch. Only the Text-event fallback consults this wider
# set; note-track detection sticks to the gp2rs_gpx idiom.
_LYRIC_TEXT_TRACK_HINTS = _VOCAL_NAME_HINTS + ("words", "karaoke")
# A lyric event pairs with a vocal note-on when their onsets sit within this
# window. Karaoke files place the lyric event at (or a hair before) the
# note-on tick, so real matches are ~0; the window only absorbs sloppy
# authoring, and staying well under a typical syllable gap keeps a melisma's
# extra notes from being stolen by the next syllable.
_LYRIC_PAIR_TOLERANCE_S = 0.30
# Duration bounds for lyric entries with no pairable note (spoken lines,
# lyrics-only files). "Until the next lyric event" is the natural display
# duration, capped so a verse-final syllable before a long instrumental
# break doesn't linger on screen, and floored so simultaneous/out-of-order
# events can't produce a zero or negative duration.
_UNPAIRED_LYRIC_MAX_D = 2.0
_UNPAIRED_LYRIC_MIN_D = 0.1
# Leading/word/trailing whitespace splitter for lyric tokens. DOTALL so
# embedded newlines land in a group rather than killing the match.
_LYRIC_TOKEN_RE = re.compile(r"^(\s*)(.*?)(\s*)$", re.S)
def _scan_tracks_for_lyrics(midi: mido.MidiFile) -> list[dict]:
"""One pass per track collecting the raw material `extract_midi_lyrics`
needs: name, per-channel programs, melodic (non-drum) notes with their
on/off ticks, and Lyric/Text meta events.
Each item: {name, channel_programs: {ch: program}, notes:
[(start_tick, end_tick, pitch, channel)], lyric_events: [(tick, text)],
text_events: [(tick, text)]}. Note pairing uses the same FIFO
note_on/note_off matching as the keys converter so retriggers don't
cross-wire durations.
"""
out: list[dict] = []
for track in midi.tracks:
name = ""
channel_programs: dict[int, int] = {}
lyric_events: list[tuple[int, str]] = []
text_events: list[tuple[int, str]] = []
notes: list[tuple[int, int, int, int]] = []
active: dict[tuple[int, int], deque[int]] = {}
abs_tick = 0
for msg in track:
abs_tick += msg.time
if msg.type == "track_name" and not name:
name = msg.name or ""
elif msg.type == "lyrics":
lyric_events.append((abs_tick, msg.text or ""))
elif msg.type == "text":
text_events.append((abs_tick, msg.text or ""))
elif msg.type == "program_change":
ch = int(getattr(msg, "channel", -1))
if ch != 9 and ch not in channel_programs:
channel_programs[ch] = int(msg.program)
elif msg.type == "note_on" and int(getattr(msg, "velocity", 0)) > 0:
ch = int(getattr(msg, "channel", -1))
if ch == 9:
continue
active.setdefault((ch, int(msg.note)), deque()).append(abs_tick)
elif msg.type == "note_off" or (
msg.type == "note_on" and int(getattr(msg, "velocity", 0)) == 0
):
ch = int(getattr(msg, "channel", -1))
pitch = int(msg.note)
stack = active.get((ch, pitch))
if not stack:
continue
start_tick = stack.popleft()
if not stack:
active.pop((ch, pitch), None)
notes.append((start_tick, abs_tick, pitch, ch))
# Close anything left hanging at end-of-track, mirroring the keys
# converter's end-of-track sweep.
for (ch, pitch), starts in active.items():
for start_tick in starts:
notes.append((start_tick, abs_tick, pitch, ch))
notes.sort(key=lambda n: n[0])
out.append({
"name": name,
"channel_programs": channel_programs,
"notes": notes,
"lyric_events": lyric_events,
"text_events": text_events,
})
return out
def _name_matches(name: str, hints: tuple[str, ...]) -> bool:
name_l = (name or "").lower()
return any(h in name_l for h in hints)
def _normalize_lyric_tokens(events: list[tuple[int, str]]) -> list[dict]:
"""Turn raw lyric/text meta events into clean syllable tokens.
Handles both encodings seen in the wild:
- **`.kar` / karaoke convention**: `/` prefix = new line, `\\` prefix =
new paragraph (both mean "the previous syllable ended a line"),
`-` suffix = syllable joins the next one, `@`-prefixed tokens are
file metadata (`@KMIDI`, `@T<title>`, ...) and are dropped.
- **Plain Lyric-event convention**: word boundaries carried by leading
or trailing spaces; line breaks carried by embedded CR/LF.
Each token: {tick, word, lead_ws, trail_ws, line_end}. Spacing-only and
newline-only events don't emit a token — they fold their meaning
(word-break / line-end) onto the previous one.
"""
toks: list[dict] = []
for tick, raw in events:
text = "" if raw is None else str(raw)
if not text:
continue
if text.lstrip().startswith(("@", "%")):
# .kar metadata / sequencer directives, not sung text.
continue
kar_break = text[0] in ("/", "\\")
if kar_break:
text = text[1:]
m = _LYRIC_TOKEN_RE.match(text)
head, body, tail = m.group(1), m.group(2), m.group(3)
nl_before = ("\n" in head) or ("\r" in head)
nl_after = ("\n" in tail) or ("\r" in tail)
if "\n" in body or "\r" in body:
# Rare multi-line event: keep it one token, treat the break as
# trailing so the line ends after this token.
body = re.sub(r"[\r\n]+", " ", body).strip()
nl_after = True
if (kar_break or nl_before) and toks:
toks[-1]["line_end"] = True
if not body:
# Pure spacing/newline token: fold onto the previous syllable.
if nl_after and toks:
toks[-1]["line_end"] = True
if toks:
toks[-1]["trail_ws"] = True
continue
toks.append({
"tick": tick,
"word": body,
"lead_ws": bool(head),
"trail_ws": bool(tail),
"line_end": nl_after,
})
return toks
def _apply_word_conventions(toks: list[dict]) -> list[str]:
"""Map tokens to spec §7.1 `w` strings: trailing ``-`` joins to the next
syllable, trailing ``+`` ends a line.
Which join convention the source used is detected per stream:
- Any token already carrying a ``-`` suffix the stream is
hyphen-delimited (`.kar` style); those suffixes are the spec's own
join marker and pass through untouched.
- Otherwise, if the stream carries any spacing at all space-delimited:
a token with no trailing space followed by a token with no leading
space is a mid-word syllable and gains a ``-``.
- No hyphens and no spacing anywhere the tokens are whole words
(common for Text-event lyrics); no joins are synthesized.
"""
has_hyphens = any(t["word"].endswith("-") for t in toks)
has_spacing = any(t["lead_ws"] or t["trail_ws"] for t in toks)
words: list[str] = []
for i, tk in enumerate(toks):
w = tk["word"]
nxt = toks[i + 1] if i + 1 < len(toks) else None
if tk["line_end"]:
# A join can't cross a line break — the line marker wins.
if w.endswith("-"):
w = w[:-1]
if w and not w.endswith("+"):
w += "+"
elif nxt is not None and not has_hyphens and has_spacing:
if not tk["trail_ws"] and not nxt["lead_ws"] and not w.endswith("-"):
w += "-"
words.append(w)
return words
def _select_vocal_notes(
scans: list[dict],
lyric_track_index: int,
midi_type: int,
) -> tuple[int, list[tuple[int, int, int, int]]] | None:
"""Pick the note pool the lyric syllables should be pitch-paired with.
Returns ``(track_index, notes)`` or ``None`` when no vocal melody is
identifiable ( lyrics-only import). Selection order:
1. The lyric-carrying track itself, when it has notes:
- channels with a vocal GM program only those channels' notes
(isolates the sung line inside a format-0 everything-in-one-track
file);
- vocal-ish track name all its non-drum notes;
- SMF type 1/2 with neither still trusted: a track that interleaves
per-syllable Lyric events with its own notes *is* the karaoke
melody by construction. Format-0 files don't get this benefit of
the doubt there the single track holds every instrument, so
without a vocal program/name there is no way to isolate the melody
and we fall back to lyrics-only.
2. Otherwise (dedicated noteless "Words" track), the vocal-ish track
by name hint or vocal GM program, mirroring gp2rs_gpx with the
most notes; within it, vocal-program channels only when present.
"""
def _vocal_channels(scan: dict) -> set[int]:
return {
ch for ch, prog in scan["channel_programs"].items()
if prog in _VOCAL_MIDI_PROGRAMS
}
def _pool(scan: dict) -> list[tuple[int, int, int, int]]:
chans = _vocal_channels(scan)
if chans:
return [n for n in scan["notes"] if n[3] in chans]
return scan["notes"]
src = scans[lyric_track_index]
if src["notes"]:
if _vocal_channels(src) or _name_matches(src["name"], _VOCAL_NAME_HINTS):
return lyric_track_index, _pool(src)
if midi_type != 0:
return lyric_track_index, src["notes"]
return None
best: tuple[int, list] | None = None
for i, scan in enumerate(scans):
if not scan["notes"]:
continue
if not (_vocal_channels(scan)
or _name_matches(scan["name"], _VOCAL_NAME_HINTS)):
continue
pool = _pool(scan)
if pool and (best is None or len(pool) > len(best[1])):
best = (i, pool)
return best
def extract_midi_lyrics(midi_path: str, audio_offset: float = 0.0) -> dict | None:
"""Extract lyrics (and, when pairable, the vocal melody) from a `.mid`.
Returns ``None`` when the file carries no usable lyric events callers
then change nothing, leaving any existing manifest keys and sidecar
files untouched. Otherwise returns::
{
"lyrics": [{"t": float, "d": float, "w": str}, ...],
"lyrics_source": "authored",
"vocal_pitch": {"version": 1,
"notes": [{"t", "d", "midi"}, ...]} | None,
}
``lyrics`` is the feedpak `lyrics.json` payload (spec §7.1: flat list,
no version field; ``w`` uses trailing ``-`` for syllable joins and
trailing ``+`` for line ends). ``vocal_pitch`` is the `vocal_pitch.json`
payload (spec §7.2, same shape as gp2rs_gpx's
``convert_vocal_track_to_pitch_sidecar`` and the lyrics-karaoke
plugin's ``_persist_pitch``) — ``None`` when no vocal note track could
be identified, in which case the caller writes `lyrics.json` only.
``lyrics_source`` is always ``"authored"`` (spec §7.1 vocabulary):
lyric meta events are chart-author data, not machine transcription.
Callers assembling a pack write ``lyrics.json`` /
``vocal_pitch.json`` and set the manifest ``lyrics`` /
``lyrics_source`` / ``vocal_pitch`` keys and should do so only for
keys not already present, so an import never clobbers lyrics that
arrived from another source.
Sourcing rules:
- Lyric text comes from SMF Lyric (0x05) meta events the track with
the most of them wins when several carry some. When the file has
none at all, Text (0x01) events are accepted as a fallback, but only
from a vocal-ish track (gp2rs_gpx-style name/program detection,
widened with "words"/"karaoke" for `.kar` text tracks) Text events
elsewhere are copyright notices / markers, not lyrics.
- `.kar` conventions are normalized (see ``_normalize_lyric_tokens`` /
``_apply_word_conventions``): ``/`` and ``\\`` line-break prefixes
become the spec's ``+`` suffix on the previous syllable, ``@``
metadata tokens are dropped, ``-`` hyphen joins pass through.
- Each syllable is paired with the vocal note (see
``_select_vocal_notes``) whose onset falls within
``_LYRIC_PAIR_TOLERANCE_S`` of the lyric event, greedily in time
order, one note per syllable. Paired syllables snap ``t``/``d`` to
the note (the authored melody is timing-authoritative, and keeps
`lyrics.json` and `vocal_pitch.json` mirrored per §7.2); a melisma's
extra notes are skipped. Unpaired syllables (talkies) keep the lyric
event's own time and run until the next syllable, clamped to
[``_UNPAIRED_LYRIC_MIN_D``, ``_UNPAIRED_LYRIC_MAX_D``] they appear
in ``lyrics`` only, which spec §7.2 explicitly allows
(`vocal_pitch.notes` MAY be shorter than `lyrics.json`).
``audio_offset`` (seconds) shifts every emitted time, same handle as
the keys/drums converters. Tempo-map scope per SMF type also matches
them (type 2 reads only the involved track's tempo events).
"""
offset = float(audio_offset)
if not math.isfinite(offset):
raise ValueError(f"audio_offset must be a finite number, got {audio_offset!r}")
midi = mido.MidiFile(midi_path)
midi_type = getattr(midi, "type", 1)
scans = _scan_tracks_for_lyrics(midi)
# ── choose the lyric event stream ────────────────────────────────────
lyric_idx = -1
best_count = 0
for i, scan in enumerate(scans):
if len(scan["lyric_events"]) > best_count:
lyric_idx = i
best_count = len(scan["lyric_events"])
if lyric_idx >= 0:
toks = _normalize_lyric_tokens(scans[lyric_idx]["lyric_events"])
else:
# Text-event fallback: vocal-ish tracks only (plus .kar "Words" /
# "Soft Karaoke" text tracks). Normalize before counting so a track
# of @-metadata can't outscore a real lyric track.
toks = []
for i, scan in enumerate(scans):
if not scan["text_events"]:
continue
vocal_prog = any(
p in _VOCAL_MIDI_PROGRAMS
for p in scan["channel_programs"].values()
)
if not (vocal_prog
or _name_matches(scan["name"], _LYRIC_TEXT_TRACK_HINTS)):
continue
cand = _normalize_lyric_tokens(scan["text_events"])
if len(cand) > len(toks):
lyric_idx = i
toks = cand
if lyric_idx < 0 or not toks:
return None
words = _apply_word_conventions(toks)
lyric_tick_to_seconds = _build_tick_to_seconds(midi, lyric_idx)
# ── pick + time the vocal note pool ──────────────────────────────────
picked = _select_vocal_notes(scans, lyric_idx, midi_type)
vocal_notes: list[dict] = []
if picked is not None:
note_idx, pool = picked
# Type-2 tracks own independent timelines — time the notes through
# their own track's tempo scope (same map as the lyric track for
# type 0/1, where tempo is merged across tracks anyway).
note_tick_to_seconds = (
lyric_tick_to_seconds if note_idx == lyric_idx
else _build_tick_to_seconds(midi, note_idx)
)
for start_tick, end_tick, pitch, _ch in pool:
t = note_tick_to_seconds(start_tick)
vocal_notes.append({
"t": t,
"d": max(0.0, note_tick_to_seconds(end_tick) - t),
"midi": int(pitch),
})
vocal_notes.sort(key=lambda n: n["t"])
# ── pair syllables with notes (greedy, time-ordered) ─────────────────
entries: list[dict] = [] # {t, d (None until resolved), w, paired}
j = 0
for tk, w in zip(toks, words):
t_lyric = lyric_tick_to_seconds(tk["tick"])
while (j < len(vocal_notes)
and vocal_notes[j]["t"] < t_lyric - _LYRIC_PAIR_TOLERANCE_S):
j += 1
if (j < len(vocal_notes)
and vocal_notes[j]["t"] <= t_lyric + _LYRIC_PAIR_TOLERANCE_S):
note = vocal_notes[j]
j += 1
entries.append({
"t": note["t"], "d": note["d"], "w": w,
"midi": note["midi"], "paired": True,
})
else:
entries.append({"t": t_lyric, "d": None, "w": w, "paired": False})
# Snapping can nudge a paired syllable past an unpaired neighbour;
# sort so both sidecars stay chronological for downstream consumers.
entries.sort(key=lambda e: e["t"])
# Unpaired durations: until the next syllable, clamped. Resolved after
# the sort so "next" is the true chronological neighbour.
for i, e in enumerate(entries):
if e["d"] is None:
if i + 1 < len(entries):
gap = entries[i + 1]["t"] - e["t"]
d = min(gap, _UNPAIRED_LYRIC_MAX_D)
else:
d = _UNPAIRED_LYRIC_MAX_D
e["d"] = max(d, _UNPAIRED_LYRIC_MIN_D)
lyrics_out = [
{"t": round(e["t"] + offset, 3), "d": round(e["d"], 3), "w": e["w"]}
for e in entries
]
pitch_notes = [
{"t": round(e["t"] + offset, 3), "d": round(e["d"], 3),
"midi": int(e["midi"])}
for e in entries if e["paired"]
]
return {
"lyrics": lyrics_out,
"lyrics_source": "authored",
"vocal_pitch": (
{"version": 1, "notes": pitch_notes} if pitch_notes else None
),
}
+15 -6
View File
@@ -774,20 +774,29 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# (Arrangement.tones, populated by the converter), so read it straight # (Arrangement.tones, populated by the converter), so read it straight
# off `arr` rather than walking for XML that doesn't exist. # off `arr` rather than walking for XML that doesn't exist.
if is_slop: if is_slop:
# `sloppak_tone_changes` builds the (base, sorted changes) pair # `sloppak_tone_changes` builds the (base, base_rig, sorted
# from `Arrangement.tones`, skipping non-string names and # changes) triple from `Arrangement.tones`, skipping non-string
# non-finite/non-numeric times — unit-tested in test_tones.py. # names, non-finite/non-numeric times, and unusable rig ids —
# unit-tested in test_tones.py.
from tones import sloppak_tone_changes from tones import sloppak_tone_changes
base_name, tone_changes = sloppak_tone_changes(getattr(arr, "tones", None)) base_name, base_rig, tone_changes = sloppak_tone_changes(
getattr(arr, "tones", None)
)
# Send when there's a base tone OR timed changes — a single-tone # Send when there's a base tone OR timed changes — a single-tone
# arrangement has a base but no switches, and the highway should # arrangement has a base but no switches, and the highway should
# still be able to show the initial tone. # still be able to show the initial tone.
if tone_changes or base_name: if tone_changes or base_name:
await websocket.send_json({ payload = {
"type": "tone_changes", "type": "tone_changes",
"base": base_name, "base": base_name,
"data": tone_changes, "data": tone_changes,
}) }
# `base_rig` is additive (feedpak-spec §6.9) — omitted entirely
# when the chart binds no rig, so consumers that predate the rig
# model see the exact payload they always did.
if base_rig:
payload["base_rig"] = base_rig
await websocket.send_json(payload)
else: else:
xml_paths = sorted(_xml_walk("*.xml")) xml_paths = sorted(_xml_walk("*.xml"))
+148 -2
View File
@@ -698,6 +698,14 @@ class LoadedSloppak:
# absent / unreadable / malformed. Streamed over the highway WS as a # absent / unreadable / malformed. Streamed over the highway WS as a
# `keys` message; consumers (renderers, plugins) read it from there. # `keys` message; consumers (renderers, plugins) read it from there.
keys: dict | None = None 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` # Sanitized song-level tempo + time-signature maps from `song_timeline.json`
# (feedpak 1.2.0). `tempos`: [{time, bpm}]; `time_signatures`: [{time, ts}]. # (feedpak 1.2.0). `tempos`: [{time, bpm}]; `time_signatures`: [{time, ts}].
# None when absent/empty. Streamed over the highway WS (`tempos` / # None when absent/empty. Streamed over the highway WS (`tempos` /
@@ -776,18 +784,117 @@ def _load_drum_tab_file(source_dir: Path, rel: str, label: str) -> dict | None:
return raw 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 _entry_tones(entry: dict) -> dict | None:
"""A manifest entry's `tones` binding, or None when it doesn't carry one.
Spec §5.2: a manifest arrangement entry's `tones` overrides the arrangement
JSON's `tones` **wholesale** — no field-level merge. This normalizes the
"does it carry one" test for both the arrangement path and the drum path.
An empty dict reads as *absent*, not as "override to silence": it is what a
Writer emits by accident, `arrangement_from_wire` already normalizes the
in-JSON `{}` to None the same way, and treating it as an override would let
a stray empty object silently unbind a part's sound.
"""
tones = entry.get("tones")
return tones if isinstance(tones, dict) and tones else None
def _resolve_drum_parts( def _resolve_drum_parts(
source_dir: Path, source_dir: Path,
drum_tab_rel: object, drum_tab_rel: object,
drum_tab_data: dict | None, drum_tab_data: dict | None,
drum_pointer_entries: list[dict], drum_pointer_entries: list[dict],
drum_tones: dict | None = None,
) -> tuple[dict | None, list[dict] | None]: ) -> tuple[dict | None, list[dict] | None]:
"""Resolve drum pointers into a primary-first list with unique ids.""" """Resolve drum pointers into a primary-first list with unique ids.
Also binds each part's sound (feedpak 1.18.0). The precedence mirrors the
`drum_tab` alias rule this function already implements: a `type: drums`
entry's own `tones` wins for that part, and the song-level `drum_tones` is
the fallback for the **primary** part only. A Reader MUST NOT apply both to
the same part (spec §5.1/§5.2), which is why the primary picks one or the
other here rather than merging them.
"""
if drum_tab_data is None and not drum_pointer_entries: if drum_tab_data is None and not drum_pointer_entries:
return drum_tab_data, None return drum_tab_data, None
primary_id = "drums" primary_id = "drums"
primary_name = None primary_name = None
# The primary's own binding, lifted from its alias pointer entry when it has
# one. Stays None if no entry claims the primary — `drum_tones` fills in.
primary_tones = None
extra_parts: list[dict] = [] extra_parts: list[dict] = []
seen_rels: set[str] = set() seen_rels: set[str] = set()
# Use the same canonical, traversal-safe identity as zip member lookup so # Use the same canonical, traversal-safe identity as zip member lookup so
@@ -811,6 +918,11 @@ def _resolve_drum_parts(
primary_id = entry_id primary_id = entry_id
if entry_name: if entry_name:
primary_name = entry_name primary_name = entry_name
# This entry IS the primary (an alias pointer at the same file), so
# its binding is the primary's — and it outranks `drum_tones`.
_alias_tones = _entry_tones(entry)
if _alias_tones is not None:
primary_tones = _alias_tones
continue continue
tab = _load_drum_tab_file(source_dir, rel, f"drum part {entry_id or rel}") tab = _load_drum_tab_file(source_dir, rel, f"drum part {entry_id or rel}")
if tab is None: if tab is None:
@@ -821,6 +933,9 @@ def _resolve_drum_parts(
"name": entry_name "name": entry_name
or (tab_name if isinstance(tab_name, str) and tab_name else "Drums"), or (tab_name if isinstance(tab_name, str) and tab_name else "Drums"),
"drum_tab": tab, "drum_tab": tab,
# Non-primary parts bind through their own entry only; `drum_tones`
# is explicitly the primary's fallback, never theirs.
"tones": _entry_tones(entry),
}) })
parts: list[dict] = [] parts: list[dict] = []
@@ -829,7 +944,14 @@ def _resolve_drum_parts(
if primary_name is None: if primary_name is None:
tab_name = drum_tab_data.get("name") tab_name = drum_tab_data.get("name")
primary_name = tab_name if isinstance(tab_name, str) and tab_name else "Drums" 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}) parts.append({
"id": primary_id,
"name": primary_name,
"drum_tab": drum_tab_data,
# Entry `tones` takes precedence; `drum_tones` is the fallback. One
# or the other, never both on the same part (spec §5.1).
"tones": primary_tones if primary_tones is not None else drum_tones,
})
used_ids.add(primary_id) used_ids.add(primary_id)
next_generated_id = 2 next_generated_id = 2
@@ -948,6 +1070,14 @@ def load_song(
# _finite_float keeps a malformed manifest NaN/Infinity from # _finite_float keeps a malformed manifest NaN/Infinity from
# poisoning the song_info JSON (same guard as the wire path). # poisoning the song_info JSON (same guard as the wire path).
arr.cent_offset = _finite_float(entry["centOffset"]) arr.cent_offset = _finite_float(entry["centOffset"])
# `tones` overrides WHOLESALE, unlike the field-level overrides above:
# the entry's object replaces the arrangement JSON's entirely, with no
# per-field merge (spec §5.2). A Writer SHOULD NOT emit both, but when
# one does, a half-merged sound — this pack's base with that pack's
# changes — would be worse than either source alone.
_entry_tone_block = _entry_tones(entry)
if _entry_tone_block is not None:
arr.tones = _entry_tone_block
# Beats/sections can live on the arrangement itself in the wire format. # Beats/sections can live on the arrangement itself in the wire format.
# If the manifest-level arrangement JSON carries them, pull them onto # If the manifest-level arrangement JSON carries them, pull them onto
@@ -1020,8 +1150,15 @@ def load_song(
# Keep the dense compatibility logic independently testable and guarantee # Keep the dense compatibility logic independently testable and guarantee
# ids are unique before the highway exposes them as selectors. # ids are unique before the highway exposes them as selectors.
# Top-level `drum_tones` (spec §5.1) binds the song-level drum part — the
# fallback for packs without `type: drums` arrangements. Same shape as an
# arrangement entry's `tones`; `_resolve_drum_parts` owns the precedence.
_raw_drum_tones = manifest.get("drum_tones")
drum_tones_data = _raw_drum_tones if isinstance(_raw_drum_tones, dict) and _raw_drum_tones else None
drum_tab_data, drum_parts = _resolve_drum_parts( drum_tab_data, drum_parts = _resolve_drum_parts(
source_dir, drum_tab_rel, drum_tab_data, drum_pointer_entries, source_dir, drum_tab_rel, drum_tab_data, drum_pointer_entries,
drum_tones_data,
) )
# Drum-only sloppak: every GP track was percussion, so it ships a # Drum-only sloppak: every GP track was percussion, so it ships a
@@ -1325,6 +1462,14 @@ def load_song(
"events": clean_events, "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") _fpv = manifest.get("feedpak_version")
# The pack's full mix. Normally the RESERVED `full` stem partitioned out # 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 # above (spec §5.3) — no path work needed, it was validated with the other
@@ -1355,6 +1500,7 @@ def load_song(
tempos=tempos_data, tempos=tempos_data,
time_signatures=time_sigs_data, time_signatures=time_sigs_data,
keys=keys_data, keys=keys_data,
rigs=rigs_data,
notation_by_id=notation_by_id_data, notation_by_id=notation_by_id_data,
arrangement_ids=arrangement_ids_acc, arrangement_ids=arrangement_ids_acc,
full_mix=full_mix_data, full_mix=full_mix_data,
+25 -9
View File
@@ -32,20 +32,29 @@ def tokens(s: str) -> set[str]:
return {t for t in re.split(r"[^a-z0-9]+", (s or "").lower()) if t} return {t for t in re.split(r"[^a-z0-9]+", (s or "").lower()) if t}
def sloppak_tone_changes(arr_tones) -> tuple[str, list[dict]]: def sloppak_tone_changes(arr_tones) -> tuple[str, str, list[dict]]:
"""Build the highway tone-change payload from an arrangement's tone block. """Build the highway tone-change payload from an arrangement's tone block.
Given ``Arrangement.tones`` (the dict embedded in the sloppak, or ``None``), Given ``Arrangement.tones`` (the dict embedded in the sloppak, or ``None``),
returns ``(base, changes)`` where ``base`` is the initial tone name and returns ``(base, base_rig, changes)`` where ``base`` is the initial tone
``changes`` is a time-sorted ``[{"t", "name"}]`` list. Non-string names, name, ``base_rig`` is the ``rigs.json`` rig id bound to it (feedpak-spec
non-dict entries, and non-numeric / non-finite times are skipped a §6.9; ``""`` when absent), and ``changes`` is a time-sorted
hand-edited or third-party sloppak must not crash the highway WebSocket ``[{"t", "name", "rig"?}]`` list. Non-string names, non-dict entries, and
or emit NaN/inf (which the client's ``JSON.parse`` rejects). non-numeric / non-finite times are skipped a hand-edited or third-party
sloppak must not crash the highway WebSocket or emit NaN/inf (which the
client's ``JSON.parse`` rejects).
``rig`` / ``base_rig`` are carried through but NOT resolved against
``rigs.json`` here: this builder only preserves the binding the chart
declared. Realization selection and the ``intent.gm`` fallback (§7.9) belong
to the consumer that actually voices the part.
""" """
if not isinstance(arr_tones, dict): if not isinstance(arr_tones, dict):
return "", [] return "", "", []
base_val = arr_tones.get("base", "") base_val = arr_tones.get("base", "")
base = base_val.strip() if isinstance(base_val, str) else "" base = base_val.strip() if isinstance(base_val, str) else ""
base_rig_val = arr_tones.get("base_rig", "")
base_rig = base_rig_val.strip() if isinstance(base_rig_val, str) else ""
changes: list[dict] = [] changes: list[dict] = []
raw_changes = arr_tones.get("changes") raw_changes = arr_tones.get("changes")
@@ -65,6 +74,13 @@ def sloppak_tone_changes(arr_tones) -> tuple[str, list[dict]]:
continue continue
if not math.isfinite(t): if not math.isfinite(t):
continue continue
changes.append({"t": round(t, 3), "name": name}) change = {"t": round(t, 3), "name": name}
# ponytail: `rig` only when it's a usable id — a non-string or blank
# value is dropped rather than forwarded, so a consumer can treat
# presence of the key as "this change binds a rig".
rig = c.get("rig")
if isinstance(rig, str) and rig.strip():
change["rig"] = rig.strip()
changes.append(change)
changes.sort(key=lambda x: x["t"]) changes.sort(key=lambda x: x["t"])
return base, changes return base, base_rig, changes
-282
View File
@@ -1,282 +0,0 @@
"""Tests for lib/midi_import.py — extract_midi_lyrics (lyrics + vocal melody).
Synthetic mido.MidiFile objects are built in-memory and saved to tmp_path,
same style as test_midi_import.py.
Covers:
- Lyric (0x05) events on a vocal track lyrics.json + vocal_pitch.json payloads
- lyric/note pairing snaps t/d to the note; midi pitch carried through
- lyrics with no identifiable vocal track lyrics payload only
- no lyric events at all None (import behavior unchanged)
- .kar '/' line-break prefixes spec trailing '+' on the previous syllable
- .kar '-' hyphen joins pass through untouched
- space-delimited syllable streams gain '-' joins
- '@'-metadata tokens dropped; Text-event (0x01) fallback on vocal-ish tracks
- Text events on non-vocal tracks are NOT treated as lyrics
- unpaired lyric durations run to the next syllable, capped at 2.0 s
- format-0 mixed-channel file pairs only the vocal-program channel
- vocal GM program (52-54 / 85-87) detection without a track name
- audio_offset applied to both payloads
"""
import pytest
import mido
from midi_import import extract_midi_lyrics
TPB = 480 # ticks per beat; default 120 BPM → 480 ticks = 0.5 s
def _save(mid: mido.MidiFile, tmp_path, name: str = "test.mid") -> str:
p = tmp_path / name
mid.save(str(p))
return str(p)
def _vocal_file(tmp_path, *, track_name="Vocals", program=None, meta="lyrics",
syllables=("Hel-", "lo", "world")):
"""Type-1 file: conductor + one melody track carrying notes with a lyric
event at each note-on. Notes: 60, 62, 64, each one beat (0.5 s) long,
back to back."""
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
conductor = mido.MidiTrack()
mid.tracks.append(conductor)
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
tr = mido.MidiTrack()
mid.tracks.append(tr)
if track_name:
tr.append(mido.MetaMessage("track_name", name=track_name, time=0))
if program is not None:
tr.append(mido.Message("program_change", channel=0, program=program, time=0))
for i, syl in enumerate(syllables):
tr.append(mido.MetaMessage(meta, text=syl, time=0))
tr.append(mido.Message("note_on", channel=0, note=60 + 2 * i,
velocity=90, time=0))
tr.append(mido.Message("note_off", channel=0, note=60 + 2 * i,
velocity=0, time=TPB))
return _save(mid, tmp_path)
# ── both sidecars from a lyric+vocal-track file ──────────────────────────────
def test_vocal_track_emits_both_payloads(tmp_path):
result = extract_midi_lyrics(_vocal_file(tmp_path))
assert result is not None
assert result["lyrics_source"] == "authored"
lyr = result["lyrics"]
assert [e["w"] for e in lyr] == ["Hel-", "lo", "world"]
assert [e["t"] for e in lyr] == pytest.approx([0.0, 0.5, 1.0])
# Paired syllables snap d to the note duration (1 beat = 0.5 s).
assert [e["d"] for e in lyr] == pytest.approx([0.5, 0.5, 0.5])
vp = result["vocal_pitch"]
assert vp is not None
assert vp["version"] == 1
assert [n["midi"] for n in vp["notes"]] == [60, 62, 64]
# vocal_pitch t/d mirror the matching lyrics entries (spec §7.2).
assert [(n["t"], n["d"]) for n in vp["notes"]] == \
[(e["t"], e["d"]) for e in lyr]
def test_vocal_program_detection_without_name(tmp_path):
"""GM program 53 (Voice Oohs) marks the track vocal even with no name."""
path = _vocal_file(tmp_path, track_name="", program=53)
result = extract_midi_lyrics(path)
assert result is not None
assert result["vocal_pitch"] is not None
assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [60, 62, 64]
# ── lyrics-only fallbacks ────────────────────────────────────────────────────
def test_no_vocal_track_emits_lyrics_only(tmp_path):
"""Lyric events on a noteless track + only a piano note track → the
lyrics payload is emitted but vocal_pitch is None (talkies path)."""
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
conductor = mido.MidiTrack()
mid.tracks.append(conductor)
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
words = mido.MidiTrack()
mid.tracks.append(words)
words.append(mido.MetaMessage("lyrics", text="Hello ", time=0))
words.append(mido.MetaMessage("lyrics", text="there ", time=TPB))
piano = mido.MidiTrack()
mid.tracks.append(piano)
piano.append(mido.MetaMessage("track_name", name="Piano", time=0))
piano.append(mido.Message("program_change", channel=0, program=0, time=0))
piano.append(mido.Message("note_on", channel=0, note=48, velocity=90, time=0))
piano.append(mido.Message("note_off", channel=0, note=48, velocity=0, time=TPB))
result = extract_midi_lyrics(_save(mid, tmp_path))
assert result is not None
assert [e["w"] for e in result["lyrics"]] == ["Hello", "there"]
assert result["vocal_pitch"] is None
def test_no_lyrics_returns_none(tmp_path):
"""A file without lyric events changes nothing — extraction reports None."""
mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
tr = mido.MidiTrack()
mid.tracks.append(tr)
tr.append(mido.Message("note_on", channel=0, note=60, velocity=90, time=0))
tr.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=TPB))
assert extract_midi_lyrics(_save(mid, tmp_path)) is None
def test_unpaired_duration_next_event_capped_at_2s(tmp_path):
"""Unpaired lyric entries last until the next syllable, capped at 2.0 s."""
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
conductor = mido.MidiTrack()
mid.tracks.append(conductor)
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
words = mido.MidiTrack()
mid.tracks.append(words)
words.append(mido.MetaMessage("lyrics", text="one ", time=0))
words.append(mido.MetaMessage("lyrics", text="two ", time=TPB)) # +0.5 s
words.append(mido.MetaMessage("lyrics", text="three ", time=TPB * 8)) # +4.0 s
result = extract_midi_lyrics(_save(mid, tmp_path))
lyr = result["lyrics"]
assert lyr[0]["d"] == pytest.approx(0.5) # gap to next syllable
assert lyr[1]["d"] == pytest.approx(2.0) # 4.0 s gap capped
assert lyr[2]["d"] == pytest.approx(2.0) # last entry: cap value
# ── .kar conventions ─────────────────────────────────────────────────────────
def test_kar_slash_line_break_maps_to_plus(tmp_path):
"""A '/' prefix on a syllable marks the END of the previous line — the
previous syllable gains the spec's trailing '+'."""
path = _vocal_file(
tmp_path, syllables=("Hel-", "lo", "/world"))
result = extract_midi_lyrics(path)
words = [e["w"] for e in result["lyrics"]]
assert words == ["Hel-", "lo+", "world"]
def test_kar_backslash_paragraph_break_maps_to_plus(tmp_path):
path = _vocal_file(tmp_path, syllables=("one", "\\two", "three"))
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
assert words == ["one+", "two", "three"]
def test_kar_hyphen_joins_pass_through(tmp_path):
""".kar hyphen suffixes already ARE the spec join marker — untouched,
and no extra '-' is synthesized onto hyphenless word-final syllables."""
path = _vocal_file(tmp_path, syllables=("beau-", "ti-", "ful", "day"))
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
assert words == ["beau-", "ti-", "ful", "day"]
def test_space_delimited_stream_gains_hyphen_joins(tmp_path):
"""Space-delimited Lyric streams ('Hel' 'lo ' 'world') carry word
boundaries in whitespace mid-word syllables gain the '-' join."""
path = _vocal_file(tmp_path, syllables=("Hel", "lo ", "world "))
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
assert words == ["Hel-", "lo", "world"]
def test_newline_in_lyric_event_ends_line(tmp_path):
path = _vocal_file(tmp_path, syllables=("one \n", "two ", "three "))
words = [e["w"] for e in extract_midi_lyrics(path)["lyrics"]]
assert words == ["one+", "two", "three"]
# ── Text-event (0x01) fallback ───────────────────────────────────────────────
def test_text_event_fallback_on_vocal_track(tmp_path):
"""With no 0x05 events anywhere, Text events on a vocal-ish track are
accepted as lyrics; '@'-prefixed .kar metadata tokens are dropped."""
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
conductor = mido.MidiTrack()
mid.tracks.append(conductor)
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
tr = mido.MidiTrack()
mid.tracks.append(tr)
tr.append(mido.MetaMessage("track_name", name="Melody", time=0))
tr.append(mido.MetaMessage("text", text="@KMIDI KARAOKE FILE", time=0))
tr.append(mido.MetaMessage("text", text="@T A Song", time=0))
for i, syl in enumerate(("Some ", "words ")):
tr.append(mido.MetaMessage("text", text=syl, time=0))
tr.append(mido.Message("note_on", channel=0, note=64 + i, velocity=90,
time=0))
tr.append(mido.Message("note_off", channel=0, note=64 + i, velocity=0,
time=TPB))
result = extract_midi_lyrics(_save(mid, tmp_path))
assert result is not None
assert [e["w"] for e in result["lyrics"]] == ["Some", "words"]
assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [64, 65]
def test_text_events_on_non_vocal_track_ignored(tmp_path):
"""Text events on a plain instrument track (copyright notices, markers)
are not lyrics extraction returns None."""
mid = mido.MidiFile(type=1, ticks_per_beat=TPB)
conductor = mido.MidiTrack()
mid.tracks.append(conductor)
conductor.append(mido.MetaMessage("set_tempo", tempo=500000, time=0))
tr = mido.MidiTrack()
mid.tracks.append(tr)
tr.append(mido.MetaMessage("track_name", name="Guitar", time=0))
tr.append(mido.MetaMessage("text", text="Copyright 2026", time=0))
tr.append(mido.Message("note_on", channel=0, note=52, velocity=90, time=0))
tr.append(mido.Message("note_off", channel=0, note=52, velocity=0, time=TPB))
assert extract_midi_lyrics(_save(mid, tmp_path)) is None
# ── format-0 channel isolation ───────────────────────────────────────────────
def test_format0_pairs_only_vocal_program_channel(tmp_path):
"""Format-0 file mixing a vocal-program channel with an accompaniment
channel: only the vocal channel's notes feed vocal_pitch."""
mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
tr = mido.MidiTrack()
mid.tracks.append(tr)
tr.append(mido.Message("program_change", channel=0, program=53, time=0)) # Voice Oohs
tr.append(mido.Message("program_change", channel=1, program=0, time=0)) # Piano
# Simultaneous piano note that must NOT be paired.
tr.append(mido.Message("note_on", channel=1, note=40, velocity=90, time=0))
tr.append(mido.MetaMessage("lyrics", text="la ", time=0))
tr.append(mido.Message("note_on", channel=0, note=67, velocity=90, time=0))
tr.append(mido.Message("note_off", channel=0, note=67, velocity=0, time=TPB))
tr.append(mido.Message("note_off", channel=1, note=40, velocity=0, time=0))
result = extract_midi_lyrics(_save(mid, tmp_path))
assert result is not None
assert [n["midi"] for n in result["vocal_pitch"]["notes"]] == [67]
def test_format0_without_vocal_channel_is_lyrics_only(tmp_path):
"""Format-0 with lyrics but no vocal program/name: the merged note soup
cannot be trusted as a melody lyrics.json only."""
mid = mido.MidiFile(type=0, ticks_per_beat=TPB)
tr = mido.MidiTrack()
mid.tracks.append(tr)
tr.append(mido.Message("program_change", channel=0, program=0, time=0))
tr.append(mido.MetaMessage("lyrics", text="la ", time=0))
tr.append(mido.Message("note_on", channel=0, note=60, velocity=90, time=0))
tr.append(mido.Message("note_off", channel=0, note=60, velocity=0, time=TPB))
result = extract_midi_lyrics(_save(mid, tmp_path))
assert result is not None
assert len(result["lyrics"]) == 1
assert result["vocal_pitch"] is None
# ── audio_offset ─────────────────────────────────────────────────────────────
def test_audio_offset_applied_to_both_payloads(tmp_path):
result = extract_midi_lyrics(_vocal_file(tmp_path), audio_offset=1.5)
assert result["lyrics"][0]["t"] == pytest.approx(1.5)
assert result["vocal_pitch"]["notes"][0]["t"] == pytest.approx(1.5)
+207
View File
@@ -0,0 +1,207 @@
"""End-to-end test for the sloppak loader recognising a `rigs:` manifest key
(rigs.json the pack-level library of engine-agnostic rigs, spec §7.9) and
surfacing the payload on the LoadedSloppak.
The governing posture: rig objects pass through VERBATIM. This loader does not
select realizations or apply the `intent.gm` floor it only makes the library
addressable by `id`, which is what `tones.base_rig` / `tones.changes[].rig`
reference."""
from __future__ import annotations
import json
from pathlib import Path
import yaml
import sloppak as sloppak_mod
def _write_dir_sloppak(root: Path, manifest_extras: dict, rigs_payload) -> Path:
"""Minimal directory-form sloppak; writes rigs.json when a payload is given.
Unique filename per test (tmp_path leaf) so the module-level
resolve_source_dir cache isn't poisoned across tests."""
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))
if rigs_payload is not None:
(pak / "rigs.json").write_text(json.dumps(rigs_payload))
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)
# ── Happy path ───────────────────────────────────────────────────────────────
def test_load_song_attaches_rigs_when_manifest_opts_in(tmp_path: Path):
"""A source rig (spec §7.9 1.18.0) survives the load intact — including the
`soundfont` realization and the `intent.gm` floor a consumer needs to voice
the part."""
payload = {
"version": 1,
"rigs": [
{
"id": "grand-piano",
"name": "Grand Piano",
"instrument": "keys",
"blocks": [
{
"role": "source",
"name": "Concert Grand",
"intent": {"kind": "instrument", "gm": {"program": 0}},
"realizations": [
{"engine": "soundfont", "format": "sf2",
"ref": "sounds/grand.sf2", "bank": 0, "program": 0},
],
},
],
},
],
}
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.rigs is not None
assert loaded.rigs["version"] == 1
assert loaded.rigs["rigs"] == payload["rigs"]
def test_load_song_rigs_absent_without_manifest_key(tmp_path: Path):
"""The file alone must not opt a pack in — the manifest is the opt-in
(spec §9.1, "manifest opt-in, file off to the side")."""
pak = _write_dir_sloppak(tmp_path, {}, {"version": 1, "rigs": []})
assert _load(pak, tmp_path).rigs is None
# ── Verbatim passthrough ─────────────────────────────────────────────────────
def test_load_song_preserves_unknown_rig_content(tmp_path: Path):
"""Unknown `role` / `engine` / `kind` values and `ext` namespaces MUST
survive (spec §7.9) core does not interpret rigs, so it must not prune
what a newer writer or a plugin put there."""
payload = {
"version": 2,
"rigs": [
{
"id": "future-rig",
"blocks": [
{"role": "quantum-flux", "intent": {"kind": "not-yet-invented"},
"realizations": [{"engine": "some-future-engine", "ref": "x.bin"}],
"ext": {"vendor.custom": {"anything": [1, 2, 3]}}},
],
"graph": {"nodes": ["input", "output"], "edges": [["input", "output"]]},
"ext": {"vendor.rig": "kept"},
},
],
}
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, payload)
loaded = _load(pak, tmp_path)
assert loaded.rigs["version"] == 2
assert loaded.rigs["rigs"] == payload["rigs"]
# ── Addressability ───────────────────────────────────────────────────────────
def test_load_song_drops_unaddressable_rigs_and_normalizes_ids(tmp_path: Path):
"""A rig is reachable only by `id`, so entries without a usable one are
unreferenceable by construction. Ids are stripped to match the reference
side, which lib/tones.py strips before it reaches the wire."""
payload = {
"rigs": [
"not-a-dict",
{"name": "no id at all"},
{"id": "", "name": "blank id"},
{"id": " ", "name": "whitespace id"},
{"id": 7, "name": "non-string id"},
{"id": " padded-rig ", "name": "Padded"},
{"id": "plain-rig", "name": "Plain"},
],
}
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, payload)
loaded = _load(pak, tmp_path)
assert [r["id"] for r in loaded.rigs["rigs"]] == ["padded-rig", "plain-rig"]
# Everything except the normalized id is untouched.
assert loaded.rigs["rigs"][0]["name"] == "Padded"
# `version` defaults when the file omits it.
assert loaded.rigs["version"] == 1
def test_load_song_first_rig_wins_on_duplicate_id(tmp_path: Path):
"""A duplicate id makes `tones.base_rig` ambiguous, which would surface as
the wrong sound rather than an error."""
payload = {
"rigs": [
{"id": "dupe", "name": "First"},
{"id": "dupe", "name": "Second"},
{"id": " dupe ", "name": "Third, padded into a collision"},
],
}
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, payload)
loaded = _load(pak, tmp_path)
assert len(loaded.rigs["rigs"]) == 1
assert loaded.rigs["rigs"][0]["name"] == "First"
# ── Permissive posture (spec §7.9: never fail the pack) ──────────────────────
def test_load_song_survives_malformed_rigs(tmp_path: Path):
"""Malformed / missing / traversing rig libraries disable rigs, never the
pack the song itself must still load."""
cases = [
{"version": 1, "rigs": "not-a-list"}, # wrong `rigs` type
["top-level-not-a-dict"], # wrong document type
{"version": 1}, # no `rigs` key at all
]
for i, payload in enumerate(cases):
sub = tmp_path / f"case{i}"
sub.mkdir()
pak = _write_dir_sloppak(sub, {"rigs": "rigs.json"}, payload)
loaded = _load(pak, sub)
assert loaded.rigs is None, f"case {i} should disable rigs"
assert loaded.song is not None, f"case {i} must not fail the pack"
def test_load_song_survives_unparseable_rigs(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, None)
(pak / "rigs.json").write_text("{ not json at all ")
loaded = _load(pak, tmp_path)
assert loaded.rigs is None
assert loaded.song is not None
def test_load_song_survives_missing_rigs_file(tmp_path: Path):
pak = _write_dir_sloppak(tmp_path, {"rigs": "rigs.json"}, None)
loaded = _load(pak, tmp_path)
assert loaded.rigs is None
assert loaded.song is not None
def test_load_song_rejects_traversing_rigs_path(tmp_path: Path):
"""A crafted manifest must not read outside the pack."""
(tmp_path / "outside.json").write_text(json.dumps({"rigs": [{"id": "leaked"}]}))
pak = _write_dir_sloppak(tmp_path, {"rigs": "../outside.json"}, None)
loaded = _load(pak, tmp_path)
assert loaded.rigs is None
assert loaded.song is not None
+230
View File
@@ -0,0 +1,230 @@
"""Loader coverage for the manifest-vs-in-JSON `tones` precedence cascade
(feedpak 1.18.0, spec §5.1 / §5.2).
Two rules, both about *which* sound binding wins, neither about interpreting it:
- A manifest arrangement entry's `tones` replaces the arrangement JSON's
`tones` **WHOLESALE** no field-level merge. A half-merged block (this
source's `base` with that source's `changes`) would be a sound nobody
authored, so the two never blend.
- Top-level `drum_tones` binds the song-level (primary) drum part and is the
fallback; a `type: drums` entry's own `tones` takes precedence, and a
Reader MUST NOT apply both to the same part.
"""
from __future__ import annotations
import json
from pathlib import Path
import yaml
import sloppak as sloppak_mod
IN_JSON_TONES = {
"base": "In-JSON Clean",
"base_rig": "injson-clean",
"changes": [{"t": 5.0, "name": "In-JSON Lead", "rig": "injson-lead"}],
}
ENTRY_TONES = {
"base": "Entry Grand",
"base_rig": "entry-grand",
"changes": [{"t": 9.0, "name": "Entry Rhodes", "rig": "entry-rhodes"}],
}
def _tab(name: str) -> dict:
return {
"version": 1,
"name": name,
"kit": [{"id": "kick", "name": "Kick"}],
"hits": [{"t": 1.0, "p": "kick", "v": 100}],
}
def _write_pak(root: Path, manifest_extras: dict, arr_tones: dict | None = None,
files: dict[str, dict] | None = None) -> Path:
"""Directory-form sloppak with one Lead arrangement, optionally carrying an
in-JSON `tones` block, plus any extra files."""
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": [],
}
if arr_tones is not None:
arr["tones"] = arr_tones
(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 or {}).items():
(pak / rel).write_text(json.dumps(payload))
return pak
def _load(pak_path: Path, tmp_path: Path):
cache = tmp_path / "cache"
cache.mkdir()
return sloppak_mod.load_song(pak_path.name, pak_path.parent, cache)
# ── Arrangement entry vs in-JSON (§5.2) ──────────────────────────────────────
def test_entry_tones_replaces_in_json_wholesale(tmp_path: Path):
"""The entry object replaces the in-JSON one entirely — no key survives
from the loser, not even ones the winner doesn't define."""
entry_tones = {"base": "Entry Only"} # no base_rig, no changes
pak = _write_pak(
tmp_path,
{"arrangements": [{"id": "lead", "name": "Lead",
"file": "arrangements/lead.json",
"tones": entry_tones}]},
arr_tones=IN_JSON_TONES,
)
arr = _load(pak, tmp_path).song.arrangements[0]
assert arr.tones == entry_tones
# The in-JSON `base_rig` and `changes` must NOT have been merged in.
assert "base_rig" not in arr.tones
assert "changes" not in arr.tones
def test_in_json_tones_survive_when_entry_has_none(tmp_path: Path):
pak = _write_pak(tmp_path, {}, arr_tones=IN_JSON_TONES)
assert _load(pak, tmp_path).song.arrangements[0].tones == IN_JSON_TONES
def test_empty_entry_tones_is_absent_not_an_override(tmp_path: Path):
"""`{}` reads as "didn't specify", not "override to silence" — otherwise a
stray empty object silently unbinds the part's sound."""
pak = _write_pak(
tmp_path,
{"arrangements": [{"id": "lead", "name": "Lead",
"file": "arrangements/lead.json", "tones": {}}]},
arr_tones=IN_JSON_TONES,
)
assert _load(pak, tmp_path).song.arrangements[0].tones == IN_JSON_TONES
def test_malformed_entry_tones_is_ignored(tmp_path: Path):
"""A non-dict `tones` must not override, and must not crash the load."""
pak = _write_pak(
tmp_path,
{"arrangements": [{"id": "lead", "name": "Lead",
"file": "arrangements/lead.json",
"tones": ["not", "a", "dict"]}]},
arr_tones=IN_JSON_TONES,
)
assert _load(pak, tmp_path).song.arrangements[0].tones == IN_JSON_TONES
def test_entry_tones_binds_a_notation_only_arrangement(tmp_path: Path):
"""§5.2: entry `tones` is available whether or not the arrangement has a
`file` a keys part is a notation-only entry, and binding its sound is the
whole point of the 1.18.0 work."""
notation = {"version": 1, "measures": []}
pak = _write_pak(
tmp_path,
{"arrangements": [{"id": "keys", "name": "Keys",
"notation": "notation_keys.json",
"tones": ENTRY_TONES}]},
files={"notation_keys.json": notation},
)
arr = _load(pak, tmp_path).song.arrangements[0]
assert arr.name == "Keys"
assert arr.tones == ENTRY_TONES
# ── drum_tones vs entry tones (§5.1) ─────────────────────────────────────────
def test_drum_tones_binds_the_primary_part(tmp_path: Path):
pak = _write_pak(
tmp_path,
{"drum_tab": "drum_tab.json", "drum_tones": ENTRY_TONES},
files={"drum_tab.json": _tab("Drums")},
)
parts = _load(pak, tmp_path).drum_parts
assert len(parts) == 1
assert parts[0]["tones"] == ENTRY_TONES
def test_entry_tones_outrank_drum_tones_on_the_primary(tmp_path: Path):
"""An alias pointer entry naming the same file IS the primary, so its own
binding wins and `drum_tones` must not also be applied."""
alias_tones = {"base": "Alias Kit", "base_rig": "alias-kit"}
pak = _write_pak(
tmp_path,
{
"drum_tab": "drum_tab.json",
"drum_tones": ENTRY_TONES,
"arrangements": [
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
{"id": "drums", "name": "Drums", "type": "drums",
"drum_tab": "drum_tab.json", "tones": alias_tones},
],
},
files={"drum_tab.json": _tab("Drums")},
)
parts = _load(pak, tmp_path).drum_parts
assert len(parts) == 1
assert parts[0]["tones"] == alias_tones
def test_drum_tones_does_not_leak_to_secondary_parts(tmp_path: Path):
"""`drum_tones` is the PRIMARY's fallback only. A second drummer with no
binding of its own gets None not the primary's kit."""
live_tones = {"base": "Live Kit", "base_rig": "live-kit"}
pak = _write_pak(
tmp_path,
{
"drum_tab": "drum_tab.json",
"drum_tones": ENTRY_TONES,
"arrangements": [
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
{"id": "drums-live", "name": "Drums (Live)", "type": "drums",
"drum_tab": "drum_tab_live.json", "tones": live_tones},
{"id": "drums-prog", "name": "Drums (Prog)", "type": "drums",
"drum_tab": "drum_tab_prog.json"},
],
},
files={
"drum_tab.json": _tab("Drums"),
"drum_tab_live.json": _tab("Drums Live"),
"drum_tab_prog.json": _tab("Drums Prog"),
},
)
parts = {p["id"]: p for p in _load(pak, tmp_path).drum_parts}
assert parts["drums"]["tones"] == ENTRY_TONES # primary, from drum_tones
assert parts["drums-live"]["tones"] == live_tones # own entry
assert parts["drums-prog"]["tones"] is None # no binding, no leak
def test_drum_parts_carry_none_when_pack_binds_nothing(tmp_path: Path):
"""A pack with drums and no sound binding at all still loads, with the key
present and None consumers can read `part["tones"]` unconditionally."""
pak = _write_pak(
tmp_path,
{"drum_tab": "drum_tab.json"},
files={"drum_tab.json": _tab("Drums")},
)
parts = _load(pak, tmp_path).drum_parts
assert parts[0]["tones"] is None
def test_malformed_drum_tones_is_ignored(tmp_path: Path):
pak = _write_pak(
tmp_path,
{"drum_tab": "drum_tab.json", "drum_tones": "not-a-dict"},
files={"drum_tab.json": _tab("Drums")},
)
assert _load(pak, tmp_path).drum_parts[0]["tones"] is None
+57 -8
View File
@@ -6,16 +6,17 @@ from tones import sloppak_tone_changes
# ── sloppak_tone_changes (highway payload builder) ─────────────────────────── # ── sloppak_tone_changes (highway payload builder) ───────────────────────────
def test_sloppak_tone_changes_sorts_and_returns_base(): def test_sloppak_tone_changes_sorts_and_returns_base():
base, changes = sloppak_tone_changes({ base, base_rig, changes = sloppak_tone_changes({
"base": "Clean", "base": "Clean",
"changes": [{"t": 12.5, "name": "Drive"}, {"t": 3.0, "name": "Clean"}], "changes": [{"t": 12.5, "name": "Drive"}, {"t": 3.0, "name": "Clean"}],
}) })
assert base == "Clean" assert base == "Clean"
assert base_rig == ""
assert changes == [{"t": 3.0, "name": "Clean"}, {"t": 12.5, "name": "Drive"}] assert changes == [{"t": 3.0, "name": "Clean"}, {"t": 12.5, "name": "Drive"}]
def test_sloppak_tone_changes_skips_malformed_markers(): def test_sloppak_tone_changes_skips_malformed_markers():
_, changes = sloppak_tone_changes({ _, _, changes = sloppak_tone_changes({
"changes": [ "changes": [
{"t": "nan", "name": "BadStr"}, {"t": "nan", "name": "BadStr"},
{"t": float("inf"), "name": "Inf"}, {"t": float("inf"), "name": "Inf"},
@@ -29,18 +30,66 @@ def test_sloppak_tone_changes_skips_malformed_markers():
def test_sloppak_tone_changes_handles_none_and_bad_base(): def test_sloppak_tone_changes_handles_none_and_bad_base():
assert sloppak_tone_changes(None) == ("", []) assert sloppak_tone_changes(None) == ("", "", [])
base, changes = sloppak_tone_changes({"base": 123, "changes": []}) base, base_rig, changes = sloppak_tone_changes({"base": 123, "changes": []})
assert base == "" and changes == [] assert base == "" and base_rig == "" and changes == []
def test_sloppak_tone_changes_non_dict_input(): def test_sloppak_tone_changes_non_dict_input():
"""A truthy non-dict payload must not crash.""" """A truthy non-dict payload must not crash."""
assert sloppak_tone_changes(["not", "a", "dict"]) == ("", []) assert sloppak_tone_changes(["not", "a", "dict"]) == ("", "", [])
assert sloppak_tone_changes("nope") == ("", []) assert sloppak_tone_changes("nope") == ("", "", [])
def test_sloppak_tone_changes_non_list_changes(): def test_sloppak_tone_changes_non_list_changes():
"""A truthy non-list `changes` value must not raise on iteration.""" """A truthy non-list `changes` value must not raise on iteration."""
base, changes = sloppak_tone_changes({"base": "Clean", "changes": 1}) base, _, changes = sloppak_tone_changes({"base": "Clean", "changes": 1})
assert base == "Clean" and changes == [] 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 == ""