Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- **The rig library reaches the client.** Completes the core reader for source
rigs: a new `rigs` WebSocket message carries the pack's `rigs.json` library
verbatim, and each entry in `song_info.drum_parts` now carries its `tones`
binding. Previously the *references* shipped (`base_rig`, per-change `rig`)
with nothing to resolve them against — the library loaded server-side and had
no consumer. The library is sent whenever a pack ships one rather than being
gated on the arrangement having tone changes, since a pack can bind sound to
its drum parts alone. `highway.js` exposes `getRigs()` / `getToneBaseRig()`
and adds `bundle.rigs` / `bundle.toneBaseRig` for renderers. Core still
selects no realization and applies no `intent.gm` floor — that belongs to
whatever voices the part. Packs that bind no rig send byte-identical frames.
- **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
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -684,13 +684,14 @@ The highway WebSocket at `/ws/highway/{filename}?arrangement={index}` streams th
| Message | Shape | Description |
|---------|-------|-------------|
| `loading` | `{ type: 'loading', stage }` | Status/progress message during extraction or conversion |
| `song_info` | `{ type, title, artist, arrangement, arrangement_index, arrangements, duration, tuning, capo, centOffset, format, audio_url, audio_error, stems }` | Song metadata. `arrangements` is the full list for the switcher. `audio_url` is `null` when audio is unavailable, in which case `audio_error` is non-null; otherwise `audio_error` is `null`. `stems` is always present — an empty array for non-sloppak songs or sloppak songs with no split stems. `tuning` is an array (6 for guitar, 4 for bass). `centOffset` is a float (cents) from the RS2014 `<centOffset>` field — commonly `-1200.0` for extended-range bass (one octave down), small non-zero values for true-tuned content (e.g. A443 ≈ +11.8 cents), `0.0` when absent. Available via `getSongInfo().centOffset`. |
| `song_info` | `{ type, title, artist, arrangement, arrangement_index, arrangements, duration, tuning, capo, centOffset, format, audio_url, audio_error, stems }` | Song metadata. `arrangements` is the full list for the switcher. `audio_url` is `null` when audio is unavailable, in which case `audio_error` is non-null; otherwise `audio_error` is `null`. `stems` is always present — an empty array for non-sloppak songs or sloppak songs with no split stems. `tuning` is an array (6 for guitar, 4 for bass). `centOffset` is a float (cents) from the RS2014 `<centOffset>` field — commonly `-1200.0` for extended-range bass (one octave down), small non-zero values for true-tuned content (e.g. A443 ≈ +11.8 cents), `0.0` when absent. Available via `getSongInfo().centOffset`. `drum_parts` (present for sloppaks, `[]` otherwise) lists the song's drum parts primary-first as `{id, name}`, plus **`tones`** when that part binds a sound (feedpak 1.18.0 §5.1/§5.2 — the entry's own `tones` or the song-level `drum_tones` fallback, already resolved server-side); the key is omitted when the part binds nothing. Resolve its `base_rig`/`rig` ids against the `rigs` message below. |
| `beats` | `{ type, data: [{ time, measure }] }` | Beat timestamps with measure numbers |
| `sections` | `{ type, data: [{ time, name }] }` | Named sections (Intro, Verse, Chorus, etc.) |
| `anchors` | `{ type, data: [{ time, fret, width }] }` | Fret zoom anchors |
| `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 |
| `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. |
| `rigs` | `{ type: 'rigs', version, data: [ <rig>, ... ] }` | Optional — the pack's **rig library** (`rigs.json`, feedpak §7.9): the engine-agnostic signal chains that `tone_changes`' `base_rig`/`rig` and each drum part's `tones` reference by `id`. Sent whenever the pack ships one, **not** gated on this arrangement having tone changes (a pack can bind sound to its drum parts alone). Rigs are **verbatim** — core selects no realization and applies no `intent.gm` floor, so whatever voices the part reads the whole block, including unknown `role`/`engine`/`kind` values and `ext` namespaces. |
| `notes` | `{ type, data: [{ t, s, f, sus, ho, po, sl, bn, ... }] }` | Single notes |
| `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". |
Expand Down
24 changes: 23 additions & 1 deletion lib/routers/ws_highway.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,8 +581,14 @@ def _evict_audio_cache():
# 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.
# `tones` is the part's sound binding (feedpak 1.18.0 §5.1/§5.2 —
# entry `tones` or the song-level `drum_tones` fallback, already
# resolved by the loader). Omitted when the part binds nothing, so
# a pack without bindings sends the payload it always did; pair it
# with the `rigs` message below to resolve `base_rig`/`rig` ids.
"drum_parts": [
{"id": p["id"], "name": p["name"]}
{"id": p["id"], "name": p["name"],
**({"tones": p["tones"]} if p.get("tones") else {})}
for p in (loaded_slop.drum_parts or [])
] if is_slop and loaded_slop is not None else [],
"has_notation": bool(
Expand Down Expand Up @@ -797,6 +803,22 @@ def _evict_audio_cache():
if base_rig:
payload["base_rig"] = base_rig
await websocket.send_json(payload)

# The pack's RIG LIBRARY (rigs.json, spec §7.9) — the thing the
# `base_rig` / `rig` ids above (and the drum parts' `tones`) are
# references INTO. Sent whole and verbatim: core does not select a
# realization or apply the `intent.gm` floor, so the consumer that
# voices the part needs the entire block to make that choice.
#
# Deliberately NOT gated on this arrangement having tone changes —
# a pack can bind sound to its drum parts alone (`drum_tones`) and
# still need the library. Sent whenever the pack ships one.
if loaded_slop is not None and loaded_slop.rigs is not None:
await websocket.send_json({
"type": "rigs",
"version": int(loaded_slop.rigs.get("version", 1)),
"data": loaded_slop.rigs.get("rigs") or [],
})
else:
xml_paths = sorted(_xml_walk("*.xml"))

Expand Down
24 changes: 21 additions & 3 deletions static/highway.js
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,14 @@
hwState.lyricsSource = "";
hwState.toneChanges = [];
hwState.toneBase = "";
// Rig id bound to the base tone ("" when the chart binds none).
hwState.toneBaseRig = "";
// The pack's rig library (rigs.json, feedpak-spec §7.9) — the engine-
// agnostic signal chains that `toneBase`/`toneChanges[].rig` and each drum
// part's `tones` reference by `id`. Empty array when the pack ships none.
// Verbatim from the server: core resolves no realization and applies no
// `intent.gm` floor, so whatever voices a part reads the whole block.
hwState.rigs = [];
// Drum-tab payload (sloppak-spec §5.3). When the active sloppak's
// manifest carries a `drum_tab:` key, the server streams a `drum_tab`
// metadata message followed by chunked `drum_hits`. `drumTab.hits` is
Expand Down Expand Up @@ -574,6 +582,10 @@
b.lyricsSource = hwState.lyricsSource;
b.toneChanges = hwState.toneChanges;
b.toneBase = hwState.toneBase;
b.toneBaseRig = hwState.toneBaseRig;
// Live reference, like the other chart arrays — swaps identity when
// the song changes, so a field-identity cache stays valid.
b.rigs = hwState.rigs;
// Drum tab payload (or null when the active arrangement has
// no drum_tab). Live reference — renderers MUST treat as
// read-only. Plugins should prefer this over decoding the
Expand Down Expand Up @@ -1486,7 +1498,7 @@
// chords are time-sorted already (PR 1's parser sorts them), so
// concatenation preserves the order. No explicit sort needed.
function _rebuildMasteryFilter() {
// Null OR empty → fall through to flat arrays. The server's

Check warning on line 1501 in static/highway.js

View workflow job for this annotation

GitHub Actions / ci / lint

File has too many lines (2991). Maximum allowed is 1500

Check warning on line 1501 in static/highway.js

View workflow job for this annotation

GitHub Actions / ci / lint

File has too many lines (2991). Maximum allowed is 1500
// chunked emission invariant means _phrases should never land
// at `[]` in practice (it'd require the `phrases` message to
// fire with zero data), but the defensive guard means a bug
Expand Down Expand Up @@ -1686,7 +1698,7 @@
hwState._resizeHandler = () => this.resize();
window.addEventListener('resize', hwState._resizeHandler);
hwState.ready = false;
hwState.notes = []; hwState.chords = []; hwState.handShapes = []; hwState.beats = []; hwState.sections = []; hwState.anchors = []; hwState.chordTemplates = []; hwState.lyrics = []; hwState.lyricsSource = ""; hwState.toneChanges = []; hwState.toneBase = ""; hwState.drumTab = null;
hwState.notes = []; hwState.chords = []; hwState.handShapes = []; hwState.beats = []; hwState.sections = []; hwState.anchors = []; hwState.chordTemplates = []; hwState.lyrics = []; hwState.lyricsSource = ""; hwState.toneChanges = []; hwState.toneBase = ""; hwState.toneBaseRig = ""; hwState.rigs = []; hwState.drumTab = null;
hwState.stringCount = 6; // default until song_info arrives
// Reset phrase ladder + filter (feedBack#48). _mastery
// persists across arrangement switches — the slider's
Expand Down Expand Up @@ -2391,7 +2403,8 @@
// having to hook the raw WS themselves.
hwState.lyricsSource = msg.source || "";
break;
case 'tone_changes': hwState.toneChanges = msg.data; hwState.toneBase = msg.base || ""; break;
case 'tone_changes': hwState.toneChanges = msg.data; hwState.toneBase = msg.base || ""; hwState.toneBaseRig = msg.base_rig || ""; break;
case 'rigs': hwState.rigs = msg.data || []; break;
case 'notes': hwState.notes = hwState.notes.concat(msg.data); break;
case 'chords': hwState.chords = hwState.chords.concat(msg.data); break;
case 'handshapes': hwState.handShapes = hwState.handShapes.concat(msg.data); break;
Expand Down Expand Up @@ -2664,6 +2677,11 @@
getChordTemplates() { return hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates; },
getToneChanges() { return hwState.toneChanges; },
getToneBase() { return hwState.toneBase; },
// Rig id bound to the base tone ("" when the chart binds none).
getToneBaseRig() { return hwState.toneBaseRig; },
// The pack's rig library, verbatim (feedpak-spec §7.9). Index by `id`
// to resolve a `base_rig` / `rig` / drum-part `tones` reference.
getRigs() { return hwState.rigs; },
getSections() { return hwState.sections; },
// Timed lyric syllables for the active song: [{t: start, d: length,
// w: word}], same array the highway WS populates. Exposed so overlay
Expand Down Expand Up @@ -2817,7 +2835,7 @@
// Close old WS but keep audio + animation running
if (hwState.ws) { hwState.ws.close(); hwState.ws = null; }
hwState.ready = false;
hwState.notes = []; hwState.chords = []; hwState.handShapes = []; hwState.beats = []; hwState.sections = []; hwState.anchors = []; hwState.chordTemplates = []; hwState.lyrics = []; hwState.lyricsSource = ""; hwState.toneChanges = []; hwState.toneBase = ""; hwState.drumTab = null;
hwState.notes = []; hwState.chords = []; hwState.handShapes = []; hwState.beats = []; hwState.sections = []; hwState.anchors = []; hwState.chordTemplates = []; hwState.lyrics = []; hwState.lyricsSource = ""; hwState.toneChanges = []; hwState.toneBase = ""; hwState.toneBaseRig = ""; hwState.rigs = []; hwState.drumTab = null;
hwState.stringCount = 6; // default until song_info arrives
// Drop any per-song offset from the previous load so setTime
// calls that fire before the next song_info arrives don't
Expand Down
Loading
Loading