Compare commits

..
Author SHA1 Message Date
gionnibgudandGitHub eef58c88c3 feat(sloppak): core reader for source rigs (feedpak 1.18.0) (#1040)
ship-ci / ci (push) Has been cancelled
* 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
gionnibgudandGitHub 1a7e2bf084 Extract the pack-path containment guard into one helper (#1039)
Every manifest key that names a file carried its own copy of the same
traversal guard: resolve, prove containment under source_dir, warn and
skip on ValueError, warn and skip on OSError. Seven copies —
original_audio, drum_tab, arrangement, notation, song_timeline, lyrics,
keys — which is seven chances for the next side-file to get a security
check subtly wrong by copying the wrong neighbour.

Route them all through `_resolve_pack_path(source_dir, rel, label)`.

Deliberately preserved, because each was load-bearing:

- Both exception branches, with their different messages. ValueError
  means the path resolved outside the pack (a crafted or broken
  manifest); OSError means it could not be resolved at all (symlink
  loop, permissions). They send an operator to different places.
- Per-call-site control flow. The helper returns `Path | None` and says
  nothing about what to do next, so the two sites that return, the one
  that continues, and the four that fall through to an `is not None`
  test each keep the shape they had.
- The existence-check asymmetry. Some sites test `.exists()` (or
  `.is_file()`) after resolving and some do not, which is intentional —
  a missing optional side-file is silent, a missing arrangement skips an
  entry — so existence stays out of the helper entirely.

Pure refactor: no behaviour change and no new validation. Log output is
byte-identical (the hardcoded labels become a `%s` argument rendering to
the same text). Full suite is unchanged at 2774 passed / 4 skipped
before and after, and the five loader-level traversal tests that used to
cover five separate copies of the guard now all exercise the same
function.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
2026-07-23 11:26:35 +02:00
32c00cdd78 fix(count-in): follow the song's meter and its pickup measure (#1029)
The count-in always clicked exactly four beats, so a 3/4 song was counted
in 4/4, and a song opening with a pickup (anacrusis) had the pickup enter
where the downbeat belonged — putting the player a beat ahead all song.

Bar length now comes from the song_timeline beats already on the highway
(measure >= 0 marks downbeats), so no new plumbing: the time_signatures
map is streamed to plugins rather than stored in the frontend. A first bar
shorter than that meter shortens the count by its length — a 1-beat pickup
in 4/4 counts "1 2 3" and the music enters on 4.

Bar length is the mode of the downbeat gaps, not the first gap, so a
pickup's own short gap can't be read as the meter; the beats trailing the
last downbeat count as a candidate too, or a song of pickup + one bar
offers only the pickup's gap. Pickup shortening is scoped to the song's
first bar — a short bar elsewhere is a meter change, and is counted by its
own length instead. Songs without beats (pre-chart, minigames, synthetic
highways) still get four.

Applies to both count-in paths: loop wrap / section practice, and the
start-of-song 'Countdown before song' setting.

Signed-off-by: gionnibgud <gionnibgud@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:26:32 +02:00
8297afc449 feat(tools): per-platform VST3 slicing for rig content packs (#1025)
ship-ci / ci (push) Waiting to run
Rebased onto merged main (was stacked on #1023/#1024, whose venue work is
now in main) so it no longer carries a stale content_packs.py that would
revert 1023's build_pack fixes.

- build_vst_pack: slice a fat .vst3 tree to one platform (keep its binary
  dir + shared bundle files, drop the two foreign platform dirs and src/
  build trees). Pins create_system=3 like build_pack — without it the same
  tree hashes differently on a Windows runner (native .vst3 are built there),
  breaking the precomputable-hash guarantee exactly where it matters.
- Publish wiring: 'python tools/content_packs.py <vst-root> --vst --version N
  --publish' builds+uploads vst-<plat>-vN releases for mac/win/linux and emits
  a platform-keyed {url,sha256,bytes} manifest — the shape rig_builder's
  data/vst_packs.json consumes. publish() refactored onto a shared
  _publish_release helper (venue behaviour unchanged).
- Tests: slice keeps target+shared/drops foreign, per-platform binary,
  reproducibility, unknown-platform reject, and a simulated-win32 guard that
  fails if the create_system pin is dropped. selfcheck covers the VST path.

Original build_vst_pack by Matthew Harris Glover; reworked for the create_system
fix, publish wiring, and rebase.

Signed-off-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:01:51 +02:00
59bcf338a3 feat(career): host higher venues as opt-in content packs (#1023)
* feat(career): host higher venues as opt-in content packs

Move the club and arena venue packs (~678 MB of crowd MP4s) out of the
bundle and download them on demand, keeping the bar starter bundled so
career still works offline. Leans on career's existing pack pipeline
(_download_pack: stream -> sha256 -> extract -> validate -> swap), which
already degrades gracefully when a pack is absent.

- venues.json: club/arena gain `pack` URLs pointing at per-pack, versioned,
  immutable releases (venue-<id>-v<N>, matching the existing venue-arena-v1).
  Arena's sha256/bytes are the real published asset (verified end-to-end);
  club is a placeholder until its release is published.
- tools/content_packs.py: reusable, reproducible pack build/publish/manifest
  tool. Byte-identical output for identical media (fixed order/mtime/perms,
  STORED) so a pack's hash can be known before upload. --local (file://) for
  offline tests, --publish for the per-pack release. Has a --selfcheck.
- .github/workflows/content-packs.yml: workflow_dispatch automation that
  builds/publishes packs and opens the venues.json manifest-bump PR, so
  publishing is never a manual checklist.
- test: round-trips a tool-built pack through career's real _download_pack.

Part of the nightly-slimming effort (feedBack-desktop#122). The desktop
bundle change (stop shipping club/arena) is a companion PR.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(career): don't offer a venue pack until its release is published

A committed venues.json entry carries a 0-byte placeholder (and all-zero sha)
until its release exists. Previously has_pack was true as soon as a `pack`
object was present, so the UI showed a "Download" button that could only fail
(the placeholder URL 404s). Gate on a real, publish-stamped size via
_pack_published(): the card shows "coming soon" and the download endpoint 404s
until the pack is actually published. Caught by a real bundle+runtime smoke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(content-packs): address CodeRabbit review on #1023

- workflow: stop interpolating dispatch inputs into Bash (template
  injection flagged by zizmor). Pass venues/version via env, validate
  formats, use an argument array.
- content_packs: reject top-level files the career downloader would
  refuse (PACK_FILENAME_RE) before publishing — a stray .DS_Store would
  otherwise ship and fail _validate_pack_dir for every client. + test.
- content_packs: pin ZipInfo.create_system=3 so packs hash identically
  across Windows/Unix runners (was the documented reproducibility caveat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(changelog): note opt-in career venue packs (#122)

Signed-off-by: Matthew Harris Glover <matthew@harrisglover.com>

* docs(content_packs): correct --publish usage in module docstring

--publish is a flag (no tag arg) and publish() deliberately omits
--clobber; the docstring said otherwise.

Signed-off-by: byrongamatos <xasiklas@gmail.com>

---------

Signed-off-by: Matthew Harris Glover <matthew@harrisglover.com>
Signed-off-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-23 00:09:46 +02:00
03e1c1d57e feat(server): session-sync relay WebSocket /ws/sync/{session_id} (#1030) (#1032)
ship-ci / ci (push) Waiting to run
* feat(server): add session-sync relay WebSocket /ws/sync/{session_id}

Cross-device followers (splitscreen's upcoming LAN pop-out mode,
feedBack-plugin-splitscreen#21) need a machine-crossing replacement for
BroadcastChannel — the one link in the follower architecture that cannot
leave the host browser. Chart data already streams per-client over
/ws/highway, so all that's missing is a dumb live-state channel.

Add a fan-out room endpoint: a JSON text frame from one client is relayed
verbatim to every other client on the same session id. No schema, no
history, no persistence — rooms are created on first join and GC'd when
the last socket leaves. The statelessness is deliberate: an idle room is
indistinguishable from a nonexistent one, and a host that crashes and
rejoins the same id resumes publishing to reconnecting subscribers with
no server-side coordination.

Caps for a LAN-exposable port: 16 KB frames (1009), 16 sockets/room and
32 rooms (1013), 120 msg/s sustained / 240 burst per socket (1008),
text-only (1003), session id validated against [A-Za-z0-9_-]{4,64}. An
over-limit socket is closed individually; a peer that dies mid-fan-out
is dropped without wedging delivery to the rest.

Closes #1030

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>

* fix(ws_sync): bound stalled peer sends; cap ws frames at the transport

Review feedback (CodeRabbit on #1032):

- A peer that stops draining its socket left send_text() pending forever;
  since publishers await the fan-out gather, one stalled peer stalled
  every publisher's receive loop behind it. Fan-out sends are now bounded
  by SEND_TIMEOUT_SECONDS (5 s) so a stall becomes an eviction through
  the existing failed-send drop path.

- uvicorn buffers inbound WS frames up to its 16 MB default before the
  handler's 16 KB check ever runs, so the DoS bound wasn't enforced at
  the transport. main.py now passes ws_max_size=64 KB (no client sends
  large frames: the highway WS receives only small control messages, and
  the relay keeps its tighter application cap as the primary limit).

Regression tests for both; the desktop's own uvicorn spawn gets the
matching --ws-max-size flag with the feedBack-desktop follow-up work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Kris Anderson <topkoa@gmail.com>

---------

Signed-off-by: Kris Anderson <topkoa@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:45:56 -04:00
0e3522ccc3 feat(player): drum-part picker for multiple drum charts (re-land of #1021) (#1028)
ship-ci / ci (push) Waiting to run
* feat(player): drum-part picker for multiple drum charts (feedpak 1.17.0)

The last mile of the multiple-drum-parts feature: let a player CHOOSE which
drum chart plays. #1020 taught the loader + highway WS to carry several drum
parts (song_info.drum_parts + ?drum_part=<id> + a part_id echo on drum_tab);
this adds the host-chrome selector that drives it.

A "Drum part" <select> sits beside the arrangement switcher in the advanced
settings popover, shown only when a song has 2+ drum charts (drum_parts is
always present — empty for non-drum songs — so single-drum / no-drum songs
hide the row and nothing changes for them). Selecting a part re-streams that
part's tab over the highway WS, exactly like an arrangement switch.

- static/highway.js:
  - reconnect() gains a third `drumPart` arg → sets `?drum_part=<id>` on the WS
    URL (mirrors the existing `arrangement` param one line up). Empty/undefined
    → the primary part, i.e. byte-identical to today for any pack untouched.
  - song_info handler populates #drum-part-select from msg.drum_parts and
    shows/hides #v3-drum-part-row on `length > 1` (parallel to the #arr-select
    block right above it).
  - drum_tab handler carries msg.part_id onto hwState.drumTab (plugins can read
    bundle.drumTab.part_id) and reflects it as the picker's selected value, so
    the dropdown stays honest even when the server resolves an unknown/absent
    selection to the primary.
- static/app.js:
  - changeArrangement() gains an optional `drumPart`; at reconnect it forwards
    the explicit part, else preserves the current picker selection — so an
    ARRANGEMENT switch keeps the chosen drum part (parts are song-level).
  - new changeDrumPart(id) delegates to changeArrangement with the current
    arrangement held + the new part applied (a part switch is the same
    re-stream, so it reuses all the transition ceremony). Exported on window.
- static/v3/index.html: the #drum-part-select row (hidden by default).

No plugin change: the drum renderers just draw whatever drum_tab streams.

RUNTIME-VERIFIED (Playwright, the core player, a 2-drum pack + a no-drum pack):
10/10 — the picker populates with both parts and shows for the multi-drum song;
song_info.drum_parts reaches getSongInfo(); the primary is pre-selected;
selecting the 2nd part drives highway.reconnect with the id and the WS URL
carries `?drum_part=drums-2`; the picker then reflects the server's part_id
echo; a no-drum song hides the row; no page errors. ESLint 0 errors (the two
max-lines warnings are pre-existing on these files). No pytest touched (JS-only).
Stacked on #1020.

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

* Update reconnect source contract test

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 15:35:08 +02:00
e0270e5c30 fix(song): make bass detection instrument-type-aware, not name-only (#1019)
ship-ci / ci (push) Waiting to run
Editor now authors an arrangement's instrument as first-class data (a manifest
'type' field). Core dropped it: the sloppak loader never read 'type', and
'is this a bass?' was defined three different ways across call sites (name-only
in note_pitch_midi and the highway scale-degree path; path_bass+name in bass
selection; name-only in arrangement_string_count). So an authored type=bass
chart not named 'bass' got 6-string lane counts and guitar open-string MIDI.

- Add optional Arrangement.type; sloppak load_song lifts the manifest type onto it
- Add arrangement_is_bass(arr) = type=='bass' OR path_bass OR 'bass' in name
  (None/whitespace safe), and route string count, note_pitch_midi, the highway
  scale-degree base, and bass-player selection through it
- Back-compat: no bass signal -> unchanged 6-string / guitar behavior

Companion to editor #335 (first-class instrument type). Scale degrees are
display-only and never feed a grader.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:30:54 +02:00
605dbdfd25 feat(sloppak): load multiple drum parts (feedpak 1.17.0 drums-as-arrangements) (#1020)
* feat(sloppak): load multiple drum parts (feedpak 1.17.0 drums-as-arrangements)

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

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

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

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

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

* Fix drum-part review findings

* Normalize drum part pointer identities

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

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

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

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
2026-07-21 13:27:41 +02:00
a9be210f77 fix(highway_3d): Venue desync, bind race, and a11y for the player background control (#1018)
ship-ci / ci (push) Has been cancelled
* Fix 3D Highway background controls under Venue override

When the Venue visualization override is active, the entire Background control group (style dropdown and intensity/reactive knobs) is now disabled since the venue scene owns rendering. The dropdown, intensity, and reactive controls are greyed out with a tooltip explaining the state.

Also fixes a cold-load bug where the screen hook subscription could fail to bind if the event bus wasn't ready during renderer init, by re-attempting binding on each retry tick.

Includes test coverage for both the Venue override greyout behavior and the cold-load screen hook binding fix.

* Add accessibility features and explicit global reads to background control

Refactor the player chrome background control to use an explicit `_bgReadGlobal()` function instead of relying on the implicit behavior of `_bgReadSetting(null, ...)`. The control is a single shared instance across splitscreen panels and must always read/write the global slot.

Add accessibility improvements:
- aria-pressed on toggle buttons to expose state to screen readers
- aria-label on select and intensity controls
- aria-describedby pointing disabled controls to a visually-hidden reason span
- The reason span carries dynamic explanatory text for why a control is greyed out

Add comprehensive tests verifying the new `_bgReadGlobal` helper ignores per-panel overrides and that all accessibility attributes are set and updated correctly.

* Gate player control slot on v3 UI version

Add explicit check for `window.feedBack.uiVersion === 'v3'` in _pcSlot() per docs/plugin-v3-ui.md. This prevents the plugin from attempting to mount player controls on non-v3 hosts (e.g., legacy v2 shell). Complements the existing `playerControlSlot` typeof check and improves compatibility robustness.

Updated test mocks to include `uiVersion: 'v3'` and added test case verifying that mounting is skipped when uiVersion is not v3, including a guard to ensure the retry loop terminates properly.

* Clarify 3D highway style control behavior

Document that the style controls group also greyes out when the Venue scene override is active, since the controls don't apply in that mode.

* Restore style dropdown tooltip when Venue override exits

The style dropdown's tooltip was cleared whenever the Venue override was inactive, permanently discarding the "Background style" hint set at mount time. Since the sync runs on every settings change, the tooltip was lost on the first sync and never returned.

This brings the dropdown in line with the intensity slider and reactive toggle, which already restore their base tooltip when they're re-enabled.

Includes a test asserting the tooltip returns after the Venue override exits.

* fix(highway_3d): skip player-control retry loop on non-v3 shells

_pcAcquire only runs once the renderer is viable inside the v3 player
chrome, and player-chrome.js sets uiVersion synchronously as it builds
that chrome — so a missing 'v3' at acquire means v2, not a not-yet-ready
v3. Bail before scheduling the retry loop instead of spinning it out to
the ~3s budget for a slot that will never appear.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: byrongamatos <xasiklas@gmail.com>

---------

Signed-off-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 09:18:12 +02:00
35c0d0ea0d Pass per-stem name/description through to the stems payloads (#1013)
ship-ci / ci (push) Waiting to run
feedpak 1.16.0 (spec §5.3) added two OPTIONAL presentational fields to a
stems[] entry: `name` (display label, Readers fall back to the id) and
`description` (free text). The server dropped both while normalizing
manifest stems, so no client could ever display them.

Pass them through at the one place stem descriptors are built
(sloppak.load_song) and let both payload builders — the WS `ready` stems
list and the REST `/api/song/{f}?stems=1` preload list, which are pinned
against each other by test — carry them forward. Omit-when-absent, so a
stem without the fields does not grow null keys; non-string or blank
values are dropped rather than surfaced.

No behaviour change for existing packs or clients: the fields are
additive and every consumer that reads {id,url,default} keeps working
unchanged. The stems plugin / stem mixer display work lands separately.

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 11:45:16 -04:00
270cb39f41 Enable Linux nightly AppImage auto-update in the System settings UI (#999)
ship-ci / ci (push) Waiting to run
* fix(settings): enable Linux nightly AppImage auto-update in System settings

Fixes the Settings → System "App updates" panel so it actually works on
Linux, and adds Nightly as a selectable channel — previously missing
entirely, so Linux self-update couldn't be reached from this UI at all.

- The channel dropdown no longer gets permanently disabled the moment
  the desktop bridge reports 'unsupported', which is the normal state
  whenever the channel isn't Nightly on Linux. It stays enabled so the
  user can switch to Nightly, the only way out of that state.
- Shows live download progress ("Downloading update… N%") and an
  explicit button state machine (Check → grayed out while busy →
  Restart now once staged), instead of a frozen "Checking…" during the
  ~1.5GB background download.
- Renders every status update from the triggering action's own return
  value (checkNow()/setChannel()'s result) rather than a separate
  follow-up getStatus() call, which can race against other state
  changes and show a stale result even after a real success.
- setupAppUpdates() no longer re-syncs the channel to the backend on
  every Settings-panel re-render — only once per page load — so a
  redundant sync can no longer stomp an in-flight download's state.
- Routes update-flow events into the existing diagnostics.js
  console-capture + contribute() snapshot API, so the user's existing
  "Export Diagnostics" button now captures the full update decision
  trace end to end — no new UI or log file. This diagnostic tracing is
  what actually root-caused the bugs above, from real on-device
  captures rather than guesswork.

Companion PR in feedBack-desktop (the underlying update engine).

Verified end-to-end on a Steam Deck: channel switch → check → live
download progress → restart button → relaunch onto the new build,
confirmed via a real Export Diagnostics capture showing a clean,
fully-accounted-for trace.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(settings): extract + unit-test the app-update status view; dedupe diag log

- Extract the status→UI state machine from renderFrom into a pure, exported
  _appUpdateStatusView() (DOM-free) and cover it with tests/js — settings.js's
  large module graph made importing it for a full harness impractical, so the
  pure function is the testable seam. Behavior-preserving; renderFrom applies
  the returned shape to the DOM exactly as before.
- Dedupe the [update-diag] renderFrom console line so the ~1.5s download poll
  no longer floods the diagnostics ring buffer with byte-identical entries;
  every real state/percent change still logs, and the structured contribute()
  snapshot stays unconditional.

Left the 'audio_engine' diagnostics key as-is: the server export filters
client contributions to loaded plugin ids (diagnostics_bundle.py path-traversal
guard), so a dedicated key would be silently dropped from the bundle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>

---------

Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-19 12:16:02 +02:00
23c509322b Add gamepad/controller support (#1001)
* feat(input): add gamepad/controller support

Adds full gamepad/controller navigation and playback control, driven
by requests from players who use fee[dB]ack on a TV/console setup and
from wheelchair users for whom a controller is far more convenient
than a keyboard + mouse. Confirmed working end-to-end on a Steam Deck
across several rounds of on-device testing.

- static/v3/gamepad.js: polls navigator.getGamepads() and dispatches
  synthetic keydown events (Arrow/Enter/Space/Escape) on the focused
  element (falling back to document), reusing the app's existing
  keyboard pipeline (static/js/shortcuts.js's scope-aware dispatcher,
  player shortcuts, text-field/modal guards) instead of a parallel
  action-mapping table. Only acts on gamepads reporting the W3C
  "standard" mapping — which is what Steam Input presents for the
  Deck's built-in controls, both in Gaming Mode and in Desktop Mode
  via a non-Steam shortcut — so button order is guaranteed correct
  and a non-standard/raw device safely no-ops instead of misfiring.
  Handles Steam Input's virtual-pad duplicates (a real controller
  plus 1-2 mirrored XInput slots) without spamming connect toasts or
  losing input when the live pad isn't at index 0. Xbox-style face
  button mapping: bottom face = Space (play/pause, and activates the
  focused control), right face = Escape (back), top face reveals the
  player screen's tool rail (focuses it into visibility via the
  existing CSS :focus-within rule). D-pad/stick repeat while held,
  mirroring OS keyboard auto-repeat.

- static/v3/gamepad-nav.js: fills the one real gap in that reuse
  strategy — no screen but the song library grid had any arrow-key
  navigation, and Chromium doesn't run native Enter/Space button
  activation for untrusted synthetic events even when dispatched at
  the focused element. Gated entirely on `!e.isTrusted`, so it only
  ever reacts to gamepad-originated events and never touches real
  keyboard/mouse users: emulates Tab-order (the sidebar + active
  screen's real, already-focusable buttons/links) for Arrow keys,
  explicitly .click()s the focused element for Enter/Space, and gives
  Escape a consistent "go back" behavior — an existing in-screen back
  button if one's visible (reusing each screen's own drill-down logic
  for free), else the main menu. Every branch defers via
  `e.defaultPrevented` to any screen that already handles the key
  itself (the song grid, the player, settings), so nothing here
  overrides existing behavior.

- static/v3/songs.js: adds real 2D d-pad/arrow-key navigation to the
  song library's virtualized grid (only a slice of the library is
  ever in the DOM), including fetching/scrolling off-screen rows into
  view and correcting for the sticky filter toolbar's occlusion.

- static/v3/index.html: wires up the two new scripts.

* chore: regenerate stale tailwind.min.css

Rebuilt in a fresh clone (not the local working copy). Several plugin
directories (audio_engine, plugin_manager, community_charts, etc.) are
gitignored locally but present on disk from checking out plugin repos
for local dev/testing — Tailwind's content scan picks them up
regardless, so a rebuild against the contaminated local working copy
bakes in extra utility classes that don't belong in the real,
git-tracked build. A clean checkout reproduces CI's expected output
exactly.

* fix(gamepad): check all matching back buttons, not just the first

document.querySelector on the combined [data-ap-back], [data-albums-back],
#v3-pl-back selector only ever inspects the first match in DOM order —
since screens stay in the DOM (hidden, not removed) when you navigate
away, a hidden back button from an unrelated screen could sort before
the one that's actually visible, incorrectly falling through to
showScreen('v3-home') instead of clicking it. Uses querySelectorAll +
find(visible) instead.

* fix(gamepad): address CodeRabbit findings on connect/disconnect and grid nav

- gamepad.js: anyLiveConnectedPad -> anyLiveStandardPad, filtering by
  mapping === 'standard' like firstLiveStandardPad already does, and
  applied at the top of the gamepadconnected handler too. A still-
  connected non-standard raw mirror could otherwise mask the real
  pad's disconnect (toast never fires, polling never stops).

- songs.js _gpMove: an unset cursor now always seeds at index 0
  before the first press, instead of applying that press's delta
  immediately (ArrowDown/Right previously skipped straight past row
  0; Left/Up only looked right by accident of clamping). Matches the
  existing convention in shortcuts.js's legacy _handleLibArrowNav.

- songs.js _gpBlockedTarget: form-control/button blocking now
  requires the element to be visible (offsetParent !== null), not
  just present. Screens stay in the DOM hidden (not removed) when you
  navigate away, so a real button focused on some other now-hidden
  screen could leave document.activeElement pointing at it and block
  all grid navigation indefinitely. (An el.closest('#v3-songs') scope
  was tried first and reverted — it fixed that case but broke
  blocking for the topbar search input, which lives outside
  #v3-songs's DOM subtree even while v3-songs is active; visibility
  is the distinction that actually matters, not DOM nesting.)

Skipped two CodeRabbit suggestions, verified against current code:
gating songs.js's grid keydown listener to synthetic-only events
would regress the real keyboard accessibility this PR intentionally
added (v3-songs' grid had none before); renaming the _gp* helpers to
drop their underscore prefix would break from this codebase's own
established module-private naming convention.

Verified in-browser: first arrow press lands on index 0, stale hidden
focus no longer blocks grid nav, the topbar search input still
correctly blocks it, and normal nav resumes after blur.

* test(gamepad): unit-cover the controller + nav state machines

- gamepad.test.js (10): standard-mapping filter, Steam Input duplicate-slot
  dedup, disconnect masking, button edge-detection, d-pad/stick repeat timing,
  analog deadzone — driven via a fake navigator + manual rAF queue.
- gamepad_nav.test.js (10): !isTrusted/defaultPrevented gating, arrow focus
  traversal + clamping, hidden-element skipping, Enter/Space click activation
  (not into text fields/body), Escape visible-back-button vs home fallback.

songs.js grid nav is left to on-device coverage (async + windowed-DOM heavy).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>

---------

Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:52:51 +02:00
fcdb4867d6 feat(highway_3d): background controls in the player chrome (#1008)
* Add mid-song background picker to player chrome

Mount a background style/intensity control in the player's plugin popover so users can switch backgrounds mid-song without leaving for Settings. Uses ref-counting to manage the shared control across multiple renderer instances. The control syncs bidirectionally with settings.html and the settings bus, so changes from either UI stay agreed. Moved _pcAcquire() to after _isReady to avoid acquiring for non-viable (e.g. WebGL2-missing) renderers.

* Grey out background controls that current style ignores

Add _PC_USES table to track which settings (intensity, reactive) each background style actually consumes. Disable and grey out controls when the active style doesn't use them, preventing user confusion. Updates _pcPaint() to support disabled state with tooltip explanations, and guards click/change handlers against disabled controls.

* Add background control tests and changelog entry

Document the new background controls feature in the 3D Highway plugin that allows changing the highway background mid-song from the player's Plugin Controls popover. Add a comprehensive test suite for the background control system covering refcounting, settings sync, greying out unsupported controls, and teardown behavior.

* Generalize background control refcounting language

Update CHANGELOG and test comments to reflect that the 3D highway background control refcounting applies to any multiple renderer instances, not exclusively splitscreen. Change test name and clarify that multi-instance behavior is exercised with stubbed instances, not real splitscreen sessions (whose visualizer does not currently work).

* Reorder 3D Highway changelog entry, bump version

Moved the 'Background controls in the player' entry to a different position in the Unreleased changelog section. Updated 3D Highway plugin version from 3.32.0 to 3.33.0.

* fix: store screen.js and CHANGELOG.md with CRLF to match main

The merge of main was run with merge.renormalize=true (needed — this repo has CRLF committed while core.autocrlf=true, so a plain merge sees all 16k lines as changed). That rewrote screen.js and CHANGELOG.md to LF, which autocrlf then stored. main has both as CRLF, so every line differed and GitHub reported 16,428/16,112 for screen.js and refused to render it.

Restaged with the CRLF blobs written directly so they are what get stored. No content change; the diff drops to 316/0 and highway_3d_render_order.test.js leaves the diff entirely.

Signed-off-by: Kyle <kyle.j.t@live.co.uk>

* Unbind screen:changed hook on last release

Ensure the highway_3d control removes its screen:changed listener when the last reference is released to avoid listener/closure leaks across plugin reloads. Added a best-effort off() call and clears _pcScreenHook so future acquires re-bind correctly. Tests updated: mock feedBack on/off implemented, helpers added (screenHooks, fireScreenChanged), and a new test verifies the subscription is removed on final _pcRelease and re-subscribed on re-acquire.

* fix(highway_3d): show greyed-out reason on hover for disabled bg controls

A native-disabled <button>/<input> receives no pointer events, so its
`title` tooltip never appears — the "greyed out, says why on hover"
affordance was dead in the browser while the tests passed on the
swallowed control title. Move the reason onto a non-disabled wrapper and
set pointer-events:none on the disabled control so the hover reaches it.
Also add aria-disabled so screen readers get the state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>

---------

Signed-off-by: Kyle <kyle.j.t@live.co.uk>
Signed-off-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 11:39:26 +02:00
be49465540 fix(highway_3d): initialize camera before silent intros (#1002)
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-19 11:38:25 +02:00
05be9ebdbe Add new chart-transform plugin capability (#1000)
* Chart-transform plugin capability

* PR comments

* Cleanup

* Fix markdown

* CodeRabbit feedback

Signed-off-by: Joe <jphinspace@gmail.com>

---------

Signed-off-by: Joe <jphinspace@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
2026-07-19 11:27:52 +02:00
f7942f3689 fix(gp8): confine registry asset matching to the declared directory (#1011)
`<EmbeddedFilePath>` is matched on filename stem so a format variant of the
same recording can win — an `.ogg` beside the declared `.mp3` is copied out
losslessly rather than transcoded. But the search spanned every directory in
the archive, so an unrelated file that merely shared the stem could stand in
for the declared asset: exactly the substitution the registry lookup added in
#1007 exists to prevent.

Candidates are now confined to the registry path's own directory. A genuinely
absent asset still falls through to the legacy stem match and then the first
audio asset, as documented.

Found by an adversarial pass over #1007 rather than a report — no known file
triggers it, since GP8 writes embedded audio to Content/Assets/ and that is
the only directory scanned. It needs a hand-edited archive to reach.

Both tests fail on main and pass here; their ZIP ordering is deliberate, so
the fall-through target differs from the decoy (otherwise fixed and unfixed
code return the same file and the tests prove nothing).


Claude-Session: https://claude.ai/code/session_01SFDokqh2H6mEjk1Kgbi6JW

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 01:36:28 -05:00
1cd6f2dd65 fix(gp8): AssetId is a key into <Assets>, not a filename stem (#1007)
* fix(gp8): AssetId is a key into <Assets>, not a filename stem

GPIF declares the backing track's audio as:

    <BackingTrack><AssetId>0</AssetId>
    <Assets><Asset id="0">
        <EmbeddedFilePath>Content/Assets/<hash>.mp3</EmbeddedFilePath>

so AssetId indexes the <Assets> registry, which names the exact path in
the ZIP. `_resolve_audio_asset` instead compared it against each audio
file's FILENAME STEM. GP8 names embedded files by hash while ids are
small integers, so that match essentially never hit: every such file
logged "declared AssetId not found" and fell through to "first audio
asset". Silently correct while a file carries exactly ONE audio asset —
but with two, a backing track declaring id 1 resolved to asset 0, i.e.
the wrong recording, for both extract_sync and extract_audio.

Found while verifying embedded-audio extraction for a reported GP8
import; that file logged the warning on the normal path.

- `_asset_path_from_registry()` reads <Asset id=N><EmbeddedFilePath>,
  normalising separators (a writer may emit backslashes). It never
  decides a path exists — the caller verifies membership in the archive,
  since the value comes out of the file and a stale entry must fall
  through rather than resolve to nothing.
- Resolution is now a ladder: registry → legacy stem match → first audio
  asset. Steps 2 and 3 are the previous behaviour, kept so existing
  files and odd shapes are unaffected. Same-stem OGG preference is
  preserved on the registry path too, so quality behaviour is unchanged.

Tests: registry resolution on the real-world shape (integer id, hashed
filename), the second asset finally being reachable (the actual bug), a
registry entry pointing at a missing file falling through, backslash
normalisation, OGG preference among same-stem duplicates, malformed and
absent registries degrading, and the legacy stem match still working.
Suite 1725 passed vs 1720 on main, same 99 pre-existing env failures.

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

* docs(changelog): record the GP8 AssetId resolution fix

Every other change in this release notes itself; this one shipped without
an entry, and the GP import path has had three fixes in two days — the
history is worth being able to read later.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 01:19:59 -05:00
K. O. A.andGitHub 39d1a8cb9b feat(v3): one-click "Not split" library filter + piano stem pill (#1010)
Finding un-split songs took five taps (cycle each stem pill to its
"lacks" state) — and was quietly wrong even then: the drawer offered
five of the canonical six stems, so a piano-only song lacked all five
listed and matched a hand-built "not split" filter despite being split.

The stems section gains a "Not split" toggle that sets stem_lacks to
every instrument stem in one tap (the same lacks-ALL query Stem
Splitter's missing-stems view runs, backend semantics unchanged), and
piano joins the pill row (already in the backend's allowed set).

(Rebuilt on current main after #1003/#810/#92e78be rewrote the drawer
region — the original branch conflicted whole-file.)

Signed-off-by: topkoa <topkoa@gmail.com>
2026-07-19 02:13:42 -04:00
32ed564006 fix(gp2rs): write arrangement XML as UTF-8 so GP import survives non-ASCII metadata (#984)
The GP→arrangement-XML writers persisted their output with
`Path.write_text(xml_str)` and no explicit encoding. On Windows that uses
the cp1252 default, so a non-ASCII metadata character — e.g. the © in an
album name like "Chrysalis©1982" — was written as the lone byte 0xA9.
The XML is read back as UTF-8 (expat's default), where 0xA9 is an invalid
start byte, so `parse_arrangement` died with:

    xml.etree.ElementTree.ParseError: not well-formed (invalid token): line 10, column 22

and the whole Guitar Pro import failed (HTTP 500). All three arrangement
XML writes (gp2rs.py, gp2rs_gpx.py ×2) now pin encoding="utf-8".

CI runs on Linux (UTF-8 default) so the bug was invisible there; the new
test pins the locale-independent contract at the source level plus a
round-trip of a © album name.


Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:56:32 -05:00
1712803dc7 feat(notation_lift): authored per-note hands steer the split — heuristic only guesses the rest (#992)
The keys LH/RH hand arc, the lift slice. split_hands was purely
heuristic (mean pitch vs middle C / largest-gap), so ANY edit to a keys
arrangement re-derived hand splits that could contradict the score's
authored grand-staff assignment — the documented "produces wrong hand
splits" failure, now that authored hands actually reach the wire
(editor #299 emits per-note `hand`; core #990 round-trips it).

- decode_wire_notes carries `hand` through ('lh'/'rh' strict enum;
  junk → None so a hand-edited pack can't steer the split).
- split_hands: an authored hand always wins, and explicit notes are
  REMOVED from their simultaneous group BEFORE the heuristic math runs
  — one authored assignment must never skew its chordmates' guesses
  (e.g. an authored LH melody note above middle C dragging the group
  mean down and flipping the rest). All-explicit groups skip the
  heuristic entirely; unassigned notes behave exactly as before.

Design per the piano-pedagogy review of the arc: binary lh/rh + absent
= unassigned; per-note explicit > heuristic precedence; crossing-hands
textures are exactly why the override is load-bearing.

Tests: five new in test_notation_lift.py (authored wins incl. a
crossing-hands case, group-removal-before-math with exact mean
arithmetic, all-explicit group, junk enum, decode carry-through); the
decode shape pin updated for the new key. Suite: 1723 passed (+5 vs
main; the pre-existing env failures reproduce identically on pristine
main).


Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q

Signed-off-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:40:08 -05:00
00fce2772d feat(song): per-note keys hand assignment (hand) on the Note wire (#990)
The editor's keys LH/RH hand arc needs a per-note hand assignment
('lh'/'rh', from a MusicXML grand staff import today, hand-editable
later) to survive a sloppak save → reload: the editor already emits it
on the wire, but note_from_wire dropped unknown keys, so the field died
on every reopen.

- Note gains `hand: str | None = None` (None = unassigned; the
  heuristic hand split keeps owning unassigned notes). Distinct from
  `right_hand` (the bass plucking finger) — hence the spelled-out
  `hand` wire key, since `rh` is taken.
- note_to_wire emits it default-omitted and validates on emit; older
  readers ignore it (feedpak: unknown note keys are permitted).
- note_from_wire decodes it as a strict enum — anything but 'lh'/'rh'
  (junk, wrong case, bools) falls back to unassigned rather than
  poisoning downstream hand-split / hands-separate practice logic.

Groundwork consumers land separately: notation_lift.split_hands
respecting per-note overrides, and the editor's hand surface.
Editor counterpart: feedBack-plugin-editor #299.

Tests: three new wire round-trip tests in tests/test_song.py (literal
key, default-omitted, junk rejection both directions), matching the
teaching-marks test style.


Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q

Signed-off-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 00:39:27 -05:00
1745b13ba7 feat(playlists): flag songs that are not in your current tuning (#1009)
* feat(playlists): flag songs that are not in your current tuning

Making the library's tuning filter instrument-aware does not repair playlists
already built under the old guitar-first behaviour. Those keep their
wrong-tuning songs, so a player still hits a surprise retune mid-practice and
reasonably concludes nothing was fixed.

Adds a per-playlist check: each row is marked against the player's current
tuning, with a summary ("3 of 24 songs are not in your tuning"), a filter to
show only those, and an explicit removal that lists every affected song by
title and states they stay in the library. Flagging is the feature -- nothing
is ever removed without being asked for, and removal reuses the existing
per-song DELETE rather than adding a bulk destructive endpoint.

Reuses the tuner capability's coverage report and `window.feedBack
.workingTuning`, the same pair the library cards already score against,
rather than introducing another source of truth.

Two deliberate departures:
- A coverage report reads "not covered" both for a real mismatch and for a
  bail-out it could not evaluate. Only a report carrying an actual reason
  counts as a mismatch; the rest render as unknown. This differs from the
  library grid, which paints every not-covered song amber -- acceptable on a
  grid, not on a hand-curated playlist where a false warning costs trust.
- With no tuning perspective available it makes no claim at all, rather than
  defaulting to guitar and reproducing the original bug in a new place.

Playlist rows carry `tuning_offsets` and `bass_only`; a tuning *name* cannot
be scored, since two "Custom Tuning" rows are different tunings.

Fully correct once the instrument-aware tuning filter lands. That dependency
is confined to `rowTuningForCheck()` in static/v3/playlists.js, marked SEAM.

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

* build(tailwind): regenerate for the playlist tuning-check classes

CI's tailwind-fresh gate rebuilds static/tailwind.min.css and hard-fails if
the committed file differs. The new chip/summary/filter markup introduces
classes the previous build never saw.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* fix(playlists): stay within the shipped Tailwind class set

Reverts the regenerated static/tailwind.min.css and reworks the tuning-check
markup to use only classes already in the committed sheet.

Regenerating that file is not reproducible off CI: nothing pins tailwindcss,
autoprefixer or caniuse-lite, so a local `npx -y tailwindcss@3.4.19` resolves
different browser data and rewrites unrelated bytes -- a clean checkout of
main rebuilds with the -webkit-backdrop-filter prefixes dropped. Committing
that output fails the tailwind-fresh gate no matter how many times it is
regenerated.

Six utilities were new: bg-fb-good/10, border-fb-accent/50,
hover:bg-fb-accent/10, list-disc, list-inside, max-h-48, plus gap-x-3/gap-y-2.
Substituted bg-fb-good/30, the amber border already used by the mismatch
state, hover:bg-fb-card, a literal bullet in a div, max-h-32 and gap-3. Visual
intent is unchanged.

The removal-confirm test pinned the <li> markup; it now accepts either
wrapper, since what it guards is that every song is named and escaped ahead
of any DELETE, not which element wraps it.

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>

* Use instrument tuning in playlist checks

---------

Signed-off-by: ChrisBeWithYou <christian.a.cowan@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 00:10:53 -05:00
66 changed files with 12438 additions and 4745 deletions
+7
View File
@@ -63,11 +63,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# npm ci runs third-party postinstall scripts; don't leave the token in
# git config for them (this job never pushes).
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Rebuild Tailwind CSS
run: bash scripts/build-tailwind.sh
+90
View File
@@ -0,0 +1,90 @@
name: Content packs
# Build & publish opt-in career venue packs as per-pack, versioned, immutable
# releases (convention: tag `venue-<id>-v<N>`, asset `<id>-pack-v<N>.zip`),
# then open a PR bumping venues.json url/sha256/bytes. This is automation so
# publishing packs is never a person's manual job (SLIM-NIGHTLY item 1b).
#
# Immutable tags → publishing is a deliberate, versioned act, so this runs on
# manual dispatch (not push): a media change means a new version, a human call.
on:
workflow_dispatch:
inputs:
venues:
description: "Space-separated venue ids to (re)publish, e.g. 'club arena'"
required: true
default: "club"
version:
description: "Pack version N (tag venue-<id>-vN). Bump for new media."
required: true
default: "1"
concurrency:
group: content-packs
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Media lives in-tree today; add `lfs: true` once SLIM-NIGHTLY item 4
# moves venue-packs/** to Git LFS.
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Build & publish packs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Never interpolate dispatch inputs straight into the shell — a crafted
# value would execute on the runner with this job's write token. Pass
# via env, validate the formats, and use a Bash argument array.
VENUES: ${{ github.event.inputs.venues }}
VERSION: ${{ github.event.inputs.version }}
run: |
set -euo pipefail
[[ "$VERSION" =~ ^[1-9][0-9]*$ ]] || { echo "::error::version must be a positive integer"; exit 1; }
[[ "$VENUES" =~ ^[a-z0-9][a-z0-9-]*(\ [a-z0-9][a-z0-9-]*)*$ ]] || { echo "::error::venues must be space-separated venue ids"; exit 1; }
read -r -a venues <<< "$VENUES"
dirs=()
for v in "${venues[@]}"; do
dirs+=("plugins/career/venue-packs/$v")
done
python tools/content_packs.py "${dirs[@]}" \
--version "$VERSION" \
--publish \
--manifest /tmp/packs-manifest.json
cat /tmp/packs-manifest.json
- name: Apply url/sha256/bytes to venues.json
run: |
python - <<'PY'
import json, pathlib
manifest = json.load(open("/tmp/packs-manifest.json"))
vpath = pathlib.Path("plugins/career/venues.json")
data = json.loads(vpath.read_text())
for v in data["venues"]:
m = manifest.get(v["id"])
if m and v.get("pack"):
v["pack"].update(url=m["url"], sha256=m["sha256"], bytes=m["bytes"])
vpath.write_text(json.dumps(data, indent=4) + "\n")
PY
- name: Open manifest-bump PR
uses: peter-evans/create-pull-request@v6
with:
commit-message: "career: refresh venue pack manifest (v${{ github.event.inputs.version }})"
title: "career: refresh venue pack manifest (v${{ github.event.inputs.version }})"
body: |
Automated by the content-packs workflow after publishing
`${{ github.event.inputs.venues }}` v${{ github.event.inputs.version }}
to their `venue-<id>-v${{ github.event.inputs.version }}` releases.
Bumps `venues.json` pack url/sha256/bytes to match the uploaded zips.
branch: content-packs/manifest-bump
delete-branch: true
+122
View File
@@ -8,6 +8,80 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### 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
(`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
starter `bar` venue bundled for offline play. Trims ~678 MB from the desktop
download; an unpublished pack shows "coming soon" and plays on the standard
stage until its release lands.
- **Session-sync relay WebSocket — `/ws/sync/{session_id}` (#1030).** A
deliberately dumb JSON fan-out room: a text frame received from one client is
forwarded verbatim to every other client on the same session id; the server
interprets nothing (message schemas are owned by consumers). Rooms are created
on first join and garbage-collected when the last socket leaves — no history,
no replay, no persistence, so a host that crashes and rejoins the same id
resumes publishing to reconnecting subscribers with no server-side
coordination. Session ids are client-generated (`[A-Za-z0-9_-]{4,64}`); DoS
hygiene for a LAN-exposed port via frame-size (16 KB), per-room (16 sockets),
total-room (32), and per-socket rate (120 msg/s sustained, 240 burst) caps —
over-limit sockets are closed with a policy code and the room carries on, and
a peer that dies — or stalls: fan-out sends are bounded by a 5 s timeout —
mid-fan-out is dropped without disturbing delivery to the rest. `main.py`
also caps inbound WS frames at the transport (`ws_max_size=64 KB`, down from
uvicorn's 16 MB default) so oversized frames never materialize server-side. First consumer: splitscreen's "pop out to LAN" follower mode
(feedBack-plugin-splitscreen#21), which relays playhead/playstate/song-change
frames from a host window to view-only followers on other LAN devices.
Implementation in `lib/routers/ws_sync.py`; tests in `tests/test_ws_sync.py`.
- **Drum-part picker (feedpak 1.17.0 "drums as arrangements").** When a song
carries several drum charts, a **Drum part** selector appears beside the
arrangement switcher (advanced settings) so a player can choose which drummer
to play. Selecting one re-streams that part's tab over the highway WS
(`?drum_part=<id>`, mirroring the arrangement switch); the choice persists
across an arrangement change, and the picker reflects the server's
authoritative part (unknown/absent selection falls back to the primary). The
row hides for single-drum and non-drum songs, so nothing changes there. Builds
on the loader below; no plugin change needed — the drum renderer just draws
whatever tab streams.
- **Multiple drum parts (feedpak 1.17.0 "drums as arrangements").** The sloppak
loader now reads `type: drums` arrangement entries carrying per-arrangement
`drum_tab` file pointers — a song can ship several drum charts (a second
drummer, an aux-percussion layer). Parts surface as `LoadedSloppak.drum_parts`
(primary first; the entry aliasing the song-level `drum_tab:` key is the
primary and is never loaded twice), the highway WS `song_info` gains a
`drum_parts` name list, and `?drum_part=<id>` on the WS URL selects which
part's tab streams (`drum_tab` messages carry `part_id` when multiple parts
exist; unknown ids fall back to the primary). Pointer entries are **never**
loaded as fretted arrangements — the loader's file/notation gate keeps a drum
part out of the fretted pipeline (and out of note-detection grading), pinned
by test. Legacy single-drum packs read exactly as before, as a one-part list.
- **`chart-transform` capability domain (#952)** — plugins can now remap the
chart before rendering and scoring through a core-owned provider
coordinator. Synchronous transforms run after difficulty filtering; host
data is isolated from providers, accepted timelines are time-sorted, and
failures fall back to the original chart with a fixed public reason.
Effective chart arrays and metadata are available to 2D/custom renderers
and highway getters, while `getSongInfo()` retains the original metadata.
Provider selection persists and applies to primary and splitscreen highways.
- **Library filter: one-click "Not split" + a piano stem pill.** The v3 Filters drawer's
stems section gains a **Not split** shortcut that selects "lacks every instrument stem"
in one tap — the same query Stem Splitter's missing-stems view runs — instead of
cycling five pills to ✕ by hand. The pill row also gains `piano` (the drawer offered
five of the canonical six stems, so a piano-only song wrongly matched a hand-built
"lacks all" filter). "No lyrics" already existed in the Lyrics section.
- **Gold tier (career passports)** — an earned badge turns **gold** when
Virtuoso verifies an improvised jam in the passport's style (the
`gold_improv` artifact relays with the drill snapshot; a genre inherits its
@@ -58,6 +132,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
fence. The gem's rim joins in: on a confirmed hit the outline flashes in the
string's own colour with the same intensity treatment as the wires, fading with the
scorer's alpha. With no scorer attached, nothing changes.
- **Background controls in the player (3D Highway)** — change the highway
background mid-song from the player's Plugin Controls popover instead of
opening Settings: a style dropdown, a Reactive toggle, and an Intensity
slider, all kept in sync with the Settings page. Controls that the active
style ignores are greyed out with a reason on hover (Custom video and
Butterchurn use neither; Custom image uses Intensity but not Reactive), so
a knob is never present-but-inert. The control disappears when a non-3D
renderer is selected. The whole group also greys out while the Venue scene
override is active, since none of the three controls reach a mounted style
in that mode.
### Changed
- **`GET /api/song/{f}?stems=1`** (new, opt-in) — returns the pack's playable stem
@@ -229,6 +313,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
engine (`app.js`, `highway.js`, `playSong`, `showScreen`, the capability registry).
### Fixed
- **Count-in follows the song's meter and its pickup measure.** The count-in
(loop wrap, section practice, and the "Countdown before song" setting) always
clicked exactly four beats, so a 3/4 song was counted in 4/4, and a song
opening with a pickup (anacrusis) had the pickup enter where the downbeat
belonged — putting the player a beat ahead for the whole song. The bar length
now comes from the `song_timeline` beats already on the highway
(`measure >= 0` marks downbeats; no new plumbing, since the `time_signatures`
map is streamed to plugins rather than stored in the frontend), and a first
bar shorter than that meter shortens the count by its length: a 1-beat pickup
in 4/4 counts "1 2 3" and the music enters on 4. Songs without beats — pre-chart,
minigames, synthetic highways — still get four.
- **GP8 asset resolution honours the directory the registry named.**
`<EmbeddedFilePath>` is matched on filename stem so a format variant of the
same recording can win (an `.ogg` beside the declared `.mp3` is copied out
losslessly instead of transcoded) — but the search was not restricted to the
declared directory, so an unrelated file elsewhere in the archive that merely
shared the stem could stand in for the declared asset. That is the exact
substitution the registry lookup exists to prevent. Candidates are now
confined to the registry path's own directory; a genuinely absent asset
falls through as documented.
- **Guitar Pro 8: the right backing track is extracted when a file carries more than one.**
`BackingTrack/AssetId` is a key into the GPIF's `<Assets>` registry — `<Asset
id="0"><EmbeddedFilePath>` names the exact path inside the archive — but it
was being matched against embedded *filename stems*. GP8 names embedded audio
by hash while ids are small integers, so that match essentially never hit: every
such file warned and fell through to "first audio asset". That was silently
correct while a file carried exactly one recording — with two, a backing track
declaring id 1 resolved to asset 0, i.e. the wrong take. Resolution now reads
the registry first (verifying the path is really in the archive, so a stale
entry falls through rather than resolving to nothing), then the legacy stem
match, then the first asset.
- **Guitar Pro import no longer fails on non-ASCII song metadata (Windows).**
The GP→arrangement-XML writers wrote their output with `Path.write_text()`
and no explicit encoding, so on Windows (cp1252 default) a metadata
character like the © in an album name ("Chrysalis©1982") was written as a
lone `0xA9` byte — invalid UTF-8 — and import died with
`not well-formed (invalid token): line N, column 22`. All three arrangement
XML writes now pin `encoding="utf-8"`.
- **3D Highway: the lane stops at the hit line** (#991) — the highway lane, its
dividers, and the fret boundary extension lines ran `BEHIND` seconds *past* the
hit line toward the player. Nothing is ever drawn in that strip (notes and chord
+9 -1
View File
@@ -400,6 +400,14 @@ highway.setNoteStateProvider((note, chartTime) => {
- The built-in 2D highway consults it in `drawNote` / `drawSustains` / the chord-frame path: 'hit'/'active' → bright string colour + additive halo + a contained "sizzle" (crackling sparks, throbbing core, a shockwave ring on a fresh strike) on the gem and a bright (vs dim) sustain trail; 'miss' → faint red wash. The bundled **3D highway** reads the same data via `bundle.getNoteState` (bright string-tinted outline + bright body + glowing sustain + a contained sparkle hugging the note rect on hit/active; red outline + suppressed body on miss). Custom renderers that want it call `bundle.getNoteState(note, chartTime)` — it null-guards and returns the normalized `{ state, alpha, color }` (or null).
- This is orthogonal to the overlay contract: note_detect remains an overlay (HUD, diagnostic miss markers, the "currently detected" indicator) *and* a scorer that feeds this provider. A renderer that ignores `getNoteState` simply doesn't light gems — nothing breaks.
#### 4. Chart-transform provider — remap the chart before rendering AND scoring (feedBack#952)
The core-owned `chart-transform` provider coordinator applies synchronous chart substitutions after difficulty filtering. Register and select providers through the capability domain; it owns persistence, refresh, splitscreen propagation, failure attribution, and diagnostics.
Provider inputs and staged outputs are isolated copies. Async returns or provider errors fail back to the original chart and expose only a fixed public failure reason. `getSongInfo()` remains the original chart contract; transform-aware consumers use the renderer bundle or `getStringCount()`, `getTuning()`, `getCapo()`, and `getCentOffset()`.
See [docs/capability-recipes.md](docs/capability-recipes.md#chart-transform-provider) for the manifest and registration example.
### Audio mixer fader registration (feedBack#87)
Plugins that produce audio outside the song's `<audio>` element (NAM amp output, synth voices, etc.) can register a labeled fader so users can balance them against the song from one mixer popover in the player controls.
@@ -682,7 +690,7 @@ The highway WebSocket at `/ws/highway/{filename}?arrangement={index}` streams th
| `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, 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 |
| `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". |
+12 -3
View File
@@ -153,6 +153,14 @@ The legacy chart-coupled surface — `highway.setNoteStateProvider(fn)`, the sin
Diagnostics live under `feedBack.note_detection_capability.v1` and contain provider ids/labels/kinds, binding summaries (requester, provider, redacted context, target size), availability, and the last bounded outcome (event, binding, provider, MIDI number, hit flag) — never raw audio buffers, sample data, device labels, or song identity.
## Chart-Transform Domain
The chart-transform slice (#952) is a core-owned provider coordinator implemented by [static/capabilities/chart-transform.js](../static/capabilities/chart-transform.js). Its commands register, select, clear, and refresh providers; `chart.transform` is the provider operation. Selection persists by provider id and applies to the primary highway and announced splitscreen instances.
The synchronous `highway.setChartTransform` data-plane hook runs at chart ready, mastery changes, and refresh—not per frame. Transforms receive isolated chart data after difficulty filtering and may replace notes, chords, anchors, hand shapes, chord templates, string count, tuning, capo, and cent offset. Outputs are isolated and timeline arrays are time-sorted before the built-in renderer, renderer bundle, or public getters read them. Async returns and other provider failures clear the stage and retain the original chart.
`getSongInfo()` retains original metadata; effective values are exposed by the renderer bundle and dedicated highway getters. Diagnostics under `feedBack.chart_transform.diagnostics.v1` contain provider selection/install state and a fixed public failure reason, never chart data, song identity, or raw exceptions. The domain has no compatibility shim because no earlier chart-substitution surface exists.
## MIDI-Input Domain
The MIDI-input slice (spec 012, issues #873/#880) promotes `midi-input` as a **core-owned** provider-coordinator implemented by [static/capabilities/midi-input.js](../static/capabilities/midi-input.js) — the MIDI analog of `audio-input`. It is deliberately separate from `audio-input` (whose source/`open` contract is audio-frame-centric: channel shapes, sample buffers) because MIDI carries discrete messages, not audio; and it is **not** owned by any feature plugin, so the device-access boundary outlives the input-setup wizard (exactly as `audio-input` is `core.audio.session`-owned). Consumers — the `input_setup` onboarding wizard, the `piano`/keys and `drums` plugins, and (as a follow-up, #881) note-detection's Web-MIDI provider — converge here on ONE device-access boundary: one permission prompt, one source list, one redaction boundary, retiring private per-plugin `navigator.requestMIDIAccess()` calls.
@@ -192,7 +200,7 @@ Core domains include review metadata in diagnostics:
- `active`: wired to current FeedBack behavior and expected to work as an integration point.
- `diagnostic`: support/inspection-only runtime surfaces.
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. The audio-effects slice promotes provider-selected effect-chain planning while leaving physical processor loading to compatible executors such as trusted Desktop native audio or a browser/WASM executor. The visualization slice promotes `visualization` as the highway renderer provider-coordinator, and the note-detection slice (spec 009) promotes `note-detection` as the detection-binding control plane. Backend routes, app UI, settings, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
PR1 includes only the delivered domains listed in [capability-roadmap.md](capability-roadmap.md): `pipeline`, `diagnostics`, and `library`. The follow-up audio graph/session slice promotes `audio-mix`, `audio-input`, `audio-monitoring`, and a coordinated `stems` surface. The playback slice promotes `playback` as an active transport control plane. The audio-effects slice promotes provider-selected effect-chain planning while leaving physical processor loading to compatible executors such as trusted Desktop native audio or a browser/WASM executor. The visualization slice promotes `visualization` as the highway renderer provider-coordinator, the note-detection slice (spec 009) promotes `note-detection` as the detection-binding control plane, and the chart-transform slice (#952) promotes `chart-transform` as the pre-render/pre-scoring chart substitution coordinator. Backend routes, app UI, settings, and other hardware-facing domains remain documented in the roadmap and safety matrix until their own host workflow/provider slice exists.
Capability metadata is versioned by the `capability-pipelines.v1` standard. Invalid roles, commands, operations, requests, observes, emits, events, owner kinds, compatibility modes, ownership policies, safety classes, or version fields are excluded from the capability graph and surfaced through `capability_validation_warnings`; legacy plugin fields continue to load through their existing app paths. Plugins that declare a future `capability-pipelines` version are reported through `capability_unsupported_versions` and their runtime handlers are marked incompatible.
@@ -248,7 +256,7 @@ UI placement and settings contributions are real FeedBack surfaces, but they are
The library provider workflow is the PR1 core adapter and is implemented natively as the `library` capability module. Provider refresh, selection, and sync run through `library` owner commands; backend provider registration remains the way providers enter the library registry, and the browser module turns that registry into provider participants. The app event bus continues to dispatch local `window.feedBack` events for legacy listeners; playback now mirrors song transport, route, seek, and loop lifecycle into `playback`, and visualization attributes renderer selection/failure, while navigation, note, and route-only surfaces remain outside capability domains until their own slices land.
The direct `window.highway` object remains the renderer data plane. Per-frame reads such as notes, chords, beats, and renderer hooks should not be moved behind asynchronous capability commands until there is a dedicated chart/render facade.
The direct `window.highway` object remains the renderer data plane. Per-frame reads such as notes, chords, beats, and renderer hooks should not be moved behind asynchronous capability commands until there is a dedicated chart/render facade. The `chart-transform` domain follows this doctrine: its substitution runs through the synchronous `highway.setChartTransform` hook (staged once per chart change), while the capability surface owns only registration, selection, and diagnostics.
## First-Party Management Plugins
@@ -295,8 +303,9 @@ From the `feedBack/` directory:
```bash
node --check static/app.js
node --check static/capabilities.js
node --check static/capabilities/chart-transform.js
node --check static/diagnostics.js
node --check plugins/capability_inspector/screen.js
node --test tests/js/*.test.js
pytest tests/test_plugin_runtime_idempotence.py tests/test_plugins.py tests/test_diagnostics_bundle.py -q
```
```
+51
View File
@@ -499,6 +499,57 @@ window.feedBack.on('progression:quest-completed', (e) => {
});
```
## Chart-Transform Provider
Plugins that transpose, simplify, annotate, or otherwise rewrite chart data register as `chart-transform` providers (#952). The effective chart reaches the built-in highway, custom renderers, and highway getters on primary and splitscreen instances.
```json
{
"id": "my_transform",
"name": "My Transform",
"standards": ["capability-pipelines.v1"],
"capabilities": {
"chart-transform": {
"roles": ["provider"],
"operations": ["chart.transform"],
"mode": "active",
"compatibility": "none",
"ownership": "multi-provider",
"safety": "safe",
"version": 1
}
}
}
```
```js
const api = window.feedBack.capabilities;
await api.dispatch({
capability: 'chart-transform',
command: 'register-provider',
source: 'my_transform',
payload: {
providerId: 'my_transform',
label: 'My Transform',
transform(input) {
const notes = rewriteNotes(input.notes);
const allNotes = input.allNotes === input.notes ? notes : rewriteNotes(input.allNotes);
return { notes, allNotes };
},
},
});
await api.dispatch({ capability: 'chart-transform', command: 'select-provider',
source: 'my_transform', payload: { providerId: 'my_transform' } });
await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: 'my_transform' });
```
`transform(input)` receives filtered `notes`, `chords`, `anchors`, and `handShapes`, plus full-difficulty `allNotes`/`allChords`, `chordTemplates`, `stringCount`, and `songInfo`. It may synchronously return any subset of those arrays plus `tuning`, `capo`, or `centOffset`; null leaves the chart unchanged. The host isolates provider inputs and outputs, time-sorts accepted timelines, and falls back to the original chart on failure.
Transforms run at chart ready, mastery recompute, and explicit `refresh`, never per frame. Selection persists by provider id. `getSongInfo()` retains original metadata; effective metadata is available through the renderer bundle and `getStringCount()`, `getTuning()`, `getCapo()`, and `getCentOffset()`.
## Future Expansion Domains
Some domain names are reserved for expected future contracts, but they are not registered in the runtime graph yet. For example, `ui.player-panels` is documented as a likely panel-host surface, but FeedBack does not currently expose a capability command for panel contributions. See [capability-roadmap.md](capability-roadmap.md) for the PR1 domain set and deferred-domain checklist.
+4
View File
@@ -60,6 +60,10 @@ The progression slice (spec 010) promotes `progression` as an active exclusive-o
Deferred follow-up slices: a `contributor` role so plugins ship their own challenge/quest content (drums challenges from a drums-scoring plugin, quest-pool entries from minigame plugins), and drums scoring wiring so `song_completed {instrument: "drums"}` goals become satisfiable.
## Chart-Transform Control Plane Slice
The chart-transform slice (#952) is an active provider-coordinator domain. It owns provider lifecycle, persisted selection, refresh, failure attribution, and redaction-safe diagnostics. Its synchronous highway hook applies isolated provider output after difficulty filtering to built-in, custom-renderer, and getter consumers across primary and splitscreen highways. No compatibility shim is needed; per-panel independent selection remains a follow-up.
## Recommended Next Slices
The plugin inventory suggests this migration order after the audio graph/session and playback slices:
+1 -1
View File
@@ -20,7 +20,7 @@ Core domains also have a review scope. **Active contract** domains are wired to
| visualization | provider-coordinator | safe | inspect, list-providers, select-renderer, clear-renderer | renderer.create, renderer.destroy | Highway renderer provider registry, picker-delegated selection, auto-match attribution, and failure fallback. `renderer.create` maps to the legacy `window.feedBackViz_*` factory `init(canvas, ctx)` call; `renderer.destroy` maps to the factory `destroy()` teardown. Legacy `type: "visualization"` manifests and `window.feedBackViz_*` globals are accounted compatibility shims. Diagnostics carry provider ids/labels, selection source, last auto-match outcome, and last failure — no song filenames, titles, or arrangement names. |
| note-detection | provider-coordinator | sensitive | inspect, register-provider, unregister-provider, open-binding, close-binding, set-target, clear-target | pitch.estimate, verify.target | Detection-binding control plane (spec 009): providers (midi/engine/js) serve primitives; each requester binds its own redacted tuning context; consumers own judgment, hit/miss flow as observability events. Legacy `highway.setNoteStateProvider` is an accounted shim. Diagnostics carry provider/binding summaries and bounded outcomes — no raw audio, sample data, device labels, or song identity. |
| chart-transform | provider-coordinator | safe | inspect, list-providers, register-provider, unregister-provider, select-provider, clear-provider, refresh | chart.transform | Synchronous chart substitution after difficulty filtering (#952). Provider data is isolated, timelines are sorted, and failures retain the original chart with a fixed public reason. Diagnostics contain provider and selection state, never chart data, song identity, or raw exceptions. |
| midi-input | provider-coordinator | sensitive | inspect, list-sources, discover, select-source, open-source, close-source | source.enumerate, source.describe, source.open, source.close | Core-owned MIDI device control plane (spec 012), the MIDI analog of `audio-input`. Inspect/list/select are prompt-free; `discover` is the Web-MIDI permission boundary (`requestMIDIAccess()` gates the whole input list) and records denied/unavailable outcomes; `open-source` attaches a shared listener session and never re-prompts. Selection persists by redaction-safe `logicalSourceKey`. Diagnostics redact device labels and never include raw MIDI messages or live handles. |
Privileged commands are roadmap-only until they have: a visible user confirmation path, diagnostics redaction rules, failure recovery, and tests that prove disabled or incompatible participants cannot execute handlers.
+1 -1
View File
@@ -2080,7 +2080,7 @@ def convert_file(
safe_name = track.name.strip().replace(" ", "_").replace("/", "_")
filename = f"{safe_name}_{arr_name or 'arr'}.xml"
filepath = out / filename
filepath.write_text(xml_str)
filepath.write_text(xml_str, encoding="utf-8")
output_files.append(str(filepath))
return output_files
+2 -2
View File
@@ -1680,7 +1680,7 @@ def convert_file(
filepath = safe_join(out, filename)
if filepath is None:
raise ValueError(f"unsafe output filename from track name: {track['name']!r}")
filepath.write_text(xml_str)
filepath.write_text(xml_str, encoding="utf-8")
output_files.append(str(filepath))
continue
@@ -2108,7 +2108,7 @@ def convert_file(
filepath = safe_join(out, filename)
if filepath is None:
raise ValueError(f"unsafe output filename from track name: {track['name']!r}")
filepath.write_text(xml_str)
filepath.write_text(xml_str, encoding="utf-8")
output_files.append(str(filepath))
# Keys/piano tracks additionally get a standard-notation sidecar
+76 -6
View File
@@ -72,15 +72,59 @@ def _parse_gpif(data: bytes):
return ET.fromstring(data)
def _asset_path_from_registry(root, asset_id: str) -> str | None:
"""The ZIP path an ``<Asset id=...>`` declares, or None.
GPIF shape::
<Assets>
<Asset id="0">
<EmbeddedFilePath>Content/Assets/&lt;hash&gt;.mp3</EmbeddedFilePath>
Separators are normalised (a writer may emit backslashes) and the
result is returned as-is for the caller to verify against the
archive — this function never decides that a path exists.
"""
if root is None or not asset_id:
return None
try:
for asset in root.iter('Asset'):
if (asset.get('id') or '').strip() != asset_id:
continue
node = asset.find('EmbeddedFilePath')
path = (node.text or '').strip() if node is not None else ''
if not path:
return None
return path.replace('\\', '/').lstrip('./')
except Exception:
# A malformed registry is not fatal — the caller has two more
# resolution steps behind this one.
return None
return None
def _resolve_audio_asset(zf, root=None) -> tuple[str, str | None]:
"""Resolve the embedded backing-track audio asset inside a .gp ZIP.
Matches ``BackingTrack/AssetId`` against the audio files under
``Content/Assets/`` (OGG, MP3, M4A, …) and falls back to the first
audio asset when the declared id is missing or unmatched. Returns
``(asset_stem, audio_zip_path)``, or ``('', None)`` when the archive
has no audio asset. Shared by ``extract_sync`` and ``extract_audio``
so the matching logic can't drift between them.
``BackingTrack/AssetId`` is a key into the GPIF's ``<Assets>``
registry — ``<Asset id="0"><EmbeddedFilePath>`` names the exact path
inside the ZIP — NOT a filename stem. Resolution order:
1. the registry entry for the declared id (authoritative);
2. a filename-stem match (files whose stem IS the id);
3. the archive's first audio asset.
Step 2 was previously the only lookup, which mattered because GP8
names embedded files by hash while ids are small integers, so the
stem match essentially never hit: every such file logged a warning
and fell through to step 3. That was silently correct only because a
file almost always carries exactly ONE audio asset — with two, a
backing track declaring id 1 resolved to asset 0, i.e. the wrong
recording.
Returns ``(asset_stem, audio_zip_path)``, or ``('', None)`` when the
archive has no audio asset. Shared by ``extract_sync`` and
``extract_audio`` so the matching logic can't drift between them.
"""
audio_files = [
n for n in zf.namelist()
@@ -115,6 +159,32 @@ def _resolve_audio_asset(zf, root=None) -> tuple[str, str | None]:
declared = (aid.text or '').strip() if aid is not None else ''
if declared:
# 1. The <Assets> registry is authoritative: it maps the id to the
# embedded path directly. Membership in the archive is verified
# rather than trusted — the path comes out of the file, and a
# stale/edited entry must fall through, not resolve to nothing.
registry_path = _asset_path_from_registry(root, declared)
if registry_path:
# Matched on STEM, not the whole path, so a format variant of the
# same recording can win (see _prefer_ogg) — but constrained to the
# directory the registry actually named. Without that constraint an
# unrelated file that merely shares the stem could stand in for the
# declared asset, which is the failure the registry lookup exists
# to prevent.
declared_path = Path(registry_path)
same_stem = [
n for n in audio_files
if Path(n).stem == declared_path.stem
and Path(n).parent == declared_path.parent
]
if same_stem:
return declared_path.stem, _prefer_ogg(same_stem)
_log.warning(
'gp8_audio_sync: AssetId %r maps to %r, which is not an audio '
'asset in the archive; falling back',
declared, registry_path,
)
# 2. Legacy shape: files whose stem IS the declared id.
matched = [n for n in audio_files if Path(n).stem == declared]
if matched:
return declared, _prefer_ogg(matched)
+37 -11
View File
@@ -54,15 +54,20 @@ MIDDLE_C = 60
def decode_wire_notes(arr_data: dict) -> list[dict]:
"""Decode an arrangement JSON's notes + chord notes to
``[{"t": float, "midi": int, "sus": float}, ...]`` sorted by time.
``[{"t": float, "midi": int, "sus": float, "hand": str|None}, ...]``
sorted by time.
Keys content packs absolute MIDI as ``midi = s*24 + f`` (sloppak-spec
§5.3 legacy fallback). Sustain is the ``sus`` field (``l`` accepted as a
legacy alias). Entries with malformed fields are skipped.
legacy alias). ``hand`` is the authored per-note hand assignment
(``'lh'``/``'rh'`` — e.g. from a MusicXML grand-staff import via the
editor); a strict enum decode, anything else reads as ``None``
(unassigned) so junk can never steer the hand split. Entries with
malformed fields are skipped.
"""
out: list[dict] = []
def _push(t, s, f, sus):
def _push(t, s, f, sus, hand):
try:
t = float(t)
midi = int(s) * 24 + int(f)
@@ -70,11 +75,15 @@ def decode_wire_notes(arr_data: dict) -> list[dict]:
except (TypeError, ValueError):
return
if 0 <= midi <= 127:
out.append({"t": t, "midi": midi, "sus": max(0.0, sus)})
out.append({
"t": t, "midi": midi, "sus": max(0.0, sus),
"hand": hand if hand in ("lh", "rh") else None,
})
for n in arr_data.get("notes") or []:
if isinstance(n, dict):
_push(n.get("t"), n.get("s"), n.get("f"), n.get("sus", n.get("l")))
_push(n.get("t"), n.get("s"), n.get("f"), n.get("sus", n.get("l")),
n.get("hand"))
for ch in arr_data.get("chords") or []:
if not isinstance(ch, dict):
continue
@@ -83,7 +92,7 @@ def decode_wire_notes(arr_data: dict) -> list[dict]:
if isinstance(cn, dict):
# Chord notes carry no own time — they sound at the chord's t.
_push(cn.get("t", ch_t), cn.get("s"), cn.get("f"),
cn.get("sus", cn.get("l")))
cn.get("sus", cn.get("l")), cn.get("hand"))
out.sort(key=lambda n: (n["t"], n["midi"]))
return out
@@ -103,14 +112,31 @@ def group_simultaneous(notes: list[dict]) -> list[list[dict]]:
def split_hands(notes: list[dict]) -> dict[str, list[dict]]:
"""Assign every note to ``rh`` or ``lh`` per the heuristic.
"""Assign every note to ``rh`` or ``lh``.
Per simultaneous group: a span > 12 semitones splits at the largest
internal interval gap (low side → lh); otherwise the whole group goes by
mean pitch vs middle C (≥ 60 → rh).
An AUTHORED per-note ``hand`` ('lh'/'rh' — a MusicXML grand-staff import
or a hand edit in the editor) always wins: those notes go straight to
their hand and are REMOVED from the group before any heuristic math runs,
so one explicit assignment can never skew its chordmates' guesses (e.g.
an authored LH melody note above middle C must not drag the group mean
down and flip the remaining notes).
The remaining unassigned notes take the heuristic, per simultaneous
group: a span > 12 semitones splits at the largest internal interval gap
(low side → lh); otherwise the whole group goes by mean pitch vs middle C
(≥ 60 → rh).
"""
hands: dict[str, list[dict]] = {"rh": [], "lh": []}
for group in group_simultaneous(notes):
for full_group in group_simultaneous(notes):
# Authored hands first — explicit notes leave the group entirely.
group = []
for n in full_group:
if n.get("hand") in ("lh", "rh"):
hands[n["hand"]].append(n)
else:
group.append(n)
if not group:
continue
pitches = sorted(n["midi"] for n in group)
span = pitches[-1] - pitches[0]
if len(pitches) > 1 and span > HAND_SPLIT_SPAN_SEMITONES:
+2 -1
View File
@@ -868,7 +868,8 @@ def _playable_stems_payload(filename: str, dlc) -> dict:
return {
"stems": [
{"id": s["id"], "url": _url(s["file"]), "default": s["default"]}
{"id": s["id"], "url": _url(s["file"]), "default": s["default"],
**{k: s[k] for k in ("name", "description") if k in s}}
for s in loaded.stems
],
"full_mix_url": _url(loaded.full_mix) if loaded.full_mix else None,
+69 -19
View File
@@ -26,6 +26,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from song import (
anchor_to_wire,
arrangement_is_bass,
arrangement_string_count,
base_open_string_midis,
chord_template_to_wire,
@@ -143,9 +144,21 @@ def _sanitize_authors(manifest: dict | None) -> list[dict]:
return out
def _drum_part_id_for_wire(drum_parts: list[dict] | None, selected_id: str | None) -> str | None:
"""Expose a part id only when the pack genuinely has multiple parts."""
return selected_id if selected_id is not None and len(drum_parts or []) > 1 else None
@router.websocket("/ws/highway/{filename:path}")
async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1, naming_mode: str = "legacy"):
"""Stream song data for the highway renderer over WebSocket."""
async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
naming_mode: str = "legacy", drum_part: str = ""):
"""Stream song data for the highway renderer over WebSocket.
`drum_part` selects WHICH drum part's tab streams when the pack carries
several (feedpak 1.17.0 "drums as arrangements") — a part id from
song_info's `drum_parts`. Empty / unknown ids fall back to the primary,
so a stale or mistyped selection degrades to today's behavior instead of
silencing drums."""
await websocket.accept()
structlog.contextvars.bind_contextvars(ws_conn_id=uuid.uuid4().hex[:8])
@@ -261,9 +274,8 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
bass_idxs = [
i
for i, a in enumerate(song.arrangements)
if getattr(a, "path_bass", False)
if arrangement_is_bass(a)
or (smart_names[i] or "").lower().startswith("bass")
or "bass" in (getattr(a, "name", "") or "").lower()
]
if bass_idxs:
# Among the bass parts: (1) honor the saved default-arrangement
@@ -368,7 +380,9 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
q_fn = quote(filename, safe="")
for s in loaded_slop.stems:
url = f"/api/sloppak/{q_fn}/file/{quote(s['file'])}"
stems_payload.append({"id": s["id"], "url": url, "default": s["default"]})
stems_payload.append(
{"id": s["id"], "url": url, "default": s["default"],
**{k: s[k] for k in ("name", "description") if k in s}})
# Full-mix URL (served by the same /api/sloppak/.../file/ endpoint).
if loaded_slop is not None and loaded_slop.full_mix:
full_mix_url = (
@@ -562,6 +576,15 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
"has_drum_tab": bool(
is_slop and loaded_slop is not None and loaded_slop.drum_tab is not None
),
# The song's DRUM PARTS (feedpak 1.17.0 "drums as arrangements"),
# primary first — names only; the selected part's payload streams
# as the `drum_tab`/`drum_hits` messages below. Always a list
# (empty when the pack has no drums, and a single entry for a
# legacy one-drum pack), so a part picker can bind unconditionally.
"drum_parts": [
{"id": p["id"], "name": p["name"]}
for p in (loaded_slop.drum_parts or [])
] if is_slop and loaded_slop is not None else [],
"has_notation": bool(
is_slop
and loaded_slop is not None
@@ -585,18 +608,36 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# client-side drums plugin keeps a fallback decoder for them.
if is_slop and loaded_slop is not None and loaded_slop.drum_tab is not None:
dt = loaded_slop.drum_tab
# Multiple drum parts: `?drum_part=<id>` picks which part's tab
# streams; the default (and any unknown id) is the PRIMARY —
# exactly the pre-parts behavior, so legacy clients notice nothing.
_dt_part_id = None
if loaded_slop.drum_parts:
_dt_part_id = loaded_slop.drum_parts[0]["id"]
if drum_part:
for _p in loaded_slop.drum_parts:
if _p["id"] == drum_part:
dt = _p["drum_tab"]
_dt_part_id = _p["id"]
break
kit = drums_mod.normalise_kit(dt.get("kit"))
hits_wire = drums_mod.hits_to_wire(dt.get("hits") or [])
_dt_name = dt.get("name")
_dt_name = _dt_name if isinstance(_dt_name, str) and _dt_name else "Drums"
_dt_msg = {
"type": "drum_tab",
"version": int(dt.get("version", drums_mod.SCHEMA_VERSION)),
"name": _dt_name,
"kit": kit,
"total": len(hits_wire),
}
# Only multi-part packs identify a part on the wire. Legacy packs
# synthesize a one-item list internally but keep their old frame.
_wire_part_id = _drum_part_id_for_wire(loaded_slop.drum_parts, _dt_part_id)
if _wire_part_id is not None:
_dt_msg["part_id"] = _wire_part_id
try:
await websocket.send_json({
"type": "drum_tab",
"version": int(dt.get("version", drums_mod.SCHEMA_VERSION)),
"name": _dt_name,
"kit": kit,
"total": len(hits_wire),
})
await websocket.send_json(_dt_msg)
for i in range(0, len(hits_wire), 500):
await websocket.send_json({
"type": "drum_hits",
@@ -733,20 +774,29 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# (Arrangement.tones, populated by the converter), so read it straight
# off `arr` rather than walking for XML that doesn't exist.
if is_slop:
# `sloppak_tone_changes` builds the (base, sorted changes) pair
# from `Arrangement.tones`, skipping non-string names and
# non-finite/non-numeric times — unit-tested in test_tones.py.
# `sloppak_tone_changes` builds the (base, base_rig, sorted
# changes) triple from `Arrangement.tones`, skipping non-string
# names, non-finite/non-numeric times, and unusable rig ids —
# unit-tested in test_tones.py.
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
# arrangement has a base but no switches, and the highway should
# still be able to show the initial tone.
if tone_changes or base_name:
await websocket.send_json({
payload = {
"type": "tone_changes",
"base": base_name,
"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:
xml_paths = sorted(_xml_walk("*.xml"))
@@ -973,7 +1023,7 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
# base[string] + offset + capo + fret (matches the tuner / open-string
# labels). arrangement_string_count is O(notes), so compute once here.
_base = base_open_string_midis(
arrangement_string_count(arr), "bass" in (arr.name or "").lower())
arrangement_string_count(arr), arrangement_is_bass(arr))
_capo = int(getattr(arr, "capo", 0) or 0)
def _fill_scale_degree(wire: dict, n, t: float) -> None:
+140
View File
@@ -0,0 +1,140 @@
"""Session-sync relay WebSocket — /ws/sync/{session_id} (feedBack#1030).
A deliberately dumb fan-out room: a JSON text frame received from one client
is forwarded verbatim to every OTHER client connected to the same session id.
The server interprets nothing beyond the limits below message schemas are
owned entirely by consumers. First consumer: splitscreen's LAN follower mode
(feedBack-plugin-splitscreen#21), which relays playhead/playstate/song-change
frames from a host window to view-only followers on other LAN devices.
Design points (full spec in the issue):
- Rooms are created on first join and garbage-collected when the last socket
leaves. No history, no replay, no persistence a late joiner simply waits
for the next frame. Consumers that need state on join re-send it themselves
(splitscreen answers every follower ``hello`` with a fresh ``config``).
- That statelessness is what makes consumer crash-recovery work: a host that
relaunches and rejoins the same session id resumes publishing to its
reconnecting subscribers with no server-side coordination, and an idle room
is indistinguishable from a nonexistent one.
- ``session_id`` is client-generated and opaque (``[A-Za-z0-9_-]{4,64}``);
consumers pick their own id policy (splitscreen uses a short typeable,
persistent room key).
- DoS hygiene for a port that may be LAN-exposed: frame-size cap, per-room and
total-room caps, and a per-socket inbound token-bucket rate cap. Over-limit
sockets are closed with a policy code; the room carries on. A peer that dies
mid-fan-out is dropped without wedging delivery to the rest.
"""
import asyncio
import logging
import re
import time
from fastapi import APIRouter, WebSocket
log = logging.getLogger("feedBack.server")
router = APIRouter()
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_-]{4,64}$")
# Limits. Sized generously above the first consumer's needs (splitscreen
# publishes time frames at ~15-20 Hz to a handful of viewers) while bounding
# what an open LAN port can be made to do. All module-level so tests (and a
# desperate operator) can override them.
MAX_FRAME_BYTES = 16 * 1024
MAX_CLIENTS_PER_ROOM = 16
MAX_ROOMS = 32
RATE_MSGS_PER_SEC = 120.0 # sustained inbound frames per socket
RATE_BURST = 240.0 # token-bucket burst headroom
# A peer that stops draining its socket would leave send_text() pending
# forever — and since publishers await the fan-out gather, one stalled peer
# would stall every publisher's receive loop behind it. Bounding the send
# turns the stall into an eviction through the normal failed-send drop path.
SEND_TIMEOUT_SECONDS = 5.0
# RFC 6455 close codes.
_WS_UNSUPPORTED_DATA = 1003 # binary frame on a text-only relay
_WS_POLICY_VIOLATION = 1008 # invalid session id / rate cap exceeded
_WS_MSG_TOO_BIG = 1009
_WS_TRY_AGAIN_LATER = 1013 # room or server at capacity
# session_id → {socket: per-socket send lock}. The lock serializes concurrent
# fan-out sends to the same peer (two publishers relaying at once must not
# interleave writes on a third socket's transport).
_rooms: dict[str, dict[WebSocket, asyncio.Lock]] = {}
async def _send_locked(peer: WebSocket, lock: asyncio.Lock, text: str) -> None:
async with lock:
await asyncio.wait_for(peer.send_text(text), timeout=SEND_TIMEOUT_SECONDS)
@router.websocket("/ws/sync/{session_id}")
async def sync_ws(websocket: WebSocket, session_id: str):
"""Join the fan-out room *session_id*; relay every inbound text frame."""
await websocket.accept()
if not _SESSION_ID_RE.fullmatch(session_id):
await websocket.close(code=_WS_POLICY_VIOLATION, reason="invalid session id")
return
# Capacity checks and insertion run with no await between them, so
# concurrent joiners on the event loop can't race past the caps.
room = _rooms.get(session_id)
if room is None:
if len(_rooms) >= MAX_ROOMS:
await websocket.close(code=_WS_TRY_AGAIN_LATER, reason="too many active sessions")
return
room = _rooms[session_id] = {}
log.debug("ws_sync: room %s created", session_id)
elif len(room) >= MAX_CLIENTS_PER_ROOM:
await websocket.close(code=_WS_TRY_AGAIN_LATER, reason="session full")
return
room[websocket] = asyncio.Lock()
tokens = RATE_BURST
last_refill = time.monotonic()
try:
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
text = message.get("text")
if text is None:
await websocket.close(code=_WS_UNSUPPORTED_DATA, reason="text frames only")
break
if len(text.encode("utf-8", errors="ignore")) > MAX_FRAME_BYTES:
await websocket.close(code=_WS_MSG_TOO_BIG, reason="frame too large")
break
now = time.monotonic()
tokens = min(RATE_BURST, tokens + (now - last_refill) * RATE_MSGS_PER_SEC)
last_refill = now
tokens -= 1.0
if tokens < 0:
await websocket.close(code=_WS_POLICY_VIOLATION, reason="rate cap exceeded")
break
peers = [(ws, lock) for ws, lock in room.items() if ws is not websocket]
if not peers:
continue
results = await asyncio.gather(
*(_send_locked(ws, lock, text) for ws, lock in peers),
return_exceptions=True,
)
# A peer that failed mid-send is dropped from the room here; its
# own handler finishes cleanup (the finally below) when its
# receive loop observes the disconnect.
for (peer, _lock), result in zip(peers, results):
if isinstance(result, Exception):
room.pop(peer, None)
finally:
room.pop(websocket, None)
# Guard against deleting a NEW room another joiner created after this
# one emptied (only possible for a dict that is no longer ours).
if not room and _rooms.get(session_id) is room:
del _rooms[session_id]
log.debug("ws_sync: room %s closed", session_id)
+344 -85
View File
@@ -121,6 +121,41 @@ def partition_stems(stems: list[dict]) -> tuple[dict | None, list[dict]]:
return full, [s for s in stems if str(s.get("id", "")) != FULL_MIX_STEM_ID]
def _resolve_pack_path(source_dir: Path, rel: str, label: str) -> Path | None:
"""Resolve a manifest-relative path, contained inside the pack. None if not.
Every manifest key that names a file routes through here. A crafted manifest
must not read outside the sloppak directory via path traversal
(e.g. `../../etc`), and a symlink loop or permission error on `.resolve()`
must disable that one file rather than abort the whole load so both
failures are caught, and both are warnings rather than raises.
The two branches log differently on purpose: a `ValueError` means the path
resolved *outside* the pack (a crafted or broken manifest), an `OSError`
means it could not be resolved at all (symlink loop, permissions). Reading
"escapes source_dir" in the logs and reading "resolution failed" lead an
operator to very different places, so the distinction is worth two lines.
Returns the resolved path **existence is NOT checked here**. Callers
differ on that deliberately: a missing optional side-file is silent, while a
missing arrangement skips an entry, so each caller keeps its own `.exists()`
(or `.is_file()`) test and its own control flow.
`label` names the manifest key in the log message ("keys", "song_timeline",
a drum part's id, …).
"""
try:
p = (source_dir / rel).resolve()
p.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: %s path %r escapes source_dir — skipped", label, rel)
return None
except OSError as e:
log.warning("sloppak: %s path resolution failed (%s) — skipped", label, e)
return None
return p
def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
"""Full mix from the DEPRECATED `original_audio:` manifest key, or None.
@@ -152,16 +187,8 @@ def _legacy_full_mix(manifest: dict, source_dir: Path) -> str | None:
if not isinstance(rel_raw, str) or not rel_raw.strip():
return None
rel = rel_raw.strip()
try:
target = (source_dir / rel).resolve()
target.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: original_audio path %r escapes source_dir — skipped", rel)
return None
except OSError as e:
log.warning("sloppak: original_audio path resolution failed (%s) — skipped", e)
return None
if not target.is_file():
target = _resolve_pack_path(source_dir, rel, "original_audio")
if target is None or not target.is_file():
return None
log.info(
"sloppak: pack uses the deprecated `original_audio:` key (%r) — the full mix "
@@ -698,6 +725,14 @@ class LoadedSloppak:
# absent / unreadable / malformed. Streamed over the highway WS as a
# `keys` message; consumers (renderers, plugins) read it from there.
keys: dict | None = None
# Parsed `rigs.json` payload (manifest `rigs:` key, spec §7.9) — the pack's
# library of engine-agnostic signal chains: effect chains and, since
# feedpak 1.18.0, MIDI-voiced sound sources. Arrangements bind rigs to time
# by referencing a rig `id` from `tones.base_rig` / `tones.changes[].rig`
# (§6.9), which `lib/tones.py` carries onto the wire. None when absent /
# unreadable / malformed. Rig objects are kept verbatim — this loader does
# not select realizations or apply the `intent.gm` floor.
rigs: dict | None = None
# Sanitized song-level tempo + time-signature maps from `song_timeline.json`
# (feedpak 1.2.0). `tempos`: [{time, bpm}]; `time_signatures`: [{time, ts}].
# None when absent/empty. Streamed over the highway WS (`tempos` /
@@ -730,6 +765,227 @@ class LoadedSloppak:
# separated stems the moment one drops below 100% — demucs recombination is
# lossy, so the mixdown is strictly the better audio when nothing is muted.
full_mix: str | None = None
# The song's DRUM PARTS (feedpak 1.17.0 "drums as arrangements"): one dict
# {"id", "name", "drum_tab"} per part, primary FIRST. A part comes from a
# `type: drums` arrangement entry carrying a per-arrangement `drum_tab`
# file pointer and NO note `file` — entries this loader deliberately never
# turns into fretted Arrangements (see the file/notation gate in
# load_song; that skip IS the grading invariant). The primary part's
# payload is the SAME object as `drum_tab` above (the song-level key is
# its back-compat alias). None when the pack has no drums at all; a
# single-part list for a legacy pack with only the song-level key.
drum_parts: list[dict] | None = None
def _load_drum_tab_file(source_dir: Path, rel: str, label: str) -> dict | None:
"""Load + schema-validate one drum-tab JSON named by a manifest-relative
path. Shared by the song-level `drum_tab:` key and the per-arrangement
drum-part pointers (feedpak 1.17.0), so every tab gets the same posture:
permissive a missing file disables that part silently; a traversal,
parse, or validation failure disables it with a warning, never aborting
the load."""
dt_path = _resolve_pack_path(source_dir, rel, label)
if dt_path is None or not dt_path.exists():
return None
try:
raw = load_json(dt_path)
except Exception as e:
log.warning("sloppak: failed to parse %s %r: %s", label, rel, e)
return None
ok, reason = drums_mod.validate_drum_tab(raw)
if not ok:
log.warning("sloppak: %s %r failed validation: %s", label, rel, reason)
return None
return raw
def _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(
source_dir: Path,
drum_tab_rel: object,
drum_tab_data: dict | None,
drum_pointer_entries: list[dict],
drum_tones: dict | None = None,
) -> tuple[dict | None, list[dict] | None]:
"""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:
return drum_tab_data, None
primary_id = "drums"
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] = []
seen_rels: set[str] = set()
# Use the same canonical, traversal-safe identity as zip member lookup so
# equivalent spellings ("x.json", "./x.json", or backslashes) identify
# one file. Otherwise an alias pointer can reload and duplicate the primary.
primary_rel_key = (
_zip_member_key(drum_tab_rel.strip())
if isinstance(drum_tab_rel, str) and drum_tab_rel.strip() else None
)
for entry in drum_pointer_entries:
rel = str(entry.get("drum_tab") or "").strip()
rel_key = _zip_member_key(rel) if rel else None
rel_identity = rel_key or rel
if not rel or rel_identity in seen_rels:
continue
seen_rels.add(rel_identity)
entry_id = str(entry.get("id") or "").strip()
entry_name = str(entry.get("name") or "").strip()
if primary_rel_key is not None and rel_key == primary_rel_key:
if entry_id:
primary_id = entry_id
if entry_name:
primary_name = entry_name
# 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
tab = _load_drum_tab_file(source_dir, rel, f"drum part {entry_id or rel}")
if tab is None:
continue
tab_name = tab.get("name")
extra_parts.append({
"id": entry_id,
"name": entry_name
or (tab_name if isinstance(tab_name, str) and tab_name else "Drums"),
"drum_tab": tab,
# 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] = []
used_ids: set[str] = set()
if drum_tab_data is not None:
if primary_name is None:
tab_name = drum_tab_data.get("name")
primary_name = tab_name if isinstance(tab_name, str) and tab_name else "Drums"
parts.append({
"id": primary_id,
"name": primary_name,
"drum_tab": drum_tab_data,
# 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)
next_generated_id = 2
for part in extra_parts:
part_id = part["id"]
if not part_id or part_id in used_ids:
while f"drums-{next_generated_id}" in used_ids:
next_generated_id += 1
part_id = f"drums-{next_generated_id}"
next_generated_id += 1
part["id"] = part_id
used_ids.add(part_id)
parts.append(part)
if not parts:
return drum_tab_data, None
if drum_tab_data is None:
drum_tab_data = parts[0]["drum_tab"]
return drum_tab_data, parts
def load_song(
@@ -754,6 +1010,7 @@ def load_song(
notation_acc: dict[str, dict] = {}
any_notation = False
arrangement_ids_acc: list[str | None] = [] # parallel to song.arrangements
drum_pointer_entries: list[dict] = [] # feedpak 1.17.0 drum-part pointers
for entry in manifest.get("arrangements", []) or []:
if not isinstance(entry, dict):
log.warning("sloppak: non-dict arrangement entry skipped (%r)", type(entry).__name__)
@@ -762,20 +1019,35 @@ def load_song(
rel = rel_raw.strip() if isinstance(rel_raw, str) else ""
notation_raw = entry.get("notation")
has_notation_key = isinstance(notation_raw, str) and bool(notation_raw.strip())
if not rel and not has_notation_key:
_etype = str(entry.get("type") or "").strip().lower()
is_drums = _etype in ("drums", "drum")
# A drums-typed entry MUST NEVER become a fretted Arrangement (grading
# invariant, spec §5.2/§7.5): route on `type` FIRST, not on file
# absence — a malformed drums entry that also carries a note file/
# notation would otherwise fall through and grade as garbage.
if is_drums or (not rel and not has_notation_key):
# A DRUM-PART POINTER entry (feedpak 1.17.0 "drums as
# arrangements"): `type: drums` with a per-arrangement `drum_tab`
# file. Collect it for the drum-parts load after this loop.
if is_drums and isinstance(entry.get("drum_tab"), str):
drum_pointer_entries.append(entry)
elif is_drums:
# Drums-typed but no drum_tab pointer — drop it (any note
# file/notation it carries is ignored), never fret it.
log.warning(
"sloppak: drums-typed arrangement entry %r has no drum_tab pointer — dropped",
entry.get("id"),
)
elif isinstance(entry.get("drum_tab"), str):
log.warning(
"sloppak: arrangement entry has drum_tab %r but type=%r — ignored",
entry.get("drum_tab"), entry.get("type"),
)
continue
data = None
if rel:
try:
arr_path = (source_dir / rel).resolve()
arr_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: arrangement path %r escapes source_dir — skipped", rel)
continue
except OSError as e:
log.warning("sloppak: arrangement path resolution failed (%s) — skipped", e)
continue
if not arr_path.exists():
arr_path = _resolve_pack_path(source_dir, rel, "arrangement")
if arr_path is None or not arr_path.exists():
continue
try:
data = load_json(arr_path)
@@ -792,6 +1064,11 @@ def load_song(
# the arrangement JSON (name, tuning, capo, centOffset).
if entry.get("name"):
arr.name = str(entry["name"])
# Editor-authored instrument type (feedpak-spec §5.2 / editor PR #335).
# Drives arrangement_string_count's bass fallback so a bass authored on
# an arrangement whose NAME doesn't say "bass" still reports 4 strings.
if entry.get("type"):
arr.type = str(entry["type"]).strip().lower()
if "tuning" in entry:
arr.tuning = list(entry["tuning"])
if "capo" in entry:
@@ -800,6 +1077,14 @@ def load_song(
# _finite_float keeps a malformed manifest NaN/Infinity from
# poisoning the song_info JSON (same guard as the wire path).
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.
# If the manifest-level arrangement JSON carries them, pull them onto
@@ -832,15 +1117,7 @@ def load_song(
notation_rel = notation_rel.strip()
if not notation_rel:
continue
try:
nt_path = (source_dir / notation_rel).resolve()
nt_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: notation path %r escapes source_dir — skipped", notation_rel)
nt_path = None
except OSError as e:
log.warning("sloppak: notation path resolution failed (%s) — skipped", e)
nt_path = None
nt_path = _resolve_pack_path(source_dir, notation_rel, "notation")
raw_nt = None
if nt_path is not None and nt_path.exists():
try:
@@ -868,32 +1145,20 @@ def load_song(
drum_tab_data: dict | None = None
drum_tab_rel = manifest.get("drum_tab")
if isinstance(drum_tab_rel, str) and drum_tab_rel:
# Constrain to source_dir to prevent a crafted manifest from reading
# files outside the sloppak directory via path traversal (e.g. ../../etc).
# Wrap both resolve() calls in a broad handler: symlink loops and
# permission errors on .resolve() should disable drums, not abort load.
try:
dt_path = (source_dir / drum_tab_rel).resolve()
dt_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: drum_tab path %r escapes source_dir — skipped", drum_tab_rel)
dt_path = None
except OSError as e:
log.warning("sloppak: drum_tab path resolution failed (%s) — skipped", e)
dt_path = None
if dt_path is not None and dt_path.exists():
try:
raw = load_json(dt_path)
except Exception as e:
log.warning("sloppak: failed to parse drum_tab %r: %s", drum_tab_rel, e)
raw = None
if raw is not None:
ok, reason = drums_mod.validate_drum_tab(raw)
if ok:
drum_tab_data = raw
else:
log.warning("sloppak: drum_tab %r failed validation: %s",
drum_tab_rel, reason)
drum_tab_data = _load_drum_tab_file(source_dir, drum_tab_rel, "drum_tab")
# Keep the dense compatibility logic independently testable and guarantee
# ids are unique before the highway exposes them as selectors.
# 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(
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_tab but no pitched arrangements. The highway WS rejects an empty
@@ -932,15 +1197,7 @@ def load_song(
time_sigs_data: list | None = None
song_timeline_rel = manifest.get("song_timeline")
if isinstance(song_timeline_rel, str) and song_timeline_rel:
try:
st_path = (source_dir / song_timeline_rel).resolve()
st_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: song_timeline path %r escapes source_dir — skipped", song_timeline_rel)
st_path = None
except OSError as e:
log.warning("sloppak: song_timeline path resolution failed (%s) — skipped", e)
st_path = None
st_path = _resolve_pack_path(source_dir, song_timeline_rel, "song_timeline")
if st_path is not None and st_path.exists():
try:
raw = load_json(st_path)
@@ -1030,15 +1287,7 @@ def load_song(
# downstream through the WS path.
lyrics_rel = manifest.get("lyrics")
if isinstance(lyrics_rel, str) and lyrics_rel:
try:
lyr_path = (source_dir / lyrics_rel).resolve()
lyr_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: lyrics path %r escapes source_dir — skipped", lyrics_rel)
lyr_path = None
except OSError as e:
log.warning("sloppak: lyrics path resolution failed (%s) — skipped", e)
lyr_path = None
lyr_path = _resolve_pack_path(source_dir, lyrics_rel, "lyrics")
if lyr_path is not None and lyr_path.exists():
try:
raw = load_json(lyr_path)
@@ -1114,11 +1363,19 @@ def load_song(
sfile = str(s.get("file", ""))
if not sid or not sfile:
continue
stems.append({
entry = {
"id": sid,
"file": sfile,
"default": stem_default_on(s.get("default", True)),
})
}
# Optional presentational fields (feedpak 1.16.0, spec §5.3). Omitted —
# not None — when absent, so payload builders can pass entries through
# without every stem growing null keys.
for key in ("name", "description"):
val = s.get(key)
if isinstance(val, str) and val.strip():
entry[key] = val
stems.append(entry)
# The complete mixdown is a stem (spec §5.3), but it is not a *layer*: lift
# it out so that no consumer of `stems` — the mixer, the library's stem
@@ -1135,15 +1392,7 @@ def load_song(
keys_data: dict | None = None
keys_rel = manifest.get("keys")
if isinstance(keys_rel, str) and keys_rel:
try:
k_path = (source_dir / keys_rel).resolve()
k_path.relative_to(source_dir.resolve())
except ValueError:
log.warning("sloppak: keys path %r escapes source_dir — skipped", keys_rel)
k_path = None
except OSError as e:
log.warning("sloppak: keys path resolution failed (%s) — skipped", e)
k_path = None
k_path = _resolve_pack_path(source_dir, keys_rel, "keys")
if k_path is not None and k_path.exists():
try:
raw = load_json(k_path)
@@ -1188,6 +1437,14 @@ def load_song(
"events": clean_events,
}
# Optional rigs.json — the pack's rig library (manifest `rigs:` key,
# spec §7.9). Loaded here so the highway WS can hand it to whatever voices
# the part; the bindings that reference it ride the arrangement's `tones`.
rigs_data: dict | None = None
rigs_rel = manifest.get("rigs")
if isinstance(rigs_rel, str) and rigs_rel:
rigs_data = _load_rigs_file(source_dir, rigs_rel)
_fpv = manifest.get("feedpak_version")
# The pack's full mix. Normally the RESERVED `full` stem partitioned out
# above (spec §5.3) — no path work needed, it was validated with the other
@@ -1213,10 +1470,12 @@ def load_song(
manifest=manifest,
feedpak_version=_fpv if isinstance(_fpv, str) and _fpv else None,
drum_tab=drum_tab_data,
drum_parts=drum_parts,
song_timeline=song_timeline_data,
tempos=tempos_data,
time_signatures=time_sigs_data,
keys=keys_data,
rigs=rigs_data,
notation_by_id=notation_by_id_data,
arrangement_ids=arrangement_ids_acc,
full_mix=full_mix_data,
+58 -7
View File
@@ -56,6 +56,13 @@ class Note:
strum_group: int = -1
scale_degree: int = -1
ignore: bool = False
# Keys hand assignment ('lh'/'rh', None = unassigned) — authored per-note,
# e.g. from a MusicXML grand staff import in the editor. Lets the notation
# hand split and hands-separate practice honor the author instead of the
# mean-pitch heuristic. Distinct from `right_hand` (the bass plucking
# finger); spelled-out `hand` on the wire because `rh` is taken.
# Default-omitted on the wire; older readers ignore it.
hand: str | None = None
@dataclass
@@ -175,6 +182,12 @@ class Arrangement:
# `base`/`changes` drive the highway tone-change markers; `definitions`
# feed the Tones plugin gear panel.
tones: dict | None = None
# Editor-authored instrument type (feedpak-spec §5.2 / editor PR #335):
# "bass" | "guitar" | "piano" | "keys" | "drums" | "". First-class DATA that
# lets a user author an instrument on an arrangement whose NAME doesn't say
# so. Lifted from the sloppak manifest entry by sloppak.load_song(); "" for
# archive/loose sources, which instead carry the path_* flags below.
type: str = ""
# arrangement XML <arrangementProperties> flags for smart naming (feedBack feat/arrangement).
# Populated from the XML; default False/0 for sloppak / GP-imported sources.
path_lead: bool = False
@@ -272,6 +285,10 @@ def note_to_wire(n: Note) -> dict:
out["ch"] = n.strum_group
if n.scale_degree != -1:
out["sd"] = n.scale_degree
# Keys hand assignment — default-omitted; validated on emit so a
# directly-constructed Note can't put junk ('LH', True, …) on the wire.
if n.hand in ("lh", "rh"):
out["hand"] = n.hand
return out
@@ -492,8 +509,8 @@ def note_pitch_midi(arr: "Arrangement", note: "Note") -> int | None:
O(notes) via ``arrangement_string_count`` for a whole arrangement, hoist
the base with :func:`base_open_string_midis` and call :func:`pitch_from_base`
per note instead."""
is_bass = "bass" in (arr.name or "").lower()
base = base_open_string_midis(arrangement_string_count(arr), is_bass)
base = base_open_string_midis(arrangement_string_count(arr),
arrangement_is_bass(arr))
return pitch_from_base(base, int(getattr(arr, "capo", 0) or 0),
arr.tuning or [], note.string, note.fret)
@@ -532,6 +549,10 @@ def note_from_wire(d: dict, time: float | None = None) -> Note:
strum_group=_wire_int_optional(d.get("ch"), -1),
scale_degree=_wire_int_optional(d.get("sd"), -1),
ignore=bool(d.get("ig", False)),
# Keys hand assignment — strict enum decode: anything but 'lh'/'rh'
# (junk, wrong case, bools) falls back to unassigned rather than
# poisoning downstream hand-split/practice logic.
hand=d.get("hand") if d.get("hand") in ("lh", "rh") else None,
)
@@ -618,6 +639,23 @@ def phrase_from_wire(d: dict) -> Phrase:
)
def arrangement_is_bass(arr: Arrangement) -> bool:
"""Whether ``arr`` is a bass, most-authoritative signal first: an
editor-authored ``type == "bass"`` (feedpak-spec §5.2 / editor PR #335,
lifted onto sloppaks by :func:`sloppak.load_song`), then the archive
``path_bass`` <arrangementProperties> flag, then the legacy "bass"
case-insensitive substring in the name. Single source of the bass decision
so string-count derivation and the open-string pitch base (via
:func:`base_open_string_midis`) agree a bass authored on an arrangement
whose NAME doesn't say "bass" must get both 4 lanes AND the bass MIDI base,
not 4 lanes on a guitar octave."""
return (
(arr.type or "").strip().lower() == "bass"
or bool(arr.path_bass)
or "bass" in (arr.name or "").lower()
)
def arrangement_string_count(arr: Arrangement) -> int:
"""Derive the active arrangement's string count.
@@ -635,10 +673,17 @@ def arrangement_string_count(arr: Arrangement) -> int:
But this is a LOWER BOUND only a 6-string lead chart that
never plays string 5 reports 5, undercounting by 1.
2. **Name-based fallback.** Arrangements named "Bass" (case-
insensitive substring match) default to 4; everything else
defaults to 6. This catches the partial-string-usage case
where notes don't span all the instrument's strings.
2. **Instrument-type fallback.** An arrangement whose authoritative
instrument signal says bass defaults to 4; everything else
defaults to 6. This catches the partial-string-usage case where
notes don't span all the instrument's strings. The bass signal is
any of: an editor-authored ``type == "bass"`` (feedpak-spec §5.2 /
editor PR #335 — lifted onto sloppaks by ``sloppak.load_song``),
the ``path_bass`` <arrangementProperties> flag (archive/DLC
sources), or the legacy "bass" case-insensitive substring in the
name. Trusting ``type``/``path_bass`` closes the gap where a user
authors a bass instrument on an arrangement whose NAME doesn't say
"bass" (the editor lays out 4 lanes; core must agree).
A third signal ``len(arr.tuning)`` when it isn't the arrangement XML
padded value of 6 folds in for sloppak / GP-imported sources
@@ -669,6 +714,10 @@ def arrangement_string_count(arr: Arrangement) -> int:
max(0, 4, 0) = 4
* Empty arrangement named "Lead" (tuning len 6)
max(0, 6, 0) = 6
* Editor-authored bass named "Low End" (type "bass", tuning len 6,
notes 0..3) name_based=4 max(4, 4, 0) = 4
* DLC bass named "Low End" (pathBass flag set, tuning len 6, notes
0..3) name_based=4 max(4, 4, 0) = 4
Topkoa's issue argues plugins shouldn't do arrangement-name
matching; server-side fallback IS the right place for it
@@ -684,7 +733,9 @@ def arrangement_string_count(arr: Arrangement) -> int:
if cn.string > max_s:
max_s = cn.string
notes_count = max_s + 1 if max_s >= 0 else 0
name_based = 4 if "bass" in arr.name.lower() else 6
# Bass signal (type / pathBass / name substring) — see arrangement_is_bass.
# Any one being bass pulls the fallback to 4.
name_based = 4 if arrangement_is_bass(arr) else 6
# Tuning-length signal — only trustworthy when NOT the arrangement XML
# padded value of 6. Length 4/5 indicates explicit bass / 5-string
# bass; length 7/8 indicates an extended-range guitar from GP.
+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}
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.
Given ``Arrangement.tones`` (the dict embedded in the sloppak, or ``None``),
returns ``(base, changes)`` where ``base`` is the initial tone name and
``changes`` is a time-sorted ``[{"t", "name"}]`` list. Non-string names,
non-dict entries, and 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).
returns ``(base, base_rig, changes)`` where ``base`` is the initial tone
name, ``base_rig`` is the ``rigs.json`` rig id bound to it (feedpak-spec
§6.9; ``""`` when absent), and ``changes`` is a time-sorted
``[{"t", "name", "rig"?}]`` list. Non-string names, non-dict entries, and
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):
return "", []
return "", "", []
base_val = arr_tones.get("base", "")
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] = []
raw_changes = arr_tones.get("changes")
@@ -65,6 +74,13 @@ def sloppak_tone_changes(arr_tones) -> tuple[str, list[dict]]:
continue
if not math.isfinite(t):
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"])
return base, changes
return base, base_rig, changes
+7
View File
@@ -44,6 +44,13 @@ def run() -> None:
# record — including early startup messages — passes through the same
# structured pipeline.
log_config=None,
# Cap inbound WebSocket frames at the transport, before uvicorn
# materializes them in memory (its default is 16 MB). No client sends
# large frames to this server: the highway WS receives only small
# control messages, and the /ws/sync relay enforces its own tighter
# 16 KB application cap (routers/ws_sync.py MAX_FRAME_BYTES) — this is
# the defense-in-depth bound above it.
ws_max_size=64 * 1024,
)
+964 -1
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -14,6 +14,7 @@
"devDependencies": {
"@playwright/test": "^1.59.1",
"eslint": "^9.39.4",
"eslint-plugin-import-x": "^4.17.1"
"eslint-plugin-import-x": "^4.17.1",
"tailwindcss": "^3.4.19"
}
}
+3 -2
View File
@@ -25,8 +25,8 @@
Object.freeze({
id: 'player-audio',
label: 'Player and Audio Runtime',
summary: 'Playback, renderer, mixer, monitoring, effects, and note-detection surfaces.',
domains: Object.freeze(['playback', 'visualization', 'audio-mix', 'audio-input', 'audio-monitoring', 'audio-effects', 'stems', 'note-detection']),
summary: 'Playback, renderer, chart-transform, mixer, monitoring, effects, and note-detection surfaces.',
domains: Object.freeze(['playback', 'visualization', 'chart-transform', 'audio-mix', 'audio-input', 'audio-monitoring', 'audio-effects', 'stems', 'note-detection']),
}),
Object.freeze({
id: 'plugin-defined',
@@ -50,6 +50,7 @@
'audio-monitoring': 'headphones',
stems: 'sliders',
'note-detection': 'activity',
'chart-transform': 'box',
diagnostics: 'fileSearch',
pipeline: 'activity',
'ui.navigation': 'list',
+9 -2
View File
@@ -104,6 +104,13 @@ def _bundled(venue_id):
return (_bundled_venue_dir(venue_id) / "manifest.json").is_file()
def _pack_published(pack):
"""A remote pack is downloadable only once a publish has stamped its real
size the committed manifest carries a 0-byte placeholder (and an all-zero
sha) until then, so don't offer a download that can't succeed yet."""
return bool(pack and (pack.get("bytes") or 0) > 0)
def _stars():
"""(total, per-song dict, detail rows). Accuracy is a 0..1 fraction."""
db = _state["meta_db"]
@@ -664,7 +671,7 @@ def setup(app, context):
"unlocked": stars_total >= v["star_threshold"],
"installed": _installed(v["id"]),
"bundled": _bundled(v["id"]),
"has_pack": _bundled(v["id"]) or bool(v.get("pack")),
"has_pack": _bundled(v["id"]) or _pack_published(v.get("pack")),
"download": dl,
})
return {
@@ -934,7 +941,7 @@ def setup(app, context):
if venue is None:
raise HTTPException(404, "Unknown venue.")
pack = venue.get("pack")
if not pack:
if not _pack_published(pack):
raise HTTPException(404, "No pack published for this venue yet.")
stars_total, _, _ = _stars()
if stars_total < venue["star_threshold"]:
+10 -2
View File
@@ -17,14 +17,22 @@
"name": "Velvet Room",
"description": "A proper club stage. People actually came to hear you.",
"star_threshold": 50,
"pack": null
"pack": {
"url": "https://github.com/got-feedBack/feedBack/releases/download/venue-club-v1/club-pack-v1.zip",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"bytes": 0
}
},
{
"id": "arena",
"name": "Feedback Arena",
"description": "Ten thousand seats. Try not to think about it.",
"star_threshold": 150,
"pack": null
"pack": {
"url": "https://github.com/got-feedBack/feedBack/releases/download/venue-arena-v1/arena-pack-v1.zip",
"sha256": "8898c7912c84123559ecfd8b0afd3be19da9a0a4b04aa9dbb5aa1e7f4e3e1669",
"bytes": 351284599
}
}
]
}
+1 -1
View File
@@ -155,7 +155,7 @@ Every per-frame renderer call receives a `bundle` from feedBack core. Fields use
- `lefty` — display flag consumed by this renderer from `bundle.lefty`. Captured into `_leftyCached` before each frame so `xFret()`, `xFretMid()`, `boardSpanX()`, board geometry, note placement, and the camera shoulder offset mirror the fret axis for left-handed mode. A runtime lefty flip rebuilds board state and mirrors `curX`/`tgtX` plus the lookahead camera X cache so the camera does not drift across the neck.
- `getNoteState(note, chartTime)` — feedBack#254; per-note judgment from a scorer (note_detect). Captured each frame into `_ndGetNoteState` at the top of `update()` and consulted in `drawNote()` AFTER the event-driven `_ndHitMarks`/`_ndMissMarks` lookup AND over the proximity-based `hit` heuristic, both of which it overrides when it has a verdict: `'hit'`/`'active'``mGlow[s]` outline (bright string-tinted, *not* green) + `mGlow[s]` body + `mGlow[s]` sustain trail + a queue entry for `drawNotedetectSizzle` (so a held sustain keeps glowing/sparkling as long as the provider keeps returning `'active'`); `'miss'``mMissOutline` and `_showHit = false` (suppresses the bright body even if the note is near the line). Called with the note's chart time (`n.t`), which is how note_detect keys its `noteResults` map — *not* `now`. Returns null on cores without the API or songs with no scorer — then the event path / `hit` heuristic drive feedback for older note_detect builds. **notedetect ≥1.13 object verdicts additionally carry `{ points, mult, popKey }`** (game-scoring layer): `points` is the note's awarded score, `mult` the multiplier tier it landed at, and `popKey` a dedup key — chord members all return the chord-level judgment's key so a chord pops once, not once per gem. Consumed by the score-pop spawn in `drawNote()` (see Score FX below); all three are absent on older notedetect builds, so guard with `!== undefined`.
`tuning` and `capo` aren't consumed by this plugin.
`tuning` and `capo` feed only the nut's open-string pitch labels. They prefer the bundle's effective values; `songInfo` remains the original metadata fallback. Note placement never reads them.
Core reuses the bundle OBJECT across frames (mutated in place); never cache it or compare its identity between frames — field values are only valid for the current draw call. Field-identity caches on ARRAY fields (this plugin's `_mergeCacheChordsRef === bundle.chords` etc.) remain valid: arrays still swap reference when chart data changes. Core also exposes `bundle.lowerBoundT(arr, time)` (lower-bound on `.t`, notes/chords) and `bundle.lowerBoundTime(arr, time)` (on `.time`, beats/anchors/sections) — prefer these over the local `lowerBoundT` helper when a downlevel-host fallback isn't needed.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "highway_3d",
"name": "3D Highway",
"version": "3.32.0",
"version": "3.34.1",
"type": "visualization",
"bundled": true,
"script": "screen.js",
+688 -123
View File
@@ -858,9 +858,13 @@
*/
function _openStringPitchLabelsForTuning(bundle, songInfo, nEffective) {
const n = Number.isFinite(nEffective) ? Math.min(Math.max(1, Math.trunc(nEffective)), MAX_RENDER_STRINGS) : resolveStringCount(bundle);
let tuning = (songInfo && songInfo.tuning) || bundle.tuning;
let cap = songInfo && songInfo.capo;
cap = Number.isFinite(cap) ? cap : (Number.isFinite(bundle.capo) ? bundle.capo : 0);
// bundle first: chart-transform substitutes tuning/capo there, while
// songInfo keeps the chart's originals by contract. A malformed
// (non-array) bundle.tuning falls back to songInfo instead of
// blanking the labels.
let tuning = Array.isArray(bundle.tuning) ? bundle.tuning : (songInfo && songInfo.tuning);
let cap = bundle.capo;
cap = Number.isFinite(cap) ? cap : (songInfo && Number.isFinite(songInfo.capo) ? songInfo.capo : 0);
if (!Array.isArray(tuning)) tuning = [];
const base = _baseOpenStringMidis(n, songInfo?.arrangement);
@@ -1304,6 +1308,56 @@
return lo;
}
/**
* Return the chart time of the first fretted event that can still affect
* the camera at `now`, or the next fretted onset after it.
*
* This is intentionally a one-time full-chart scan. It runs only when a
* new song/arrangement's arrays first arrive, allowing the camera to frame
* the opening phrase during a silent intro instead of waiting for that
* phrase to enter the live targeting window. Open strings do not define a
* horizontal fret target, and malformed/out-of-range strings are ignored.
*
* Events already inside the behind-window, plus older sustains that are
* still ringing at `now`, return `now` so bootstrap framing matches the
* ordinary live path. Future events return their onset time.
*/
function hwyFirstRelevantFrettedTime(notes, chords, now, behind, stringCount) {
const nStrings = Number.isFinite(stringCount) ? Math.max(0, Math.floor(stringCount)) : 0;
const cameraFloor = now - Math.max(0, Number(behind) || 0);
let first = Infinity;
const validFretted = n => n
&& n.f > 0
&& Number.isInteger(n.s)
&& n.s >= 0
&& n.s < nStrings;
const consider = (eventTime, sustain) => {
const t = Number(eventTime);
if (!Number.isFinite(t)) return;
const sus = Number(sustain);
const end = t + (Number.isFinite(sus) && sus > 0 ? sus : 0);
if (t < cameraFloor && end < now) return;
const relevantTime = t <= now ? now : t;
if (relevantTime < first) first = relevantTime;
};
if (notes) {
for (const n of notes) {
if (validFretted(n)) consider(n.t, n.sus);
}
}
if (chords) {
for (const ch of chords) {
if (!ch || !ch.notes) continue;
for (const cn of ch.notes) {
if (validFretted(cn)) consider(ch.t, cn.sus);
}
}
}
return Number.isFinite(first) ? first : null;
}
// Last arrangement <anchor> at or before chart time `t` (sorted by .time).
// Mirrors static/highway.js getAnchorAt — until t reaches the first anchors
// time, the first anchor still defines fret/width.
@@ -2732,6 +2786,21 @@
if (globalVal !== null && globalVal !== undefined) return _bgCoerce(key, globalVal);
return BG_DEFAULTS[key];
}
// Read a setting's GLOBAL value, ignoring any per-panel override. The
// player-chrome control is a single shared instance, so it must always
// read (and write) the global slot. Passing null as a panelKey to
// _bgReadSetting happened to work only because 'h3d_bg_null_<key>' never
// exists; this states the intent directly and can't be shadowed if a
// panelKey of null is ever used deliberately. Mirrors the global half of
// _bgReadSetting exactly (mem-fallback precedence, then persisted, then
// default).
function _bgReadGlobal(key) {
let globalVal = null;
try { globalVal = localStorage.getItem('h3d_bg_' + key); } catch (_) { /* storage blocked */ }
if (key in _bgMemFallback) return _bgCoerce(key, _bgMemFallback[key]);
if (globalVal !== null && globalVal !== undefined) return _bgCoerce(key, globalVal);
return BG_DEFAULTS[key];
}
// Shared "stored string -> bool" coercion for every boolean
// setting. Mirrors settings.html's coerceBool so the renderer and
// the UI hydration always agree on what a corrupted/unknown value
@@ -3957,12 +4026,436 @@
let _nextInstanceId = 0;
/* ======================================================================
* Player-chrome background control
* ======================================================================
* A Background picker mounted into the player's Plugins rail popover, so
* the background can be switched MID-SONG without leaving for Settings.
*
* It writes through the SAME global setters settings.html uses
* (h3dBgSetStyle / SetReactive / SetIntensity), so the existing pub-sub
* rebuilds the mounted style live and both UIs stay agreed. Nothing extra
* is persisted here, and the option list is generated from BG_STYLE_IDS
* add a style there and it shows up in both places automatically.
*
* MOUNTED ONCE, REFCOUNTED. Under splitscreen there are N renderer
* instances but these settings are global a panel may set a per-panel
* override, but this single shared control only ever reads/writes the
* global slot (via _bgReadGlobal), so N copies would be N ways to set
* one value. init() acquires, destroy() releases,
* and the last release unmounts so the control disappears when the user
* switches to a non-3D renderer instead of lingering as a dead knob.
*
* Everything here is event-driven. No DOM work on a per-frame path.
*/
// Wording is kept verbatim in sync with settings.html's <option> text so
// the same style is not named two different things in two UIs that sit
// two clicks apart. An id with no entry here falls back to the raw id.
const _PC_LABELS = {
off: 'Off', particles: 'Particles (drifting)',
silhouettes: 'Silhouettes (parallax)', lights: 'Lights (stage glows)',
geometric: 'Geometric (rotating shapes)',
butterchurn: 'Butterchurn (visualizer)',
image: 'Custom image', video: 'Custom video',
};
// Which settings each background style actually consumes, so a control
// that would do nothing is greyed out instead of lying.
//
// Derived by reading the BG_STYLES bodies: a style uses `intensity` if its
// build() reads settings.intensity, and uses `reactive` if its update()
// dereferences the `bands` argument. 'butterchurn' is a mode, not a
// BG_STYLES fog-scenery entry: _bcSyncMode owns its controller, which
// drives its own audio tap and canvas opacity (only the fog-scenery half
// falls through to BG_STYLES.off). So neither knob here reaches it - both
// are false, and the tooltip points at Butterchurn's own controls.
//
// KEEP IN STEP WITH BG_STYLES. If a style starts reading bands or intensity
// and its row is not updated, the control stays greyed out and lies the
// other way. An id missing from this table defaults to both-enabled, which
// is the safe direction: a new style is assumed to use its settings.
const _PC_USES = {
off: { intensity: false, reactive: false, why: 'No background to adjust' },
particles: { intensity: true, reactive: true },
silhouettes: { intensity: true, reactive: true },
lights: { intensity: true, reactive: true },
geometric: { intensity: true, reactive: true },
image: { intensity: true, reactive: false, why: 'This background does not react to audio' },
video: { intensity: false, reactive: false, why: 'The video plays as-is - nothing to adjust here' },
butterchurn: { intensity: false, reactive: false, why: 'Butterchurn reacts to audio itself - tune it in Settings > 3D Highway, or its Visualizer panel' },
// Not in BG_STYLE_IDS, so it never appears in the dropdown - reached
// only via the viz-picker Venue flow (h3dVenueSceneSetActive). While
// active it is the EFFECTIVE style, so both knobs drive nothing.
venue: { intensity: false, reactive: false, why: 'Venue visualization is active - pick a background from the visualization picker' },
};
let _pcRefs = 0, _pcEl = null, _pcSel = null, _pcReactive = null, _pcIntensity = null;
// Non-disabled wrappers around the two greyable controls. A native-disabled
// <button>/<input> receives no pointer events, so its `title` tooltip never
// shows on hover — the whole "greyed out, says why on hover" affordance
// would be dead. The reason lives on these wrappers instead, and the
// disabled control gets pointer-events:none so the hover reaches them.
let _pcReactiveWrap = null, _pcIntensityWrap = null, _pcReason = null;
let _pcListener = null, _pcRetry = 0, _pcRetryTimer = 0;
// The player chrome exposes this slot once it has initialised. A host
// that does not provide it gets no control (and no error) - the Settings
// page remains the way in.
function _pcSlot() {
try {
// Gate on the v3 shell per docs/plugin-v3-ui.md (matches the tuner
// precedent). The playerControlSlot typeof check below already
// covers the practical case - only v3 exposes it - but the
// documented checklist asks plugins to detect v3 explicitly.
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return null;
const fn = window.feedBack.ui && window.feedBack.ui.playerControlSlot;
return typeof fn === 'function' ? fn() : null;
} catch (_) { return null; }
}
// Visual language: these controls sit in the player's Plugin Controls
// popover alongside pills from other plugins (Invert, Split, Tuner, the
// STEMS group...), so they follow the same look - small rounded pills,
// dark fill, brighter on hover, tinted when active.
//
// Styled INLINE rather than with the Tailwind classes those plugins use
// (px-3 py-1.5 bg-dark-600 hover:bg-dark-500 ...). This plugin owns its
// compiled stylesheet and several of those utilities are not in it, so
// using them would mean regenerating assets/plugin.css and bumping the
// manifest version. The values below are the resolved tokens from
// tailwind.config.js (dark-600 #181830, dark-500 #1e1e3a, gray-300
// #d1d5db), so the result matches without the build step.
const _PC_C = {
idle: '#181830', // bg-dark-600
hover: '#1e1e3a', // bg-dark-500
text: '#d1d5db', // text-gray-300
textDim: '#6b7280', // text-gray-500 (inert controls)
onBg: 'rgba(20,83,45,0.5)', // bg-green-900/50
onText: '#86efac', // text-green-300
};
const _PC_PILL = 'padding:.375rem .75rem;border:0;border-radius:.5rem;'
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
+ 'transition:background-color .15s,color .15s;';
function _pcPill(label, title) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = label;
if (title) b.title = title;
b.style.cssText = _PC_PILL;
// Hover is a pseudo-class we cannot express inline; these two
// listeners reproduce hover:bg-dark-500 for non-active pills only
// (an active pill keeps its tint on hover, as the other plugins do).
b.addEventListener('mouseenter', () => { if (!b._on) b.style.backgroundColor = _PC_C.hover; });
b.addEventListener('mouseleave', () => { if (!b._on) b.style.backgroundColor = _PC_C.idle; });
return b;
}
// Paint a pill's on/off state, optionally greyed out. `disabled` is used
// when the active background style ignores the setting entirely (see
// _pcSync and _PC_USES) - the pill stays visible so the layout
// does not jump, but it is inert and says why on hover.
function _pcPaint(btn, on, disabled, reason) {
btn._on = !!on && !disabled;
btn.disabled = !!disabled;
btn.setAttribute('aria-disabled', disabled ? 'true' : 'false');
// A toggle button must expose its state, not just its label.
btn.setAttribute('aria-pressed', btn._on ? 'true' : 'false');
// pointer-events:none lets the hover fall through to _pcReactiveWrap,
// which carries the reason a disabled button's own title can't show.
btn.style.pointerEvents = disabled ? 'none' : '';
btn.style.cursor = disabled ? 'not-allowed' : 'pointer';
btn.style.opacity = disabled ? '.45' : '1';
btn.title = reason || 'React to the audio';
if (disabled) {
btn.style.backgroundColor = _PC_C.idle;
btn.style.color = _PC_C.textDim;
return;
}
btn.style.backgroundColor = on ? _PC_C.onBg : _PC_C.idle;
btn.style.color = on ? _PC_C.onText : _PC_C.text;
}
function _pcGroupLabel(text) {
const el = document.createElement('div');
el.textContent = text;
el.style.cssText = 'font-size:.625rem;letter-spacing:.05em;text-transform:uppercase;'
+ 'color:#6b7280;margin:.375rem 0 .1875rem;';
return el;
}
// Pull every control back to what is actually stored. Runs on mount and
// whenever the settings bus reports one of our keys changed, so editing
// from the Settings page updates this control and vice-versa.
function _pcSync() {
// The active style is the EFFECTIVE one, not the stored one: while the
// Venue scene override is on it is what's mounted, and it ignores the
// whole Background group - picking a style writes `style` but
// _bgMountStyle resolves back to venue, so the dropdown would look
// broken. So under Venue the ENTIRE group goes inert (dropdown too),
// and the user exits Venue from the visualization picker where they
// entered it. An unknown id enables everything rather than disabling
// it, so a style added without a _PC_USES row is merely unhelpful.
const venue = !!_venueSceneOverride;
const effectiveStyle = venue ? 'venue' : _bgReadGlobal('style');
const uses = _PC_USES[effectiveStyle] || { intensity: true, reactive: true };
const why = uses.why || 'This background style ignores this setting';
if (_pcReason) _pcReason.textContent = why;
// Point a screen reader at the reason, but only while a control is
// inert - cleared otherwise so an enabled control is not described by a
// stale reason.
const _pcDescribe = (el, inert) => {
if (!el) return;
if (inert) el.setAttribute('aria-describedby', 'h3d-pc-reason');
else el.removeAttribute('aria-describedby');
};
_pcDescribe(_pcSel, venue);
_pcDescribe(_pcReactive, !uses.reactive);
_pcDescribe(_pcIntensity, !uses.intensity);
if (_pcSel) {
// The custom slots stay unselectable until something is uploaded -
// same rule settings.html applies.
const img = _pcSel.querySelector('option[value="image"]');
const vid = _pcSel.querySelector('option[value="video"]');
if (img) img.disabled = !_bgReadGlobal('customImageDataUrl');
if (vid) vid.disabled = !_bgReadGlobal('customVideoName');
_pcSel.value = _bgReadGlobal('style');
// The dropdown still SHOWS the stored style (venue has no option),
// but it's inert while Venue owns the scene.
_pcSel.disabled = venue;
_pcSel.setAttribute('aria-disabled', venue ? 'true' : 'false');
_pcSel.style.opacity = venue ? '.45' : '1';
_pcSel.style.cursor = venue ? 'not-allowed' : '';
// Restore the base tooltip when Venue exits — blanking it would
// permanently drop the mount-time 'Background style' hint. Matches
// how the intensity slider and Reactive pill restore theirs.
_pcSel.title = venue ? why : 'Background style';
}
if (_pcReactive) {
_pcPaint(_pcReactive, !!_bgReadGlobal('reactive'), !uses.reactive,
uses.reactive ? 'React to the audio' : why);
}
// The reason shows via the wrapper (see _pcReactiveWrap); empty when
// enabled so the control's own title takes over.
if (_pcReactiveWrap) {
_pcReactiveWrap.title = uses.reactive ? '' : why;
_pcReactiveWrap.style.cursor = uses.reactive ? '' : 'not-allowed';
}
if (_pcIntensity) {
_pcIntensity.value = String(_bgReadGlobal('intensity'));
_pcIntensity.disabled = !uses.intensity;
_pcIntensity.setAttribute('aria-disabled', uses.intensity ? 'false' : 'true');
_pcIntensity.style.pointerEvents = uses.intensity ? '' : 'none';
_pcIntensity.style.opacity = uses.intensity ? '1' : '.45';
_pcIntensity.style.cursor = uses.intensity ? '' : 'not-allowed';
_pcIntensity.title = uses.intensity ? 'Background intensity' : why;
}
if (_pcIntensityWrap) {
_pcIntensityWrap.title = uses.intensity ? '' : why;
_pcIntensityWrap.style.cursor = uses.intensity ? '' : 'not-allowed';
}
}
// Mirror the current values into the Settings panel's controls when it's
// in the DOM.
//
// settings.html hydrates ONCE from localStorage when the panel is injected
// and never subscribes to the settings bus, so before this existed there
// was only one writer and it could not go stale. Adding the in-player
// picker made a second writer, and the panel had no way to hear about it —
// change the style mid-song and Settings would still show the old value.
//
// Assigning .value / .checked programmatically does NOT fire a 'change'
// event, so this cannot loop back into the setters.
function _pcSyncSettingsPanel() {
try {
const st = document.getElementById('h3d-bg-style');
if (st) st.value = _bgReadGlobal('style');
const re = document.getElementById('h3d-bg-reactive');
if (re) re.checked = !!_bgReadGlobal('reactive');
const inten = _bgReadGlobal('intensity');
const ie = document.getElementById('h3d-bg-intensity');
if (ie) ie.value = String(inten);
// The panel prints the numeric value beside the slider; keep its
// formatting identical to settings.html's own hydration.
const il = document.getElementById('h3d-bg-intensity-label');
if (il) il.textContent = Number(inten).toFixed(2);
} catch (e) { console.error('[3D-Hwy] settings-panel mirror failed', e); }
}
function _pcMount() {
// A screen change can swap the popover out from under us, orphaning
// the control. Re-resolve only when the cached node is actually gone.
if (_pcEl && !_pcEl.isConnected) _pcTeardownDom();
if (_pcEl) return true;
const slot = _pcSlot();
if (!slot) return false;
const box = document.createElement('div');
box.className = 'h3d-pc';
box.style.cssText = 'display:flex;flex-direction:column;width:100%;';
// Visually-hidden text carrying the "why greyed out" reason to screen
// readers; disabled controls point aria-describedby here. A title alone
// is announced unreliably and never on touch. One span suffices - every
// greyed control shares the same reason (derived from the single
// effective style).
_pcReason = document.createElement('span');
_pcReason.id = 'h3d-pc-reason';
_pcReason.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;'
+ 'margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0;';
box.appendChild(_pcReason);
box.appendChild(_pcGroupLabel('Background'));
// A dropdown, not pills: the style list is 8 entries and growing, and
// a pill per style dominated a popover whose other controls are single
// toggles. Styled to match the surrounding pills rather than left as a
// raw <select>.
_pcSel = document.createElement('select');
_pcSel.title = 'Background style';
_pcSel.setAttribute('aria-label', 'Background style');
_pcSel.style.cssText = 'width:100%;padding:.375rem .5rem;border:0;border-radius:.5rem;'
+ 'font-size:.75rem;line-height:1rem;cursor:pointer;'
+ 'background-color:' + _PC_C.idle + ';color:' + _PC_C.text + ';';
for (const id of BG_STYLE_IDS) {
const o = document.createElement('option');
o.value = id;
o.textContent = _PC_LABELS[id] || id;
_pcSel.appendChild(o);
}
_pcSel.addEventListener('change', () => {
if (_pcSel.disabled) return; // inert under the Venue override
try { window.h3dBgSetStyle(_pcSel.value); }
catch (e) { console.error('[3D-Hwy] bg style set failed', e); }
});
box.appendChild(_pcSel);
const optWrap = document.createElement('div');
optWrap.style.cssText = 'display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.375rem;';
_pcReactiveWrap = optWrap; // carries the greyed-out reason on hover
_pcReactive = _pcPill('Reactive', 'React to the audio');
_pcReactive.addEventListener('click', () => {
if (_pcReactive.disabled) return;
try { window.h3dBgSetReactive(!_pcReactive._on); }
catch (e) { console.error('[3D-Hwy] bg reactive set failed', e); }
});
optWrap.appendChild(_pcReactive);
box.appendChild(optWrap);
box.appendChild(_pcGroupLabel('Intensity'));
// Wrapper carries the reason on hover when the slider is disabled — a
// native-disabled <input> shows no title of its own.
_pcIntensityWrap = document.createElement('div');
_pcIntensityWrap.style.cssText = 'width:100%;';
_pcIntensity = document.createElement('input');
_pcIntensity.type = 'range';
_pcIntensity.min = '0'; _pcIntensity.max = '1'; _pcIntensity.step = '0.05';
_pcIntensity.title = 'Background intensity';
_pcIntensity.setAttribute('aria-label', 'Background intensity');
_pcIntensity.style.cssText = 'width:100%;accent-color:#4080e0;';
// 'change' (fires on release), NOT 'input'. Every write goes through
// _bgWriteGlobal -> _bgEmitChange -> _bgRebuild(), which tears the
// background style down and re-runs build(). On 'input' a single drag
// across the range would trigger ~20 full scene rebuilds on the main
// thread mid-playback. settings.html's slider makes the same choice:
// oninput only repaints its label, onchange calls the setter.
_pcIntensity.addEventListener('change', () => {
if (_pcIntensity.disabled) return;
try { window.h3dBgSetIntensity(parseFloat(_pcIntensity.value)); }
catch (e) { console.error('[3D-Hwy] bg intensity set failed', e); }
});
_pcIntensityWrap.appendChild(_pcIntensity);
box.appendChild(_pcIntensityWrap);
slot.appendChild(box);
_pcEl = box;
_pcSync();
_pcListener = (key) => {
if (key === 'style' || key === 'reactive' || key === 'intensity'
|| key === 'customImageDataUrl' || key === 'customVideoName'
|| key === 'venueScene') {
// 'venueScene' has no dropdown/settings widget of its own, but
// toggling Venue changes the EFFECTIVE style, so the greying
// must re-evaluate (see _pcSync's effectiveStyle).
_pcSync();
_pcSyncSettingsPanel();
}
};
_bgSubscribe(_pcListener);
return true;
}
function _pcTeardownDom() {
if (_pcListener) { _bgUnsubscribe(_pcListener); _pcListener = null; }
if (_pcEl && _pcEl.parentNode) _pcEl.parentNode.removeChild(_pcEl);
_pcEl = null; _pcSel = null; _pcReactive = null; _pcIntensity = null;
_pcReactiveWrap = null; _pcIntensityWrap = null; _pcReason = null;
}
function _pcAcquire() {
_pcRefs++;
_pcBindScreenHook();
if (_pcMount()) return;
// A non-v3 shell has no slot and never will — _pcAcquire only runs once
// the renderer is viable inside the v3 player chrome, and player-chrome.js
// sets uiVersion synchronously as it builds that chrome, so a missing 'v3'
// here means v2, not a not-yet-ready v3. Skip the retry loop rather than
// spinning it out to the ~3s budget for a slot that will never appear.
if (!window.feedBack || window.feedBack.uiVersion !== 'v3') return;
// The rail popover may not be built yet on a cold load. Retry a few
// times, then give up quietly — Settings still works.
if (_pcRetryTimer) return;
_pcRetry = 0;
const tick = () => {
_pcRetryTimer = 0;
if (_pcRefs <= 0) return; // renderer went away mid-retry
// Re-attempt the bus subscription too, not just the mount. On a cold
// load the renderer can init before window.feedBack.on exists; the
// first _pcBindScreenHook() then no-ops and, without this, the hook
// never binds and the control goes permanently deaf to screen
// changes. Idempotent via the _pcScreenHook guard.
_pcBindScreenHook();
if (_pcMount()) return;
if (++_pcRetry > 12) return; // ~3s at 250ms
_pcRetryTimer = setTimeout(tick, 250);
};
_pcRetryTimer = setTimeout(tick, 250);
}
// Re-mount after the player chrome is rebuilt.
//
// _pcMount's isConnected check can only run when something calls it, and
// after the first successful mount nothing did - init() and the retry tick
// are the only callers, and the tick stops on success. So a popover that
// got swapped out left the control gone until the next song change. This
// listener gives that check a real trigger.
//
// Event-driven and cheap: one _pcMount() call per screen change, and it
// early-returns immediately when the cached node is still connected.
let _pcScreenHook = null;
function _pcBindScreenHook() {
if (_pcScreenHook) return;
const bus = window.feedBack;
if (!bus || typeof bus.on !== 'function') return;
_pcScreenHook = () => { if (_pcRefs > 0) _pcMount(); };
try { bus.on('screen:changed', _pcScreenHook); }
catch (e) { _pcScreenHook = null; }
}
function _pcRelease() {
_pcRefs = Math.max(0, _pcRefs - 1);
if (_pcRefs > 0) return;
if (_pcRetryTimer) { clearTimeout(_pcRetryTimer); _pcRetryTimer = 0; }
// Drop the screen:changed subscription too, not just the DOM. The
// refcount guard inside the hook makes a stale one harmless, but the
// listener and its closure would otherwise outlive the control for the
// page's lifetime — and a plugin re-load (new ?v=) evaluates this file
// again, binding another hook to the same bus while the old one stays.
// _pcBindScreenHook re-binds on the next acquire.
if (_pcScreenHook) {
try {
const bus = window.feedBack;
if (bus && typeof bus.off === 'function') bus.off('screen:changed', _pcScreenHook);
} catch (e) { /* best-effort: a host without off() just keeps the no-op hook */ }
_pcScreenHook = null;
}
_pcTeardownDom();
}
/* ======================================================================
* Factory feedBack#36 setRenderer contract
* ====================================================================== */
function createFactory() {
const _instanceId = ++_nextInstanceId;
// Whether THIS instance holds a refcount on the shared player-chrome
// control. Guards the init -> init (no destroy) path so one instance
// can never take two references and pin the control.
let _pcAcquired = false;
// ── Per-instance Three.js state ───────────────────────────────────
let scene = null, cam = null, ren = null;
@@ -4900,29 +5393,31 @@
let prevLockActive = false;
let tgtLookY = 0, curLookY = 0; // lerped look-at Y for self-correcting camera
let aspectScale = 1;
// _camSnapped / _camPreScanned / _songKey: together they gate the first-data snap.
// _camSnapped / _camPreScanned / _songKey: together they gate the
// first-data bootstrap.
//
// On the first update() frame where bundle.notes is available,
// _camPreScanned is set and the full notes array is scanned (O(N), once)
// to check whether ANY fretted note (f > 0) exists. If none do (e.g. an
// all-open-string bass arrangement), _camSnapped is set to true immediately
// so the per-frame pre-pass is disabled for the entire song.
// On the first update() frame where both chart arrays are available,
// they are scanned once (O(N)) for the first relevant fretted event.
// The normal camera-target calculation is sampled at the point where
// that event first enters its targeting window, and curX/curDist are
// initialized immediately. This makes silent intros start with the same
// base framing they would otherwise acquire just before the first notes.
//
// For charts that do have fretted notes, a lightweight O(window) pre-pass
// runs before any drawNote() call on every frame until the first frame
// where fretted notes appear in the camera targeting window (preWSum > 0).
// At that point curX/curDist are snapped directly to the computed targets,
// eliminating the camera swoop for songs with long silent intros.
// _camBootstrapHolding keeps that initialized target stable through the
// empty intro. It is released as soon as the ordinary live window has
// fret bounds/data, producing a continuous hand-off with no second snap.
// If the camera mode changes during the hold, live framing takes over.
//
// Once _camSnapped is true it is never cleared for the current song; the
// pre-pass is a permanent no-op thereafter and the camera reverts to
// normal lerp-based tracking for the rest of the song.
// All-open/empty charts have no horizontal fret target, so they keep the
// default base view and disable bootstrap work immediately.
//
// _songKey tracks the active song/arrangement so the snap state resets
// _songKey tracks the active song/arrangement so the bootstrap state resets
// automatically when the user switches songs or arrangements via
// reconnect() (which does not call renderer.destroy/init).
let _camSnapped = false;
let _camPreScanned = false;
let _camBootstrapHolding = false;
let _camBootstrapMode = null;
let _songKey = null;
// Smooth lookahead camera: fused world-X and displayed fret-span.
let _lookaheadCamX = xFretMid(CAM_LOCK_CENTER_FRET);
@@ -5604,13 +6099,15 @@
function _openStringLabelSignature(bundle, labels) {
const si = bundle && bundle.songInfo;
const tun = si && si.tuning;
// Same bundle-first preference as _openStringPitchLabelsForTuning.
let tStr = '';
if (Array.isArray(tun)) tStr = tun.slice(0, labels.length).join(',');
else if (bundle && Array.isArray(bundle.tuning)) tStr = bundle.tuning.slice(0, labels.length).join(',');
if (bundle && Array.isArray(bundle.tuning)) tStr = bundle.tuning.slice(0, labels.length).join(',');
else if (si && Array.isArray(si.tuning)) tStr = si.tuning.slice(0, labels.length).join(',');
// Fallback 0 matches _openStringPitchLabelsForTuning, so the
// signature reflects exactly what was rendered.
const capo =
si && Number.isFinite(si.capo) ? si.capo
: (bundle && Number.isFinite(bundle.capo) ? bundle.capo : '');
bundle && Number.isFinite(bundle.capo) ? bundle.capo
: (si && Number.isFinite(si.capo) ? si.capo : 0);
const arrIdx = si && si.arrangement_index != null ? si.arrangement_index : '';
let palSig = '';
const nLab = labels.length;
@@ -5645,8 +6142,8 @@
const tunRef = (si && Array.isArray(si.tuning)) ? si.tuning : null;
const bundleTunRef = Array.isArray(bundle.tuning) ? bundle.tuning : null;
const capo =
si && Number.isFinite(si.capo) ? si.capo
: (Number.isFinite(bundle.capo) ? bundle.capo : NaN);
Number.isFinite(bundle.capo) ? bundle.capo
: (si && Number.isFinite(si.capo) ? si.capo : 0);
const arrIdx = si && si.arrangement_index != null ? si.arrangement_index : undefined;
if (
_tuningLabelSprites.length === nStr &&
@@ -9208,6 +9705,22 @@
return now + CAM_LOOKAHEAD_SEC;
}
// Earliest future chart time whose lookahead end reaches eventTime.
// lookaheadEndTime() is monotonic but measure-stepped, so a small
// bounded binary search works for both measure grids and the seconds
// fallback without duplicating/inverting its edge-case logic.
function lookaheadBootstrapTime(now, eventTime) {
if (!(eventTime > now) || lookaheadEndTime(now) >= eventTime) return now;
let lo = now;
let hi = eventTime;
for (let i = 0; i < 32; i++) {
const mid = (lo + hi) * 0.5;
if (lookaheadEndTime(mid) >= eventTime) hi = mid;
else lo = mid;
}
return hi;
}
function lookaheadComputeFretBounds(now, anchors, notes, chords) {
const tEnd = lookaheadEndTime(now);
let minF = 99;
@@ -11148,6 +11661,8 @@
_songKey = key;
_camSnapped = false;
_camPreScanned = false;
_camBootstrapHolding = false;
_camBootstrapMode = null;
tgtX = curX = xFretMid(CAM_LOCK_CENTER_FRET);
tgtDist = curDist = CAM_DIST_BASE;
prevLowFretBonus = 0;
@@ -11173,112 +11688,130 @@
}
}
// ── Camera pre-pass (first-data snap) ────────────────────────────
// Before any drawNote() call, iterate notes/chords to accumulate
// the camera targeting data for THIS frame. If this is the first
// frame where fretted notes appear in the targeting window, snap
// curX/curDist directly to the computed targets so open-string note
// placement (which reads curX) and the camera are consistent on the
// snap frame. After the snap _camSnapped is true and this block
// becomes a permanent no-op. Open-string notes (f === 0) do not
// contribute to preWX/preWSum and therefore do not trigger the snap.
if (!_camSnapped) {
// One-time full-chart scan (runs exactly once when both bundle.notes
// and bundle.chords are available). If no fretted note exists
// anywhere in either array the snap can never fire, so we disable
// the per-frame pre-pass immediately to avoid permanent overhead.
// Both arrays are checked because some arrangements have fretted
// notes only inside chords (chord-only charts, keys arrangements).
if (!_camPreScanned && notes && chords) {
_camPreScanned = true;
const hasFrettedNote = notes.some(n => n.f > 0 && validString(n.s));
const hasFrettedChord = chords.some(
ch => ch.notes && ch.notes.some(cn => cn.f > 0 && validString(cn.s)));
if (!hasFrettedNote && !hasFrettedChord) _camSnapped = true;
}
if (!_camSnapped) {
if (cameraMode === 'lookahead') {
const bd = lookaheadBoundsNow;
if (bd) {
_lookaheadCamX = lookaheadTargetWorldX(bd.minF, bd.maxF);
_lookaheadFretSpan = Math.max(1, bd.maxF - bd.minF + 1);
const lockSnapEl = cameraLockLow && bd.maxF <= 12;
if (lockSnapEl) {
const lockedBaseU = camBaseDistU(12);
const lockedBonusU = camLowFretPullbackU(1);
const lockZoomMul = CAM_LOCK_ZOOM_MIN +
(CAM_LOCK_ZOOM_MAX - CAM_LOCK_ZOOM_MIN) * cameraLockZoom;
tgtX = xFretMid(CAM_LOCK_CENTER_FRET);
tgtDist = (lockedBaseU + lockedBonusU) * K * lockZoomMul;
prevLowFretBonus = lockedBonusU;
_lookaheadLowBonusU = lockedBonusU;
} else {
const baseDU = camBaseDistU(_lookaheadFretSpan);
const lowBU = camLowFretPullbackU(bd.minF);
tgtDist = (baseDU + lowBU) * K;
prevLowFretBonus = lowBU;
_lookaheadLowBonusU = lowBU;
tgtX = _lookaheadCamX;
}
curX = tgtX;
curDist = tgtDist;
_camSnapped = true;
_lookaheadCamPrevNow = now;
// ── Camera bootstrap (first chart data) ──────────────────────────
// Initialize against the first relevant fretted phrase as soon as
// the complete chart arrays arrive. For a future phrase, sample the
// same window state that live framing will have when the phrase
// first becomes relevant, then hold it through the silent intro.
// This is O(N) once per song/arrangement and a permanent no-op after.
if (!_camSnapped && !_camPreScanned && notes && chords) {
_camPreScanned = true;
const firstFrettedTime = hwyFirstRelevantFrettedTime(
notes, chords, now, CAM_TGT_BEHIND, nStr);
if (cameraMode === 'lookahead'
&& (lookaheadBoundsNow || firstFrettedTime !== null)) {
// Anchors can make the live lookahead valid before the first
// fretted event does. Prefer that already-current framing;
// only project forward when the live window is truly empty.
const bootstrapNow = lookaheadBoundsNow
? now
: lookaheadBootstrapTime(now, firstFrettedTime);
const bd = lookaheadBoundsNow
|| lookaheadComputeFretBounds(bootstrapNow, anchors, notes, chords);
if (bd) {
_lookaheadCamX = lookaheadTargetWorldX(bd.minF, bd.maxF);
_lookaheadFretSpan = Math.max(1, bd.maxF - bd.minF + 1);
const lockSnapEl = cameraLockLow && bd.maxF <= 12;
if (lockSnapEl) {
const lockedBaseU = camBaseDistU(12);
const lockedBonusU = camLowFretPullbackU(1);
const lockZoomMul = CAM_LOCK_ZOOM_MIN +
(CAM_LOCK_ZOOM_MAX - CAM_LOCK_ZOOM_MIN) * cameraLockZoom;
tgtX = xFretMid(CAM_LOCK_CENTER_FRET);
tgtDist = (lockedBaseU + lockedBonusU) * K * lockZoomMul;
prevLowFretBonus = lockedBonusU;
_lookaheadLowBonusU = lockedBonusU;
prevLockActive = true;
} else {
const baseDU = camBaseDistU(_lookaheadFretSpan);
const lowBU = camLowFretPullbackU(bd.minF);
tgtDist = (baseDU + lowBU) * K;
prevLowFretBonus = lowBU;
_lookaheadLowBonusU = lowBU;
tgtX = _lookaheadCamX;
prevLockActive = false;
}
curX = tgtX;
curDist = tgtDist;
_camSnapped = true;
_lookaheadCamPrevNow = now;
_camBootstrapHolding = bootstrapNow > now && !lookaheadBoundsNow;
_camBootstrapMode = _camBootstrapHolding ? cameraMode : null;
} else {
let preWX = 0, preWSum = 0, preDistMin = 99, preDistMax = 0, preDistGot = false;
if (notes) {
for (const n of notes) {
// bundle.notes is time-sorted: skip fully-expired sustains,
// break once the onset is beyond the camera window.
if (n.t + (n.sus || 0) < camT0) continue;
if (n.t > camT1) break;
if (!validString(n.s)) continue;
const nInWin = n.f > 0 && n.t >= camT0;
const nSusNow = n.f > 0 && n.t < camT0 && n.t + (n.sus || 0) >= now;
if (nInWin || nSusNow) {
const w = Math.exp(-Math.abs(n.t - now) / camTau);
preWX += xFretMid(n.f) * w; preWSum += w;
if (n.f < preDistMin) preDistMin = n.f;
if (n.f > preDistMax) preDistMax = n.f;
// Defensive fallback for malformed chart timing. The
// helper found a fretted event, so this should be
// unreachable; keeping the default is safer than a
// delayed mid-song snap.
_camSnapped = true;
}
} else if (firstFrettedTime === null) {
// Empty and all-open charts without lookahead anchor bounds
// have no horizontal fret target.
_camSnapped = true;
} else {
const bootstrapNow = Math.max(now, firstFrettedTime - camAhead);
const bootstrapT0 = bootstrapNow - CAM_TGT_BEHIND;
const bootstrapT1 = bootstrapNow + camAhead;
let preWX = 0, preWSum = 0;
let preDistMin = 99, preDistMax = 0, preDistGot = false;
for (const n of notes) {
if (n.t + (n.sus || 0) < bootstrapT0) continue;
if (n.t > bootstrapT1) break;
if (!validString(n.s)) continue;
const nInWin = n.f > 0 && n.t >= bootstrapT0;
const nSusNow = n.f > 0 && n.t < bootstrapT0
&& n.t + (n.sus || 0) >= bootstrapNow;
if (nInWin || nSusNow) {
const w = Math.exp(-Math.abs(n.t - bootstrapNow) / camTau);
preWX += xFretMid(n.f) * w;
preWSum += w;
if (n.f < preDistMin) preDistMin = n.f;
if (n.f > preDistMax) preDistMax = n.f;
preDistGot = true;
}
}
for (const ch of chords) {
if (!ch.notes) continue;
if (ch.t > bootstrapT1) break;
const chNotes = filterValidNotes(ch.notes);
if (!chNotes.length) continue;
let maxSus = 0;
for (const n of chNotes) if ((n.sus || 0) > maxSus) maxSus = n.sus;
if (ch.t + maxSus < bootstrapT0) continue;
const chOnsetInWin = ch.t >= bootstrapT0;
const chSusNow = ch.t < bootstrapT0
&& ch.t + maxSus >= bootstrapNow;
if (!chOnsetInWin && !chSusNow) continue;
const chW = Math.exp(-Math.abs(ch.t - bootstrapNow) / camTau);
for (const cn of chNotes) {
const cnOk = chOnsetInWin
|| (chSusNow && ch.t + (cn.sus || 0) >= bootstrapNow);
if (cn.f > 0 && cnOk) {
preWX += xFretMid(cn.f) * chW;
preWSum += chW;
if (cn.f < preDistMin) preDistMin = cn.f;
if (cn.f > preDistMax) preDistMax = cn.f;
preDistGot = true;
}
}
}
if (chords) {
for (const ch of chords) {
if (!ch.notes) continue;
// bundle.chords is time-sorted: break once onset is beyond window.
if (ch.t > camT1) break;
const chNotes = filterValidNotes(ch.notes);
if (!chNotes.length) continue;
let maxSus = 0;
for (const n of chNotes) if ((n.sus || 0) > maxSus) maxSus = n.sus;
if (ch.t + maxSus < camT0) continue; // fully expired
const chOnsetInWin = ch.t >= camT0;
const chSusNow = ch.t < camT0 && ch.t + maxSus >= now;
if (!chOnsetInWin && !chSusNow) continue;
const chW = Math.exp(-Math.abs(ch.t - now) / camTau);
for (const cn of chNotes) {
const cnOk = chOnsetInWin || (chSusNow && ch.t + (cn.sus || 0) >= now);
if (cn.f > 0 && cnOk) {
preWX += xFretMid(cn.f) * chW; preWSum += chW;
if (cn.f < preDistMin) preDistMin = cn.f;
if (cn.f > preDistMax) preDistMax = cn.f;
preDistGot = true;
}
}
}
}
if (preWSum > 0) {
_applyNoteCamTargets(preWX, preWSum, preDistMin, preDistMax, preDistGot,
camHystF, camDistHystF, /* skipDistHyst= */ true);
curX = tgtX;
prevLockActive = _applyNoteCamTargets(
preWX, preWSum, preDistMin, preDistMax, preDistGot,
camHystF, camDistHystF, /* skipDistHyst= */ true);
curX = tgtX;
curDist = tgtDist;
_camSnapped = true;
}
} // end steady-mode pre-pass branch
} // end !_camSnapped (post-prescan guard)
// The relevant-event helper and this accumulator share
// validity/window rules; still finish defensively if a
// malformed event could not produce a target.
_camSnapped = true;
_camBootstrapHolding = preWSum > 0 && bootstrapNow > now;
_camBootstrapMode = _camBootstrapHolding ? cameraMode : null;
}
}
pbBeg(4);
@@ -13225,7 +13758,30 @@
// ── Camera target ─────────────────────────────────────────────
let lockActive;
if (!(cameraMode === 'lookahead')) {
let bootstrapHoldActive = false;
if (_camBootstrapHolding) {
if (_camBootstrapMode !== cameraMode) {
_camBootstrapHolding = false;
_camBootstrapMode = null;
} else {
const liveFramingReady = cameraMode === 'lookahead'
? lookaheadBoundsNow !== null
: camDistGot;
if (liveFramingReady) {
_camBootstrapHolding = false;
_camBootstrapMode = null;
} else {
bootstrapHoldActive = true;
}
}
}
if (bootstrapHoldActive) {
// Keep the chart-load target intact until the ordinary live
// path can compute the same phrase. Camera Director still
// layers its free-camera transform in camUpdate().
lockActive = prevLockActive;
} else if (!(cameraMode === 'lookahead')) {
lockActive = _applyNoteCamTargets(
camWX, camWSum, camDistMin, camDistMax, camDistGot,
camHystF, camDistHystF, /* skipDistHyst= */ false);
@@ -15509,6 +16065,8 @@
prevLockActive = false;
_camSnapped = false;
_camPreScanned = false;
_camBootstrapHolding = false;
_camBootstrapMode = null;
_songKey = null;
_slideTargetSet = null;
_slideTargetNotesRef = null;
@@ -15604,6 +16162,12 @@
// Mark ready before RAF so any resize(w,h) calls that arrive
// in the meantime (e.g. from sizeCanvases()) are applied directly.
_isReady = true;
// Claim the shared player-chrome control only now that the
// renderer is actually viable. Acquiring at the top of init()
// meant a machine without WebGL2 mounted a Background control
// for a renderer that never drew a frame, and no failure path
// below released it.
if (!_pcAcquired) { _pcAcquired = true; _pcAcquire(); }
_resolveReady();
_updateFocusState();
if (sz.w > 0 && sz.h > 0) {
@@ -16031,6 +16595,7 @@
_paneAspect = 0;
if (cam && cam.fov !== BASE_VFOV) { cam.fov = BASE_VFOV; cam.updateProjectionMatrix(); }
_wrapPinned = false;
if (_pcAcquired) { _pcAcquired = false; _pcRelease(); }
_unsubscribeFocus(); teardown();
highwayCanvas = null;
},
@@ -0,0 +1,543 @@
// Player-chrome background control.
//
// The control mounts a Background picker (style / Reactive / Intensity) into
// the player's Plugin Controls popover so the background can be changed
// mid-song. Two things about it are easy to get wrong and invisible when they
// are:
//
// * It is REFCOUNTED. Several renderer instances can be live at once (a
// splitscreen host creates one per panel), but the settings it writes are
// global — N controls would be N ways to set one value, and a leaked
// refcount pins a dead control in the UI. The multi-instance behaviour is
// exercised here with stubbed instances; it is NOT verified against a real
// splitscreen session, whose visualizer does not currently work.
// * It GREYS OUT controls the active style ignores. Not every background
// style reads `intensity`, and none of them read audio bands under
// Butterchurn, so a live-looking knob that does nothing is a real bug.
//
// screen.js is a single ~16k-line IIFE, so the control cannot be imported. The
// self-contained `_pc*` block is sliced out of the real source and evaluated
// with its few collaborators stubbed (BG_STYLE_IDS, _bgReadSetting,
// _bgSubscribe/_bgUnsubscribe). The slice markers are asserted before use: move
// or rename the block and this fails loudly rather than testing nothing.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const SCREEN_JS = path.join(__dirname, '..', 'screen.js');
const START = ' const _PC_LABELS = {';
const END_CRLF = ' /* ======================================================================\r\n * Factory';
const END_LF = ' /* ======================================================================\n * Factory';
// What each style is expected to consume, derived by reading the BG_STYLES
// bodies in screen.js — deliberately NOT read from the plugin's own _PC_USES
// table, which would only assert that the table equals itself.
// intensity: true => the style's build() reads settings.intensity
// reactive: true => the style's update() dereferences its `bands` argument
// 'butterchurn' is a mode, not a BG_STYLES fog-scenery entry: _bcSyncMode
// owns its controller and drives its own audio tap + canvas opacity (only
// the fog-scenery half falls through to BG_STYLES.off), so both are false.
const EXPECTED_USES = {
off: { intensity: false, reactive: false },
particles: { intensity: true, reactive: true },
silhouettes: { intensity: true, reactive: true },
lights: { intensity: true, reactive: true },
geometric: { intensity: true, reactive: true },
image: { intensity: true, reactive: false },
video: { intensity: false, reactive: false },
butterchurn: { intensity: false, reactive: false },
};
const BG_STYLE_IDS = ['off', 'particles', 'silhouettes', 'lights', 'geometric', 'butterchurn', 'image', 'video'];
// Minimal DOM: only what the control touches.
function makeDom() {
class El {
constructor(tag) {
this.tagName = String(tag).toUpperCase();
this.children = [];
this.parentNode = null;
this.listeners = {};
this.style = { cssText: '' };
this.disabled = false;
this._on = false;
}
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
removeChild(c) {
const i = this.children.indexOf(c);
if (i >= 0) this.children.splice(i, 1);
c.parentNode = null;
return c;
}
addEventListener(t, fn) { (this.listeners[t] || (this.listeners[t] = [])).push(fn); }
setAttribute(k, v) { this[k] = v; }
removeAttribute(k) { delete this[k]; }
get isConnected() {
let n = this;
while (n.parentNode) n = n.parentNode;
return n === root;
}
querySelector(sel) {
const m = /^option\[value="(.+)"\]$/.exec(sel);
const want = m ? m[1] : null;
const walk = (n) => {
for (const c of n.children) {
if (want != null && c.tagName === 'OPTION' && c.value === want) return c;
const r = walk(c);
if (r) return r;
}
return null;
};
return walk(this);
}
fire(type) { (this.listeners[type] || []).forEach((fn) => fn()); }
}
const root = new El('root');
const slot = new El('div');
root.appendChild(slot);
return { El, root, slot };
}
function load({ store: initialStore } = {}) {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const start = src.indexOf(START);
assert.notEqual(start, -1, 'could not find the _PC_LABELS marker in screen.js');
let end = src.indexOf(END_CRLF);
if (end === -1) end = src.indexOf(END_LF);
assert.notEqual(end, -1, 'could not find the Factory banner marker in screen.js');
assert.ok(end > start, 'slice markers found out of order in screen.js');
const block = src.slice(start, end);
const dom = makeDom();
const store = Object.assign({
style: 'particles',
reactive: true,
intensity: 0.5,
customImageDataUrl: '',
customVideoName: '',
}, initialStore);
const bus = {};
const listeners = new Set();
const emit = (key) => { for (const fn of listeners) fn(key); };
const writes = [];
const timers = [];
const sandbox = {
console,
BG_STYLE_IDS,
// Module-scope in screen.js; the _pc* block reads it to resolve the
// effective style under the Venue override. Tests flip it via
// sandbox._venueSceneOverride and fire the 'venueScene' bus key.
_venueSceneOverride: false,
_bgReadSetting: (_panelKey, key) => store[key],
_bgReadGlobal: (key) => store[key],
_bgSubscribe: (fn) => listeners.add(fn),
_bgUnsubscribe: (fn) => listeners.delete(fn),
setTimeout: (fn) => { timers.push(fn); return timers.length; },
clearTimeout: () => {},
document: {
createElement: (t) => new dom.El(t),
// The Settings-panel mirror looks these up; absent here so it no-ops.
getElementById: () => null,
},
window: {
feedBack: {
uiVersion: 'v3', // _pcSlot gates on this (docs/plugin-v3-ui.md)
ui: { playerControlSlot: () => dom.slot },
// The real bus is an EventTarget wrapper exposing on/off. Modelled
// here so the screen:changed subscription — and its removal — are
// observable.
on: (ev, fn) => { (bus[ev] || (bus[ev] = [])).push(fn); },
off: (ev, fn) => {
const l = bus[ev];
if (!l) return;
const i = l.indexOf(fn);
if (i >= 0) l.splice(i, 1);
},
},
h3dBgSetStyle: (v) => { writes.push(['style', v]); store.style = v; emit('style'); },
h3dBgSetReactive: (v) => { writes.push(['reactive', v]); store.reactive = v; emit('reactive'); },
h3dBgSetIntensity: (v) => { writes.push(['intensity', v]); store.intensity = v; emit('intensity'); },
},
};
sandbox.globalThis = sandbox;
const api = vm.runInNewContext(
block
+ '\n({ _pcAcquire, _pcRelease,'
+ ' get el() { return _pcEl; },'
+ ' get sel() { return _pcSel; },'
+ ' get react() { return _pcReactive; },'
+ ' get intens() { return _pcIntensity; },'
+ ' get reason() { return _pcReason; },'
+ ' get refs() { return _pcRefs; } })',
sandbox,
);
const fireScreenChanged = () => (bus['screen:changed'] || []).slice().forEach((fn) => fn());
const screenHooks = () => (bus['screen:changed'] || []).length;
return { api, dom, store, emit, writes, timers, sandbox, listenerCount: () => listeners.size, fireScreenChanged, screenHooks };
}
// Slice the real _bgReadSetting + _bgReadGlobal out of screen.js and run them
// against a localStorage stub. The main suite stubs both helpers identically,
// so it can't tell the #2 refactor from a no-op; this one proves the actual
// helper bodies differ where they must: _bgReadGlobal ignores a per-panel
// override that _bgReadSetting(panelKey, ...) still honours.
test('_bgReadGlobal reads the global slot, ignoring per-panel overrides', () => {
const src = fs.readFileSync(SCREEN_JS, 'utf8');
const rgStart = src.indexOf(' function _bgReadSetting(panelKey, key) {');
const rgEnd = src.indexOf(' // Shared "stored string -> bool" coercion');
assert.ok(rgStart !== -1 && rgEnd > rgStart, 'could not slice the read helpers');
const block = src.slice(rgStart, rgEnd);
const storage = new Map();
const sandbox = {
localStorage: { getItem: (k) => (storage.has(k) ? storage.get(k) : null) },
_bgCoerce: (_key, v) => v, // identity: we test key resolution, not coercion
_bgMemFallback: Object.create(null),
BG_DEFAULTS: { style: 'particles' },
};
sandbox.globalThis = sandbox;
const api = vm.runInNewContext(block + '\n({ _bgReadSetting, _bgReadGlobal, _bgMemFallback })', sandbox);
storage.set('h3d_bg_style', 'lights'); // global
storage.set('h3d_bg_panel3_style', 'geometric'); // a per-panel override
// The renderer, reading with a panel key, honours the per-panel override...
assert.equal(api._bgReadSetting('panel3', 'style'), 'geometric');
// ...but the shared control's global read must NOT see it - this is the
// whole point of #2 (previously _bgReadSetting(null, ...) relied on
// 'h3d_bg_null_style' never existing).
assert.equal(api._bgReadGlobal('style'), 'lights');
// In-memory staged value wins over the persisted global (matches
// _bgReadSetting's precedence).
api._bgMemFallback.style = 'aurora';
assert.equal(api._bgReadGlobal('style'), 'aurora');
delete api._bgMemFallback.style;
// Nothing stored -> BG_DEFAULTS.
assert.equal(api._bgReadGlobal('style'), 'lights');
storage.delete('h3d_bg_style');
assert.equal(api._bgReadGlobal('style'), 'particles');
});
test('mounts one control into the player-control slot', () => {
const { api, dom } = load();
api._pcAcquire();
assert.equal(dom.slot.children.length, 1);
assert.ok(api.sel, 'style dropdown was not created');
assert.equal(api.sel.children.length, BG_STYLE_IDS.length, 'one option per style');
});
test('multiple renderer instances share a single control', () => {
const { api, dom } = load();
api._pcAcquire();
api._pcAcquire();
api._pcAcquire();
api._pcAcquire();
assert.equal(dom.slot.children.length, 1, 'four instances must not mount four controls');
assert.equal(api.refs, 4);
api._pcRelease();
api._pcRelease();
api._pcRelease();
assert.equal(dom.slot.children.length, 1, 'still held by the last instance');
api._pcRelease();
assert.equal(dom.slot.children.length, 0, 'last release must unmount');
assert.equal(api.el, null);
});
test('binds the screen hook on a retry when the bus was not ready at acquire', () => {
const ctl = load();
// Cold load: on a fresh page the renderer can init before the event bus is
// wired AND before the rail popover exists. Simulate both being absent.
const savedOn = ctl.sandbox.window.feedBack.on;
const savedUi = ctl.sandbox.window.feedBack.ui;
delete ctl.sandbox.window.feedBack.on;
ctl.sandbox.window.feedBack.ui = {}; // no playerControlSlot -> mount fails
ctl.api._pcAcquire();
assert.equal(ctl.screenHooks(), 0, 'nothing to bind to yet');
assert.equal(ctl.api.el, null, 'no slot yet, so nothing mounted');
// Bus + slot come online; the retry tick must bind the hook, not only mount.
ctl.sandbox.window.feedBack.on = savedOn;
ctl.sandbox.window.feedBack.ui = savedUi;
ctl.timers.shift()(); // run one retry tick
assert.equal(ctl.screenHooks(), 1, 'the retry tick failed to bind the screen hook');
assert.ok(ctl.api.el, 'and it should have mounted too');
ctl.api._pcRelease();
});
test('the last release unbinds the screen:changed hook', () => {
const ctl = load();
ctl.api._pcAcquire();
assert.equal(ctl.screenHooks(), 1, 'acquire should subscribe once');
ctl.api._pcAcquire();
ctl.api._pcRelease();
assert.equal(ctl.screenHooks(), 1, 'a partial release must keep the hook');
ctl.api._pcRelease();
assert.equal(ctl.screenHooks(), 0, 'the hook outlived the control');
// And re-acquiring must re-subscribe exactly once, not zero times (the
// bind is guarded on _pcScreenHook, so failing to null it would leave the
// control permanently deaf to chrome rebuilds).
ctl.api._pcAcquire();
assert.equal(ctl.screenHooks(), 1, 're-acquire did not re-subscribe');
ctl.api._pcRelease();
});
test('teardown unsubscribes from the settings bus', () => {
const ctl = load();
ctl.api._pcAcquire();
assert.equal(ctl.listenerCount(), 1);
ctl.api._pcRelease();
assert.equal(ctl.listenerCount(), 0, 'listener leaked after unmount');
});
test('tracks changes made from the Settings page', () => {
const { api, store, emit } = load();
api._pcAcquire();
store.style = 'lights';
emit('style');
assert.equal(api.sel.value, 'lights');
});
test('custom media options stay disabled until something is uploaded', () => {
const { api, store, emit } = load();
api._pcAcquire();
assert.equal(api.sel.querySelector('option[value="image"]').disabled, true);
store.customImageDataUrl = 'data:image/png;base64,AAAA';
emit('customImageDataUrl');
assert.equal(api.sel.querySelector('option[value="image"]').disabled, false);
assert.equal(api.sel.querySelector('option[value="video"]').disabled, true, 'video is independent');
});
test('re-mounts into a fresh slot when the player chrome is rebuilt', () => {
const { api, dom, sandbox, listenerCount } = load();
api._pcAcquire();
const first = api.el;
dom.root.removeChild(dom.slot);
const fresh = new dom.El('div');
dom.root.appendChild(fresh);
sandbox.window.feedBack.ui.playerControlSlot = () => fresh;
api._pcAcquire();
assert.equal(fresh.children.length, 1, 'did not remount into the new slot');
assert.notEqual(api.el, first, 'stale node was reused');
assert.equal(listenerCount(), 1, 'remount must not double-subscribe');
});
test('a non-v3 host mounts nothing (uiVersion gate)', () => {
const ctl = load();
ctl.sandbox.window.feedBack.uiVersion = 'v2'; // pre-v3 shell
ctl.api._pcAcquire();
assert.equal(ctl.api.el, null, 'must not mount when uiVersion is not v3');
assert.equal(ctl.dom.slot.children.length, 0);
// A non-v3 shell has no slot and never will, so no retry should be scheduled
// at all — the loop is for a not-yet-built v3 slot, not for polling v2.
assert.equal(ctl.timers.length, 0, 'a non-v3 host must not schedule the retry loop');
ctl.api._pcRelease();
});
test('a host with no player-control slot mounts nothing and does not throw', () => {
const { api, dom, sandbox, timers } = load();
sandbox.window.feedBack.ui = {};
api._pcAcquire();
assert.equal(api.el, null);
assert.equal(dom.slot.children.length, 0);
let guard = 0;
while (timers.length && guard++ < 100) timers.shift()();
assert.ok(guard < 100, 'retry loop did not terminate');
});
test('intensity writes once on release, not on every drag step', () => {
const { api, writes } = load();
api._pcAcquire();
for (const v of ['0.10', '0.20', '0.30', '0.40', '0.50']) {
api.intens.value = v;
api.intens.fire('input');
}
assert.equal(writes.filter((w) => w[0] === 'intensity').length, 0,
'dragging must not write — every write rebuilds the background scene');
api.intens.fire('change');
assert.equal(writes.filter((w) => w[0] === 'intensity').length, 1,
'releasing must write exactly once');
});
test('the dropdown and Reactive pill drive the real setters', () => {
const { api, store, writes } = load();
api._pcAcquire();
api.sel.value = 'geometric';
api.sel.fire('change');
assert.equal(store.style, 'geometric');
const before = store.reactive;
api.react.fire('click');
assert.equal(store.reactive, !before, 'Reactive pill must toggle');
assert.ok(writes.some((w) => w[0] === 'reactive'));
});
test('exposes state and reasons to assistive tech', () => {
const ctl = load({ store: { style: 'image', reactive: true } }); // image: reactive inert
ctl.api._pcAcquire();
// The reason live-region must be a REAL mounted element with the id the
// controls reference - not a dangling pointer. Assert resolution, not a
// literal (a wrong id in code would still equal the literal).
const reason = ctl.api.reason;
assert.ok(reason, 'the reason span was not created');
assert.equal(reason.id, 'h3d-pc-reason');
assert.equal(reason.parentNode, ctl.api.el, 'the reason span must be mounted in the control');
// aria-pressed: a toggle button must expose its state. image greys
// Reactive, so not-pressed AND disabled, and it points at the reason.
assert.equal(ctl.api.react['aria-pressed'], 'false', 'greyed toggle is not pressed');
assert.equal(ctl.api.react['aria-disabled'], 'true');
// Pointer must resolve to the actual span's id (kills a wrong-id mutation),
// and the span must carry the current reason text (kills a never-set-text
// mutation).
assert.equal(ctl.api.react['aria-describedby'], reason.id, 'inert control must reference the reason span');
assert.equal(reason.textContent, 'This background does not react to audio', 'reason text must match the style');
assert.equal(ctl.api.intens['aria-describedby'], undefined, 'an ENABLED control carries no reason');
// The intensity describe path: a style where INTENSITY is inert.
ctl.store.style = 'video'; ctl.emit('style');
assert.equal(ctl.api.intens.disabled, true, 'precondition: video greys intensity');
assert.equal(ctl.api.intens['aria-describedby'], reason.id, 'inert intensity must reference the reason');
assert.equal(reason.textContent, 'The video plays as-is - nothing to adjust here');
// Both enabled: describedby drops, aria-pressed follows the value.
ctl.store.style = 'particles'; ctl.store.reactive = true; ctl.emit('style');
assert.equal(ctl.api.react['aria-describedby'], undefined, 'enabled control drops the reason');
assert.equal(ctl.api.intens['aria-describedby'], undefined);
assert.equal(ctl.api.react['aria-pressed'], 'true', 'reactive on for particles');
ctl.store.reactive = false; ctl.emit('reactive');
assert.equal(ctl.api.react['aria-pressed'], 'false', 'aria-pressed follows the value');
// Accessible names on the non-label controls.
assert.equal(ctl.api.sel['aria-label'], 'Background style');
assert.equal(ctl.api.intens['aria-label'], 'Background intensity');
ctl.api._pcRelease();
});
test('greys out exactly the controls each style ignores', () => {
const { api, store, emit } = load();
api._pcAcquire();
for (const [style, want] of Object.entries(EXPECTED_USES)) {
store.style = style;
emit('style');
assert.equal(!api.intens.disabled, want.intensity, `${style}: intensity enabled-ness`);
assert.equal(!api.react.disabled, want.reactive, `${style}: reactive enabled-ness`);
}
});
test('the Venue override greys the whole Background group', () => {
const ctl = load({ store: { style: 'particles' } }); // a style that uses both
ctl.api._pcAcquire();
assert.equal(ctl.api.intens.disabled, false, 'precondition: both enabled off-venue');
assert.equal(ctl.api.react.disabled, false);
// Venue turns on: the effective style is now 'venue', which uses neither.
// The transition arrives on the settings bus as the 'venueScene' key.
ctl.sandbox._venueSceneOverride = true;
ctl.emit('venueScene');
assert.equal(ctl.api.intens.disabled, true, 'intensity should grey under Venue');
assert.equal(ctl.api.react.disabled, true, 'reactive should grey under Venue');
assert.equal(ctl.api.sel.disabled, true, 'the dropdown should be inert under Venue too');
assert.match(ctl.api.intens.title, /venue/i, 'reason should mention Venue');
// All three inert controls point at the reason under Venue (kills a
// 'describe reactive only' regression on the select/intensity paths).
const vReason = ctl.api.reason.id;
assert.equal(ctl.api.sel['aria-describedby'], vReason, 'select must reference the reason under Venue');
assert.equal(ctl.api.intens['aria-describedby'], vReason, 'intensity must reference the reason under Venue');
assert.equal(ctl.api.react['aria-describedby'], vReason, 'reactive must reference the reason under Venue');
assert.match(ctl.api.reason.textContent, /venue/i, 'the reason span carries the Venue text');
// The dropdown still shows the stored style (venue has no option), but
// selecting must not write while it's inert.
assert.equal(ctl.api.sel.value, 'particles');
const before = ctl.writes.length;
ctl.api.sel.value = 'lights';
ctl.api.sel.fire('change');
assert.equal(ctl.writes.length, before, 'a disabled dropdown must not write');
// Venue off: controls come back per the stored style.
ctl.sandbox._venueSceneOverride = false;
ctl.emit('venueScene');
assert.equal(ctl.api.intens.disabled, false, 'intensity re-enables when Venue exits');
assert.equal(ctl.api.react.disabled, false);
assert.equal(ctl.api.sel.disabled, false, 'the dropdown re-enables when Venue exits');
assert.equal(ctl.api.sel.title, 'Background style', 'the base tooltip must come back, not blank');
assert.equal(ctl.api.sel['aria-describedby'], undefined, 'select drops the reason off-Venue');
assert.equal(ctl.api.intens['aria-describedby'], undefined, 'intensity drops the reason off-Venue');
ctl.api._pcRelease();
});
test('an unknown style enables both controls (fails open)', () => {
const { api, store, emit } = load();
api._pcAcquire();
store.style = 'some_future_style';
emit('style');
assert.equal(api.intens.disabled, false);
assert.equal(api.react.disabled, false);
});
test('greyed-out controls cannot reach the setters', () => {
const { api, store, emit, writes } = load();
api._pcAcquire();
store.style = 'video'; // uses neither setting
emit('style');
const before = writes.length;
api.intens.fire('change');
api.react.fire('click');
assert.equal(writes.length, before, 'an inert control must not write');
});
test('greyed-out controls explain themselves on hover', () => {
const { api, store, emit } = load();
api._pcAcquire();
store.style = 'butterchurn';
emit('style');
assert.match(api.react.title, /butterchurn/i);
assert.match(api.intens.title, /butterchurn/i);
});
// A native-disabled <button>/<input> fires no pointer events, so its own
// `title` never shows on hover. The reason must therefore also sit on the
// non-disabled wrapper, and the disabled control must let the hover fall
// through (pointer-events:none) — otherwise the "says why on hover" feature is
// dead in the browser while these tests pass on the swallowed control title.
test('the greyed-out reason reaches a hoverable wrapper', () => {
const { api, store, emit } = load();
api._pcAcquire();
store.style = 'video'; // uses neither setting
emit('style');
assert.match(api.react.parentNode.title, /nothing to adjust/i,
'reactive reason must be on the wrapper, not only the disabled pill');
assert.equal(api.react.style.pointerEvents, 'none',
'disabled pill must pass hover through to its wrapper');
assert.match(api.intens.parentNode.title, /nothing to adjust/i,
'intensity reason must be on the wrapper, not only the disabled slider');
assert.equal(api.intens.style.pointerEvents, 'none',
'disabled slider must pass hover through to its wrapper');
// ...and an enabled style clears the wrapper so the control's own title wins.
store.style = 'particles';
emit('style');
assert.equal(api.react.parentNode.title, '');
assert.equal(api.intens.parentNode.title, '');
assert.equal(api.intens.style.pointerEvents, '');
});
+18 -7
View File
@@ -7,15 +7,26 @@
#
# Pin to Tailwind 3.x so the input/config syntax matches what was
# already shipped via the Play CDN (Tailwind 4 has breaking changes).
#
# Run this from a checkout with NO untracked plugin directories present (a
# `git worktree add --detach` of this branch is the safest way). The content
# glob (tailwind.config.js) scans `./plugins/**` on disk regardless of
# .gitignore — a dev machine with private/out-of-tree plugins checked out
# locally (e.g. audio_engine, plugin_manager) will silently bake their classes
# into the committed CSS, which CI's clean checkout can never reproduce and
# will permanently fail the tailwind-fresh gate.
set -euo pipefail
cd "$(dirname "$0")/.."
# Pin to the exact version used to generate the committed CSS — committed
# artifacts must rebuild byte-stable for diff-friendly maintenance. The
# pinned version is the one that produced the current static/tailwind.min.css
# (visible in its top-of-file header comment); bump deliberately when you
# want to track upstream Tailwind 3.x updates, and regenerate the CSS in
# the same commit.
exec npx -y tailwindcss@3.4.19 \
# Byte-stable rebuilds require the exact same resolved dependency tree, not
# just the same top-level tailwindcss version: `npx -y tailwindcss@x.y.z`
# installs into a scratch npx cache and lets npm re-resolve transitive deps
# (postcss, cssnano, autoprefixer) to whatever's current on the registry at
# invocation time — those drift independently of the pinned version and
# silently produced non-reproducible output between two machines. tailwindcss
# is now a pinned devDependency (package.json/package-lock.json); `npm ci`
# before this script (both here and in CI) is what actually makes the output
# reproducible.
exec npx tailwindcss \
-c tailwind.config.js \
-i static/_tailwind.src.css \
-o static/tailwind.min.css \
+7 -1
View File
@@ -49,7 +49,7 @@ import demo_mode
import scan
import tailwind_rebuild
# Extracted route modules. They import `appstate`, never `server` — one-way graph.
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics
from routers import audio_effects, artist_aliases, loops, playlists, ws_highway, ws_sync, chart, wanted, library_extras, shop, progression, profile, stats, version, diagnostics
from routers import tunings as tunings_router
import enrichment
from routers import art as art_router
@@ -1618,6 +1618,12 @@ app.include_router(media_router.router)
app.include_router(ws_highway.router)
# ── Session-sync relay WebSocket (feedBack#1030) ─────────────────────────────
# Dumb JSON fan-out rooms for cross-device followers (splitscreen LAN mode).
# Implementation in lib/routers/ws_sync.py.
app.include_router(ws_sync.router)
# ── Audio serving ─────────────────────────────────────────────────────────────
+30 -3
View File
@@ -1150,7 +1150,7 @@ window.feedBack.on('song:ready', () => {
let _arrBusyGen = 0;
let _arrBusyTimeout = null;
async function changeArrangement(index) {
async function changeArrangement(index, drumPart) {
if (currentFilename) {
// Tear down any pending fresh-load credits before switching: the
// no-count-in hold timer would otherwise fire togglePlay() against the
@@ -1276,11 +1276,38 @@ async function changeArrangement(index) {
_resetSectionPracticeLog();
invalidateParentCount();
window.highway.reconnect(currentFilename, index);
// Carry the selected drum part across the re-stream. An explicit
// `drumPart` (a drum-part switch, from changeDrumPart) wins; otherwise
// preserve the current picker selection so an ARRANGEMENT switch keeps
// the chosen part (drum parts are song-level, not per-arrangement).
const part = drumPart !== undefined
? drumPart
: (document.getElementById('drum-part-select')?.value || '');
window.highway.reconnect(currentFilename, index, part);
window.feedBack.emit('arrangement:changed', { index, filename: currentFilename });
}
}
// Switch which drum part plays (feedpak 1.17.0 "drums as arrangements"). A part
// switch re-streams the same song with a different drum tab — the same
// transition as an arrangement switch — so it delegates to changeArrangement
// with the CURRENT arrangement held and the new part applied. Wired to
// #drum-part-select's onchange; the select is populated + shown by
// highway.js's song_info handler only when the song has 2+ drum parts.
async function changeDrumPart(partId) {
if (!currentFilename) return;
let index = 0;
const si = window.highway && typeof window.highway.getSongInfo === 'function'
? window.highway.getSongInfo() : null;
if (si && typeof si.arrangement_index === 'number' && si.arrangement_index >= 0) {
index = si.arrangement_index;
} else {
const arrSel = document.getElementById('arr-select');
if (arrSel && arrSel.value !== '') index = Number(arrSel.value) || 0;
}
return changeArrangement(index, partId);
}
// Restart the current song from the beginning (or from loop A when an AB
// loop is armed). Uses the canonical _audioSeek funnel only — never touches
// audio.currentTime directly and never reloads via playSong().
@@ -2325,7 +2352,7 @@ configureHost({
Object.assign(window, {
_confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl,
_librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal,
changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop,
changeArrangement, changeDrumPart, checkPluginUpdates, clearLibFilters, clearLoop,
deleteSelectedLoop, esc, exportDiagnostics, exportSettings, filterFavorites,
filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput,
hideScanBanner, importSettings, loadPlugins, loadSavedLoop,
+2 -1
View File
@@ -128,6 +128,7 @@
stems: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated stem automation, restore, manual override, and compatibility bridge surface backed by the active Stems provider.' }),
visualization: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated highway renderer providers: discovery, picker selection, auto-match attribution, failure fallback, and redaction-safe diagnostics.' }),
'note-detection': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Coordinates note-detection providers and requester-owned, context-scoped detection bindings (spec 009); consumers own judgment, hit/miss flow as observability events.' }),
'chart-transform': Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Coordinates chart-transform providers: pre-render/pre-scoring chart substitution applied after difficulty filtering, with persisted selection, refresh, and fixed-reason failure attribution (#952).' }),
});
const EXPECTED_COMPATIBILITY_SHIMS = Object.freeze({});
@@ -1536,4 +1537,4 @@
window.dispatchEvent(new CustomEvent('feedBack:capabilities:ready', { detail: api }));
_notifySubscribers('registered', { capability: '*', pluginId: 'core', timestamp: _now() });
} catch (_) {}
})();
})();
+336
View File
@@ -0,0 +1,336 @@
// Chart-transform provider registration, selection, and diagnostics.
// Transformation stays on the synchronous highway data plane and runs after
// difficulty filtering; the selected provider is shared by highway instances.
(function () {
'use strict';
window.feedBack = window.feedBack || {};
const capabilities = window.feedBack.capabilities;
if (!capabilities || capabilities.version !== 1) return;
if (window.feedBack.chartTransformDomain && window.feedBack.chartTransformDomain.version === 1) return;
const STORAGE_KEY = 'feedBack.chartTransform.selectedProviderId';
const PUBLIC_FAILURE_REASON = 'Chart transform provider failed';
// providerId → { id, label, pluginId, transform }
const providers = new Map();
let activeProviderId = null;
let activeSource = 'startup';
let lastFailure = null;
// Count of highway instances the active provider is installed on
// (the primary window.highway plus any announced via highway:created —
// e.g. splitscreen panels). 0 = nothing capable exists yet.
let installedCount = 0;
// Known highway surfaces beyond window.highway, held weakly so closed
// splitscreen panels can be collected. WeakRef is guarded for minimal
// test environments; the strong-ref fallback only over-retains there.
const _HasWeakRef = typeof WeakRef === 'function';
let _surfaces = [];
function _handled(payload = {}) { return { outcome: 'handled', payload }; }
function _degraded(reason, payload = {}) { return { outcome: 'degraded', reason, payload }; }
function _snapshot(extra = {}) {
return {
available: true,
active: activeProviderId,
activeSource,
installed: installedCount > 0,
surfaces: installedCount,
providers: [...providers.values()].map(p => ({
id: p.id,
label: p.label,
pluginId: p.pluginId,
})),
lastFailure: lastFailure ? { ...lastFailure } : null,
...extra,
};
}
function _emit(name, detail) {
try { capabilities.emitEvent('chart-transform', name, detail || {}); }
catch (_) { /* eventing must not break rendering */ }
}
function _contributeDiagnostics() {
const diagnostics = window.feedBack && window.feedBack.diagnostics;
if (diagnostics && typeof diagnostics.contribute === 'function') {
try {
diagnostics.contribute('chart-transform-capability', {
schema: 'feedBack.chart_transform.diagnostics.v1',
..._snapshot(),
});
} catch (_) { /* diagnostics must not break rendering */ }
}
}
function _persistSelection(providerId) {
try {
if (providerId) window.localStorage.setItem(STORAGE_KEY, providerId);
else window.localStorage.removeItem(STORAGE_KEY);
} catch (_) { /* storage unavailable → in-memory selection only */ }
}
function _persistedSelection() {
try { return window.localStorage.getItem(STORAGE_KEY) || null; }
catch (_) { return null; }
}
function _capable(hw) {
return !!(hw && typeof hw.setChartTransform === 'function');
}
// Every capable highway surface: window.highway plus live announced
// instances (splitscreen panels), deduped, dead refs pruned in place.
function _eachSurface(fn) {
const seen = new Set();
const primary = window.highway;
if (_capable(primary)) { seen.add(primary); fn(primary); }
const live = [];
for (const ref of _surfaces) {
const hw = _HasWeakRef ? ref.deref() : ref;
if (!hw) continue;
live.push(ref);
if (seen.has(hw) || !_capable(hw)) continue;
seen.add(hw);
fn(hw);
}
_surfaces = live;
return seen.size;
}
function _rememberSurface(hw) {
if (!_capable(hw) || hw === window.highway) return;
let known = false;
_eachSurface(() => {});
for (const ref of _surfaces) {
if ((_HasWeakRef ? ref.deref() : ref) === hw) { known = true; break; }
}
if (!known) _surfaces.push(_HasWeakRef ? new WeakRef(hw) : hw);
}
// Hand the current selection to every highway surface (or clear it).
// Selection survives with zero surfaces — it re-applies as instances
// appear (song:ready for the primary, highway:created for panels).
function _install() {
const provider = activeProviderId ? providers.get(activeProviderId) : null;
const payload = provider ? { id: provider.id, transform: provider.transform } : null;
installedCount = 0;
_eachSurface((hw) => {
try {
hw.setChartTransform(payload);
if (payload) installedCount += 1;
} catch (_) { /* one broken surface must not block the rest */ }
});
return installedCount > 0 || payload === null;
}
function _setActive(providerId, source) {
const from = activeProviderId;
activeProviderId = providerId;
activeSource = String(source || 'unknown');
_persistSelection(providerId);
_install();
if (from !== providerId) {
_emit('transform-changed', { from, to: providerId, source: activeSource });
}
_contributeDiagnostics();
}
function _payload(ctx = {}) {
return ctx.payload && typeof ctx.payload === 'object' ? ctx.payload : {};
}
function _providersForParticipant(participantId) {
return [...providers.values()].filter(provider => provider.pluginId === participantId);
}
function _registerProviderParticipant(participantId) {
const owned = _providersForParticipant(participantId);
if (!owned.length) return;
capabilities.registerParticipant(participantId, {
'chart-transform': {
roles: ['provider'],
operations: ['chart.transform'],
events: [],
mode: 'active',
compatibility: 'none',
safety: 'safe',
runtime: true,
description: `${owned.length} registered chart transform provider${owned.length === 1 ? '' : 's'}.`,
provider_policy: {
providerIds: owned.map(provider => provider.id),
providers: owned.map(provider => ({ id: provider.id, label: provider.label })),
},
},
});
}
function _registerProvider(ctx = {}) {
const payload = _payload(ctx);
const providerId = String(payload.providerId || payload.id || '').trim();
if (!providerId) return _degraded('Provider registration requires a providerId', _snapshot());
if (typeof payload.transform !== 'function') {
return _degraded('Provider registration requires a transform(input) function', _snapshot());
}
const participantId = String(ctx.source || ctx.requester || providerId);
const existing = providers.get(providerId);
if (existing && existing.pluginId !== participantId) {
return _degraded(
`Provider ${providerId} is already registered by a different participant`,
_snapshot(),
);
}
providers.set(providerId, {
id: providerId,
label: String(payload.label || providerId),
pluginId: participantId,
transform: payload.transform,
});
_registerProviderParticipant(participantId);
_emit('provider-registered', { providerId });
// Restore a persisted selection the moment its provider appears.
if (!activeProviderId && _persistedSelection() === providerId) {
_setActive(providerId, 'restore-selection');
} else if (activeProviderId === providerId) {
// Re-registration after script rehydration: reinstall the fresh
// transform closure so the highway isn't holding a stale one.
_install();
}
_contributeDiagnostics();
return _handled(_snapshot({ registered: providerId }));
}
function _unregisterProvider(ctx = {}) {
const payload = _payload(ctx);
const providerId = String(payload.providerId || payload.id || '').trim();
const provider = providers.get(providerId);
if (!provider) return _degraded(`Unknown chart-transform provider: ${providerId || '(none)'}`, _snapshot());
const callerId = String(ctx.source || ctx.requester || providerId);
if (provider.pluginId !== callerId) {
return _degraded(
`Provider ${providerId} can only be unregistered by its original registrant`,
_snapshot(),
);
}
providers.delete(providerId);
if (activeProviderId === providerId) {
// Keep the persisted selection so the provider re-activates on
// its next registration; just detach it from the highway.
activeProviderId = null;
_install();
_emit('transform-changed', { from: providerId, to: null, source: 'provider-unregistered' });
}
const remainingProviders = _providersForParticipant(provider.pluginId);
if (remainingProviders.length) {
_registerProviderParticipant(provider.pluginId);
} else if (typeof capabilities.unregisterParticipant === 'function') {
const live = typeof capabilities.inspect === 'function' ? capabilities.inspect('chart-transform') : null;
const participant = ((live && live.participants) || []).find(p => p.pluginId === provider.pluginId);
const roles = participant && Array.isArray(participant.roles) ? participant.roles : [];
const providerOnly = roles.length === 1 && roles[0] === 'provider';
if (!participant || providerOnly) {
try { capabilities.unregisterParticipant(provider.pluginId, 'chart-transform'); }
catch (_) { /* participant cleanup is best-effort */ }
}
}
_emit('provider-unregistered', { providerId });
_contributeDiagnostics();
return _handled(_snapshot({ unregistered: providerId }));
}
function _targetProviderId(ctx = {}) {
const payload = _payload(ctx);
const target = ctx.target && typeof ctx.target === 'object' ? ctx.target : {};
return String(
target.providerId || target.provider_id || target.id
|| payload.providerId || payload.provider_id || payload.id
|| (typeof ctx.target === 'string' ? ctx.target : '') || ''
).trim();
}
function _selectProvider(ctx = {}) {
const providerId = _targetProviderId(ctx);
if (!providerId) return _degraded('Transform selection requires a provider id', _snapshot());
if (!providers.has(providerId)) {
return _degraded(`Unknown chart-transform provider: ${providerId}`, _snapshot());
}
_setActive(providerId, ctx.requester ? `command:${ctx.requester}` : 'command');
return _handled(_snapshot({ selected: providerId }));
}
function _clearProvider(ctx = {}) {
_setActive(null, ctx.requester ? `command:${ctx.requester}` : 'command');
return _handled(_snapshot({ cleared: true }));
}
function _refresh() {
if (!activeProviderId || installedCount === 0) return _handled(_snapshot({ refreshed: false }));
let refreshed = 0;
_eachSurface((hw) => {
if (typeof hw.refreshChartTransform !== 'function') return;
try { hw.refreshChartTransform(); refreshed += 1; }
catch (_) { /* one broken surface must not block the rest */ }
});
return _handled(_snapshot({ refreshed: refreshed > 0 }));
}
capabilities.registerOwner('chart-transform', {
pluginId: 'core.chart-transform',
kind: 'provider-coordinator',
safety: 'safe',
commands: ['inspect', 'list-providers', 'register-provider', 'unregister-provider', 'select-provider', 'clear-provider', 'refresh'],
operations: ['chart.transform'],
events: ['provider-registered', 'provider-unregistered', 'transform-changed', 'transform-failed'],
description: 'Owns chart-transform providers: pre-render/pre-scoring chart substitution applied after difficulty filtering, with selection, refresh, and failure attribution.',
handlers: {
inspect: () => _handled(_snapshot()),
'list-providers': () => _handled(_snapshot()),
'register-provider': (ctx) => _registerProvider(ctx),
'unregister-provider': (ctx) => _unregisterProvider(ctx),
'select-provider': (ctx) => _selectProvider(ctx),
'clear-provider': (ctx) => _clearProvider(ctx),
refresh: () => _refresh(),
},
});
// Bus mirroring (guarded: the bus may not exist in minimal/test envs).
const sm = window.feedBack;
if (typeof sm.on === 'function') {
try {
sm.on('highway:chart-transform-failed', (e) => {
const detail = (e && e.detail) || e || {};
lastFailure = {
providerId: String(detail.id || activeProviderId || 'unknown'),
reason: PUBLIC_FAILURE_REASON,
};
_emit('transform-failed', { ...lastFailure });
_contributeDiagnostics();
});
// The primary highway is created after this module evaluates —
// install a pending selection once a song is loading/ready.
sm.on('song:ready', () => {
if (activeProviderId && installedCount === 0 && _install()) {
// setChartTransform restages immediately, so the chart
// that just became ready picks the transform up now.
_contributeDiagnostics();
}
});
// Additional instances restage the active provider against their
// own chart state.
sm.on('highway:created', (e) => {
const detail = (e && e.detail) || e || {};
if (!_capable(detail.highway)) return;
_rememberSurface(detail.highway);
if (activeProviderId) _install();
_contributeDiagnostics();
});
} catch (_) { /* bus mirroring is best-effort */ }
}
window.feedBack.chartTransformDomain = {
version: 1,
snapshot: _snapshot,
};
_contributeDiagnostics();
})();
+232 -24
View File
@@ -267,6 +267,19 @@ function createHighway() {
hwState._filteredChords = null;
hwState._filteredAnchors = null;
hwState._filteredHandShapes = null;
// Transform stage; null fields fall through to filtered/original data.
hwState._xfProvider = null; // { id, transform } or null
hwState._xfNotes = null; // effective (post-filter) views
hwState._xfChords = null;
hwState._xfAnchors = null;
hwState._xfNotesAll = null; // full-difficulty views (getNotes/getChords)
hwState._xfChordsAll = null;
hwState._xfChordTemplates = null;
hwState._xfStringCount = null; // number or null
hwState._xfTuning = null; // array or null
hwState._xfCapo = null; // number or null
hwState._xfHandShapes = null; // array or null
hwState._xfCentOffset = null; // number or null
// Tracks whether ANY phrase level carries handshape data. Lets us
// distinguish "this difficulty has none" (respect strictly — even
// when empty) from "the chart's phrase data never authored any
@@ -397,7 +410,8 @@ function createHighway() {
function getAnchorAt(t) {
// Same master-difficulty fallback as the render loops — the
// anchor ladder pairs with the note ladder.
const src = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
const src = hwState._xfAnchors !== null ? hwState._xfAnchors
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
let a = src[0] || { fret: 1, width: 4 };
for (const anc of src) {
if (anc.time > t) break;
@@ -408,7 +422,8 @@ function createHighway() {
function getMaxFretInWindow(t) {
// Find the highest fret needed across all anchors visible on screen
const src = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
const src = hwState._xfAnchors !== null ? hwState._xfAnchors
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
let maxFret = 0;
for (const anc of src) {
if (anc.time > t + VISIBLE_SECONDS + 2) break; // Skip anchors well in the future (with a little buffer to avoid moving early the cutoff)
@@ -541,17 +556,20 @@ function createHighway() {
// Chart content (filter-aware — difficulty-filtered arrays
// preferred; raw arrays are the fallback when no ladder data).
b.notes = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
b.chords = hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
b.anchors = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
b.notes = hwState._xfNotes !== null ? hwState._xfNotes
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
b.chords = hwState._xfChords !== null ? hwState._xfChords
: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
b.anchors = hwState._xfAnchors !== null ? hwState._xfAnchors
: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors;
b.beats = hwState.beats;
b.sections = hwState.sections;
b.chordTemplates = hwState.chordTemplates;
b.stringCount = hwState.stringCount;
// Mirrors song_info tuning capo offsets (±semitones from the
// instruments standard open-string layout). Live reference.
b.tuning = hwState.songInfo?.tuning;
b.capo = hwState.songInfo?.capo;
b.chordTemplates = hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates;
b.stringCount = hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount;
// Effective tuning metadata; live references like the chart arrays.
b.tuning = hwState._xfTuning !== null ? hwState._xfTuning : hwState.songInfo?.tuning;
b.capo = hwState._xfCapo !== null ? hwState._xfCapo : hwState.songInfo?.capo;
b.centOffset = hwState._xfCentOffset !== null ? hwState._xfCentOffset : hwState.songInfo?.centOffset;
b.lyrics = hwState.lyrics;
b.lyricsSource = hwState.lyricsSource;
b.toneChanges = hwState.toneChanges;
@@ -572,9 +590,10 @@ function createHighway() {
// don't belong. Only fall back to the flat list when the
// phrase data carries no handshapes at all (common on DLC
// where handshapes ship on the arrangement root).
b.handShapes = (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
? hwState._filteredHandShapes
: hwState.handShapes;
b.handShapes = hwState._xfHandShapes !== null ? hwState._xfHandShapes
: (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
? hwState._filteredHandShapes
: hwState.handShapes;
// Display flags
b.inverted = hwState._inverted;
@@ -1372,9 +1391,10 @@ function createHighway() {
// slots, so 4 strings spread across the full band rather than
// using the upper 4/6ths of the 6-string layout. The Math.max
// guards against a hypothetical 1-string instrument (denom=0).
const span = Math.max(1, hwState.stringCount - 1);
for (let i = 0; i < hwState.stringCount; i++) {
const yi = hwState._inverted ? (hwState.stringCount - 1 - i) : i;
const sc = hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount;
const span = Math.max(1, sc - 1);
for (let i = 0; i < sc; i++) {
const yi = hwState._inverted ? (sc - 1 - i) : i;
const y = strTop + (yi / span) * (strBot - strTop);
hwState.ctx.strokeStyle = hwState.STRING_COLORS[i] || '#888';
hwState.ctx.lineWidth = 3;
@@ -1477,6 +1497,7 @@ function createHighway() {
hwState._filteredAnchors = null;
hwState._filteredHandShapes = null;
hwState._phrasesHaveHandShapes = false;
_restageChartTransform();
return;
}
const outNotes = [];
@@ -1524,6 +1545,116 @@ function createHighway() {
}
hwState._filteredHandShapes = outHandShapes;
hwState._phrasesHaveHandShapes = anyHandShapeInPhrases;
_restageChartTransform();
}
function _clearChartTransformStage() {
hwState._xfNotes = null;
hwState._xfChords = null;
hwState._xfAnchors = null;
hwState._xfNotesAll = null;
hwState._xfChordsAll = null;
hwState._xfChordTemplates = null;
hwState._xfStringCount = null;
hwState._xfTuning = null;
hwState._xfCapo = null;
hwState._xfHandShapes = null;
hwState._xfCentOffset = null;
}
function _cloneChartTransformValue(value, seen = new WeakMap()) {
if (!value || typeof value !== 'object') return value;
if (seen.has(value)) return seen.get(value);
const copy = Array.isArray(value) ? new Array(value.length) : {};
seen.set(value, copy);
for (const key of Object.keys(value)) {
Object.defineProperty(copy, key, {
value: _cloneChartTransformValue(value[key], seen),
enumerable: true,
configurable: true,
writable: true,
});
}
return copy;
}
function _sortedChartTransformArray(items, key) {
return items.slice().sort((a, b) => a[key] - b[key]);
}
function _reportChartTransformFailure(provider, error) {
_clearChartTransformStage();
console.error('chart transform:', error);
if (window.feedBack && typeof window.feedBack.emit === 'function') {
try {
window.feedBack.emit('highway:chart-transform-failed', {
id: provider.id,
});
} catch (_) { /* eventing must not break rendering */ }
}
}
// Stage one synchronous transform over the difficulty-filtered chart.
function _restageChartTransform() {
_clearChartTransformStage();
const p = hwState._xfProvider;
if (!p) return;
// Pre-ready there is nothing meaningful to transform (chart arrays
// are still streaming, songInfo may be empty) — keep the provider
// attached and let the `ready` path (which sets hwState.ready BEFORE
// _rebuildMasteryFilter) run the first real staging.
if (!hwState.ready) return;
const filterActive = hwState._filteredNotes !== null;
try {
let out = p.transform(_cloneChartTransformValue({
notes: filterActive ? hwState._filteredNotes : hwState.notes,
chords: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords,
anchors: hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors,
allNotes: hwState.notes,
allChords: hwState.chords,
chordTemplates: hwState.chordTemplates,
// Same effective selection the bundle uses (see b.handShapes).
handShapes: (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
? hwState._filteredHandShapes
: hwState.handShapes,
stringCount: hwState.stringCount,
songInfo: hwState.songInfo,
}));
if (out && typeof out.then === 'function') {
try {
const catchAsyncFailure = out.catch;
if (typeof catchAsyncFailure === 'function') {
catchAsyncFailure.call(out, error => console.error('chart transform async:', error));
}
} catch (_) { /* the synchronous failure below remains authoritative */ }
throw new TypeError('Chart transform providers must return synchronously');
}
if (!out || typeof out !== 'object') return;
out = _cloneChartTransformValue(out);
if (Array.isArray(out.notes)) hwState._xfNotes = _sortedChartTransformArray(out.notes, 't');
if (Array.isArray(out.chords)) hwState._xfChords = _sortedChartTransformArray(out.chords, 't');
if (Array.isArray(out.anchors)) hwState._xfAnchors = _sortedChartTransformArray(out.anchors, 'time');
// Full-difficulty views: explicit allNotes/allChords, or reuse the
// effective output when no filter is active (effective === raw then).
if (Array.isArray(out.allNotes)) hwState._xfNotesAll = _sortedChartTransformArray(out.allNotes, 't');
else if (!filterActive && Array.isArray(out.notes)) hwState._xfNotesAll = hwState._xfNotes;
if (Array.isArray(out.allChords)) hwState._xfChordsAll = _sortedChartTransformArray(out.allChords, 't');
else if (hwState._filteredChords === null && Array.isArray(out.chords)) hwState._xfChordsAll = hwState._xfChords;
if (Array.isArray(out.chordTemplates)) hwState._xfChordTemplates = out.chordTemplates;
if (Number.isFinite(out.stringCount) && out.stringCount >= 1) {
// Same [1, 8] clamp as the song_info stringCount handler.
hwState._xfStringCount = Math.max(1, Math.min(8, Math.trunc(out.stringCount)));
}
if (Array.isArray(out.tuning) && out.tuning.length) hwState._xfTuning = out.tuning;
if (Number.isFinite(out.capo) && out.capo >= 0) hwState._xfCapo = Math.trunc(out.capo);
if (Array.isArray(out.handShapes)) {
hwState._xfHandShapes = _sortedChartTransformArray(out.handShapes, 'start_time');
}
if (Number.isFinite(out.centOffset)) hwState._xfCentOffset = out.centOffset;
} catch (e) {
_reportChartTransformFailure(p, e);
return;
}
}
// ── Public API ───────────────────────────────────────────────────────
@@ -1568,6 +1699,8 @@ function createHighway() {
hwState._filteredAnchors = null;
hwState._filteredHandShapes = null;
hwState._phrasesHaveHandShapes = false;
// Keep _xfProvider (persists across songs); drop staged output.
_clearChartTransformStage();
_resetChordRenderState();
},
@@ -2165,6 +2298,31 @@ function createHighway() {
sel.appendChild(opt);
}
}
// Drum-part picker (feedpak 1.17.0 "drums as
// arrangements"): a song can carry several drum
// charts. Populate the picker beside the
// arrangement switcher; show it only when there
// are 2+ parts to choose between. `drum_parts`
// is always present (empty for non-drum songs),
// so a single-drum / no-drum song hides it. The
// currently-streaming part is marked selected by
// the `drum_tab` handler below (authoritative
// `part_id`), so we don't guess here.
{
const dpSel = document.getElementById('drum-part-select');
if (dpSel) {
const parts = Array.isArray(msg.drum_parts) ? msg.drum_parts : [];
dpSel.textContent = '';
for (const p of parts) {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.name || p.id;
dpSel.appendChild(opt);
}
const dpRow = document.getElementById('v3-drum-part-row');
if (dpRow) dpRow.classList.toggle('hidden', parts.length <= 1);
}
}
}
// Plugin context API — broadcast current song state
if (window.feedBack) {
@@ -2247,7 +2405,22 @@ function createHighway() {
name: (typeof msg.name === 'string' && msg.name) ? msg.name : 'Drums',
kit: Array.isArray(msg.kit) ? msg.kit : [],
hits: [],
// Which drum part this stream carries (feedpak
// 1.17.0). Present only for multi-part packs;
// null otherwise. Plugins can read it via
// bundle.drumTab.part_id.
part_id: (typeof msg.part_id === 'string' && msg.part_id) ? msg.part_id : null,
};
// Reflect the authoritative streaming part in the
// picker (the server resolves an unknown/absent
// selection to the primary, so this keeps the
// dropdown honest even after a fallback).
if (hwState.drumTab.part_id) {
const dpSel = document.getElementById('drum-part-select');
if (dpSel && dpSel.value !== hwState.drumTab.part_id) {
dpSel.value = hwState.drumTab.part_id;
}
}
break;
case 'drum_hits':
if (hwState.drumTab && Array.isArray(msg.data)) {
@@ -2454,8 +2627,11 @@ function createHighway() {
hwState._domVisSampledFrame = NaN;
return _isHighwayVisible();
},
getNotes() { return hwState.notes; },
getChords() { return hwState.chords; },
// When a chart transform is active these return its full-difficulty
// views (falling through to the original arrays if the provider
// supplied only the filtered view).
getNotes() { return hwState._xfNotesAll !== null ? hwState._xfNotesAll : hwState.notes; },
getChords() { return hwState._xfChordsAll !== null ? hwState._xfChordsAll : hwState.chords; },
// Difficulty-filtered variants of getNotes()/getChords(). Returns the
// master-difficulty-filtered arrays when the current song has phrase-level
// data (i.e. the mastery slider is active). For songs with a single
@@ -2463,8 +2639,14 @@ function createHighway() {
// these fall through to the raw arrays, the same as getNotes()/getChords().
// Plugins that score or analyse only the notes the player is currently
// expected to play should prefer these over getNotes()/getChords(). Read-only.
getFilteredNotes() { return hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes; },
getFilteredChords() { return hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords; },
getFilteredNotes() {
if (hwState._xfNotes !== null) return hwState._xfNotes;
return hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
},
getFilteredChords() {
if (hwState._xfChords !== null) return hwState._xfChords;
return hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
},
// Live reference to the chord-template lookup table —
// `getChords()[i].id` is an index into this array. Each
// template carries `{ name, fingers, frets }`:
@@ -2479,7 +2661,7 @@ function createHighway() {
// its entries. Not difficulty-filter-aware (templates are
// static metadata; every chord_id referenced by `getChords()`
// is guaranteed valid).
getChordTemplates() { return hwState.chordTemplates; },
getChordTemplates() { return hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates; },
getToneChanges() { return hwState.toneChanges; },
getToneBase() { return hwState.toneBase; },
getSections() { return hwState.sections; },
@@ -2507,7 +2689,10 @@ function createHighway() {
// string-indexed UI / geometry against THIS rather than
// assuming 6. Defaults to 6 between songs (until the next
// song_info message arrives).
getStringCount() { return hwState.stringCount; },
getStringCount() { return hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount; },
getTuning() { return hwState._xfTuning !== null ? hwState._xfTuning : hwState.songInfo?.tuning; },
getCapo() { return hwState._xfCapo !== null ? hwState._xfCapo : hwState.songInfo?.capo; },
getCentOffset() { return hwState._xfCentOffset !== null ? hwState._xfCentOffset : hwState.songInfo?.centOffset; },
addDrawHook(fn) {
hwState._drawHooks.push(fn);
},
@@ -2531,6 +2716,17 @@ function createHighway() {
*/
setNoteStateProvider(fn) { hwState._noteStateProvider = (typeof fn === 'function') ? fn : null; },
getNoteStateProvider() { return hwState._noteStateProvider; },
// Install one synchronous provider for this highway. The capability
// domain owns registration and selection; null clears the provider.
setChartTransform(p) {
hwState._xfProvider = (p && typeof p.transform === 'function')
? { id: String(p.id || 'anonymous'), transform: p.transform }
: null;
_restageChartTransform();
},
getChartTransform() { return hwState._xfProvider; },
// Re-run the installed provider (e.g. its target settings changed).
refreshChartTransform() { _restageChartTransform(); },
/** Current per-string base colors (copy). Index 0..7. */
getStringColors() { return hwState.STRING_COLORS.slice(); },
/**
@@ -2617,7 +2813,7 @@ function createHighway() {
localStorage.setItem('showFingerHints', String(hwState._showFingerHints));
},
reconnect(filename, arrangement) {
reconnect(filename, arrangement, drumPart) {
// Close old WS but keep audio + animation running
if (hwState.ws) { hwState.ws.close(); hwState.ws = null; }
hwState.ready = false;
@@ -2638,9 +2834,16 @@ function createHighway() {
hwState._filteredAnchors = null;
hwState._filteredHandShapes = null;
hwState._phrasesHaveHandShapes = false;
// Keep _xfProvider (persists across songs); drop staged output.
_clearChartTransformStage();
_resetChordRenderState();
const wsParams = new URLSearchParams();
if (arrangement !== undefined) wsParams.set('arrangement', arrangement);
// Multiple drum parts (feedpak 1.17.0 "drums as arrangements"):
// carry the selected part id so the WS streams ITS drum tab. Empty
// / undefined → the primary part (server default), i.e. today's
// one-drum behavior for any pack the picker never touched.
if (drumPart) wsParams.set('drum_part', drumPart);
let namingMode = 'smart';
if (typeof window._getArrangementNamingMode === 'function') {
const v = window._getArrangementNamingMode();
@@ -2735,6 +2938,11 @@ function createHighway() {
*/
isDefaultRenderer() { return hwState._renderer === _defaultRenderer || hwState._renderer == null; },
};
// Let cross-instance coordinators discover this highway.
if (window.feedBack && typeof window.feedBack.emit === 'function') {
try { window.feedBack.emit('highway:created', { highway: api }); }
catch (e) { console.error('highway:created emit:', e); }
}
return api;
}
const highway = createHighway();
+78 -5
View File
@@ -1,4 +1,4 @@
// Count-in — the 1-2-3-4 click before playback, plus the song-credits overlay that
// Count-in — the one-bar click before playback, plus the song-credits overlay that
// shares its lifecycle and timers.
//
// The third slice out of app.js's strongly-connected core, and the first that had to
@@ -40,6 +40,75 @@ export function playClick(high = false) {
osc.stop(_audioCtx.currentTime + 0.08);
}
// ── How many clicks lead into `startT` ──────────────────────────────────
// One bar, derived from the song_timeline beats: `window.highway.getBeats()`
// is the only meter data the frontend holds (the `time_signatures` map is
// streamed to plugins, not stored here). Beats carry `measure >= 0` on
// downbeats, so the gap between consecutive downbeats IS the bar length —
// which is why a 3/4 song no longer gets four clicks.
//
// A first bar shorter than that is a pickup (anacrusis), and the count is
// shortened by its length so the music enters on its real beat: a 1-beat
// pickup in 4/4 counts "1 2 3" and the pickup lands on 4. Counting a full
// four there puts the pickup where the downbeat belongs, and the player comes
// in a beat late for the whole song.
export function countInBeats(startT) {
const DEFAULT = 4; // pre-chart, synthetic highway (minigames), or no beats
let beats = null;
try {
if (window.highway && typeof window.highway.getBeats === 'function') {
beats = window.highway.getBeats();
}
} catch (_) { /* fall through to the default */ }
if (!Array.isArray(beats) || beats.length < 2) return DEFAULT;
const downbeats = [];
for (let i = 0; i < beats.length; i++) {
if (beats[i] && beats[i].measure >= 0) downbeats.push(i);
}
if (downbeats.length < 2) return DEFAULT;
// Bar length = the most common gap between downbeats. The mode rather than
// the first gap: it ignores a short pickup bar and a short final bar, and
// survives an isolated meter change mid-song. The beats trailing the last
// downbeat count as a candidate too — otherwise a song of pickup + one bar
// offers only the pickup's own gap and the count collapses to it.
const gapCounts = new Map();
const addGap = (gap) => gapCounts.set(gap, (gapCounts.get(gap) || 0) + 1);
for (let k = 1; k < downbeats.length; k++) {
addGap(downbeats[k] - downbeats[k - 1]);
}
addGap(beats.length - downbeats[downbeats.length - 1]);
let barLen = DEFAULT;
let bestCount = 0;
for (const [gap, n] of gapCounts) {
// Tie → the longer bar: a pickup's short gap must not outvote the
// real meter when the song is too short to repeat it.
if (n > bestCount || (n === bestCount && gap > barLen)) {
barLen = gap;
bestCount = n;
}
}
// The beat playback resumes on. The 50 ms tolerance matches the seek
// precision the loop-wrap path already assumes.
const startIdx = beats.findIndex(b => b && b.time >= startT - 0.05);
if (startIdx === -1) return barLen; // past the last beat
if (!(beats[startIdx].measure >= 0)) return barLen; // resuming mid-bar
const nextDownbeat = downbeats.find(d => d > startIdx);
if (nextDownbeat === undefined) return barLen; // the last downbeat
const thisBar = nextDownbeat - startIdx;
if (thisBar <= 0) return barLen;
// Only the song's FIRST bar can be a pickup. A short bar anywhere else is
// a meter change (or a truncated final bar), and counting it as a pickup
// would leave almost no count-in at all — so elsewhere we simply count
// that bar's own length, which is also what a mid-song meter change wants.
if (startIdx === downbeats[0] && thisBar < barLen) return barLen - thisBar;
return thisBar;
}
let _countingIn = false;
let _countOverlay = null;
// Generation token so teardown can cancel an in-progress count-in. Each
@@ -273,12 +342,15 @@ export async function startCountIn(opts = {}) {
function beginCount() {
const bpm = window.highway.getBPM(loopA);
const beatInterval = 60 / bpm;
// One bar of the meter at loop A (a short bar there is counted short,
// same as the song-start pickup).
const clicks = countInBeats(loopA);
let count = 0;
function tick() {
if (gen !== _countInGen) return; // teardown mid-count
count++;
if (count > 4) {
if (count > clicks) {
hideCountOverlay();
_countingIn = false;
if (window._juceMode) {
@@ -320,7 +392,7 @@ export async function startCountIn(opts = {}) {
}
}
// Start-of-song count-in: a 4-beat click before playback begins, gated by the
// Start-of-song count-in: a one-bar click before playback begins, gated by the
// "Countdown before song" setting (Gameplay tab). Mirrors the loop count-in's
// overlay + click + gen-token cancellation, but counts from the song's current
// position (0 at song start) with no loop A/B rewind. startCountIn() is loop-
@@ -340,14 +412,15 @@ export async function startSongCountIn() {
if (gen !== _countInGen) return; // teardown during pause
const startT = S.lastAudioTime || 0;
let bpm = window.highway.getBPM(startT);
// Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each).
// Pre-chart / malformed-tempo fallback: 120 BPM (500 ms per beat).
if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120;
const beatInterval = 60 / bpm;
const clicks = countInBeats(startT);
let count = 0;
function tick() {
if (gen !== _countInGen) return; // teardown mid-count
count++;
if (count > 4) {
if (count > clicks) {
hideCountOverlay();
_countingIn = false;
// Hand off to the normal play path — togglePlay() flips isPlaying,
+21 -10
View File
@@ -400,8 +400,9 @@ export function drawSustains(hwState, W, H) {
// Same master-difficulty fallback as drawNotes/drawChords —
// without this, sustain bars for filtered-out notes would
// still render, leaving orphan rectangles where no note head
// is drawn.
const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
// is drawn. An active chart transform substitutes its staged view.
const src = hwState._xfNotes !== null ? hwState._xfNotes
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
for (const n of src) {
if (n.sus <= 0.01) continue;
const end = n.t + n.sus;
@@ -501,7 +502,9 @@ export function drawNotes(hwState, W, H) {
// phrase-level ladder data, render from the mastery-filtered
// array. _filteredNotes stays null for slider-disabled sources
// so rendering falls through to the flat notes array unchanged.
const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
// An active chart transform (_xfNotes) substitutes its staged view.
const src = hwState._xfNotes !== null ? hwState._xfNotes
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
// Binary search for visible range
const tMin = hwState.currentTime - 0.25;
const tMax = hwState.currentTime + VISIBLE_SECONDS;
@@ -649,7 +652,8 @@ export function drawUnisonBends(hwState, W, H, drawnNotes) {
export function drawChords(hwState, W, H) {
// See drawNotes — _filteredChords is null for slider-disabled
// sources so we fall through to the flat chords array.
const src = hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
const src = hwState._xfChords !== null ? hwState._xfChords
: hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords;
_ensureChordRenderCache(hwState, src);
const tMin = hwState.currentTime - 0.25;
@@ -674,7 +678,7 @@ export function drawChords(hwState, W, H) {
const actualSpread = Math.max(spread, minSpread);
const actualTotalH = actualSpread * Math.max(0, sorted.length - 1);
const { tmpl, getTemplateFret } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
const { tmpl, getTemplateFret } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
const hasNonZero = nonZeroNotes.length >= 1;
const frameLeftFret = baseFret;
@@ -1124,15 +1128,22 @@ export function getChordTemplateInfo(chordId, chordTemplates) {
return { tmpl, tmplFrets, getTemplateFret, isOpen };
}
// Effective chord templates: an active chart transform substitutes its
// re-indexed table (identity change also invalidates the render cache).
export function _effChordTemplates(hwState) {
return hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates;
}
// Build _chordRenderInfo for every chord in `src` if the cache is stale.
// Two passes over the array: chain bounds, then base-fret resolution
// (which can read previous chord's cached baseFret).
export function _ensureChordRenderCache(hwState, src) {
const templatesChanged = hwState._chordRenderCacheTemplates !== hwState.chordTemplates;
const effTemplates = _effChordTemplates(hwState);
const templatesChanged = hwState._chordRenderCacheTemplates !== effTemplates;
if (hwState._chordRenderCacheSrc === src && hwState._chordRenderCacheInverted === hwState._inverted && !templatesChanged) return;
hwState._chordRenderCacheSrc = src;
hwState._chordRenderCacheInverted = hwState._inverted;
hwState._chordRenderCacheTemplates = hwState.chordTemplates;
hwState._chordRenderCacheTemplates = effTemplates;
// Templates feed isOpen() — when they land after `chords`,
// _updateFretLinePreview's stashed open/non-open classification
// for the currently-active chord is also stale. It only refreshes
@@ -1188,7 +1199,7 @@ export function _ensureChordRenderCache(hwState, src) {
for (let i = 0; i < src.length; i++) {
const ch = src[i];
const info = hwState._chordRenderInfo.get(ch);
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
const { isOpen } = getChordTemplateInfo(ch.id, effTemplates);
const sortedNotes = [...ch.notes].sort((a, b) => hwState._inverted ? b.s - a.s : a.s - b.s);
const nonZero = sortedNotes.filter(cn => !isOpen(cn));
const nonZeroFrets = nonZero.map(cn => cn.f);
@@ -1248,7 +1259,7 @@ export function _updateFretLinePreview(hwState, src, lo, hi) {
ch.t > bestChordTime) {
bestChordTime = ch.t;
activeChord = ch;
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
const { isOpen } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
const nonZero = ch.notes.filter(cn => !isOpen(cn));
activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : [];
}
@@ -1260,7 +1271,7 @@ export function _updateFretLinePreview(hwState, src, lo, hi) {
const p = project(ch.t - hwState.currentTime);
if (!p) continue;
activeChord = ch;
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates);
const { isOpen } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
const nonZero = ch.notes.filter(cn => !isOpen(cn));
activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : [];
break;
+256 -78
View File
@@ -213,9 +213,66 @@ export function setupWindowOptions() {
}
}
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha'];
export const APP_UPDATE_CHANNELS = ['stable', 'rc', 'beta', 'alpha', 'nightly'];
export let _appUpdatesWired = false;
// Poll handle for the active-download watcher (module-scoped so re-running
// setupAppUpdates on a panel re-render never stacks a second poll).
let _appUpdatePollTimer = null;
// Last channel main actually acknowledged (initial sync or a successful
// user switch). Used to revert the dropdown/localStorage if a switch fails,
// so the UI/persisted state can never end up ahead of the real updater state.
let _appUpdateAckedChannel = null;
// Last [update-diag] renderFrom line logged, so the ~1.5s download poll (and
// repeated no-op re-renders) don't flood the diagnostics ring buffer with
// byte-identical lines and evict genuinely useful trace. Every real state or
// percent change still differs and logs; the structured contribute() snapshot
// (with its own ts) is unconditional, so liveness is never lost.
let _appUpdateLastRenderLog = null;
// Pure status → view model for the App-updates panel. DOM-free and exported so
// the button/channel/text state machine can be unit-tested without a browser;
// renderFrom() applies the returned shape to the DOM. `canApply` is whether the
// bridge exposes apply() (older bridges fall back to text-only), `fmtTimestamp`
// formats the "last checked" time, `channelValue` is the dropdown's fallback
// when the status omits a channel.
export function _appUpdateStatusView(s, { channelValue, canApply = true, fmtTimestamp = (t) => String(t) } = {}) {
if (!s) return { kind: 'unavailable' };
if (s.status === 'unsupported' || s.platform === 'linux') return { kind: 'unsupported' };
const base = `Version ${s.currentVersion || '?'} · ${s.channel || channelValue}`;
let action;
let btnLabel = 'Check for updates';
let btnMode = 'check';
let btnDisabled = false;
// Lock the channel selector only while a check/download is in flight —
// switching mid-operation abandons it. Enabled for every other status.
const channelDisabled = s.status === 'checking' || s.status === 'downloading';
switch (s.status) {
case 'checking':
action = 'checking for updates…';
btnDisabled = true;
break;
case 'downloading': {
const pct = typeof s.percent === 'number' ? s.percent : null;
action = pct === null ? 'update available — downloading…' : `downloading update… ${pct}%`;
btnDisabled = true;
break;
}
case 'downloaded':
action = 'update ready';
if (canApply) { btnLabel = 'Restart now'; btnMode = 'restart'; }
else { action = 'update ready — restart to apply'; }
break;
case 'error':
action = s.message ? `update error: ${s.message}` : 'update check failed';
break;
case 'idle':
default:
action = `up to date · last checked ${fmtTimestamp(s.lastChecked)}`;
break;
}
return { kind: 'status', line: `${base} · ${action}`, btnLabel, btnMode, btnDisabled, channelDisabled };
}
export function setupAppUpdates() {
const block = document.getElementById('app-updates-block');
@@ -248,13 +305,29 @@ export function setupAppUpdates() {
try { storedRaw = localStorage.getItem('feedBack-update-channel') || localStorage.getItem('slopsmith-update-channel'); } catch (_) { /* fall through */ }
const stored = APP_UPDATE_CHANNELS.includes(storedRaw) ? storedRaw : 'stable';
channelSelect.value = stored;
_appUpdateAckedChannel = stored;
const isLinux = window.feedBackDesktop?.platform === 'linux';
// Diagnostic: every entry into this function, with whether the one-time
// sync gate has already fired. _appUpdatesWired is a MODULE-level `let`,
// so it only resets to false on a genuine fresh evaluation of this
// script (a real page reload/navigation) — not on loadSettings() simply
// being called again within the same page. A second "wired=false" in one
// exported log is direct proof of a reload; a series of "wired=true"
// entries proves it's just repeated Settings-panel visits (harmless).
console.log('[update-diag] setupAppUpdates() entered', JSON.stringify({ wired: _appUpdatesWired, stored }));
function showLinuxFallback(message) {
// Deliberately leaves channelSelect ENABLED: on Linux "unsupported"
// usually just means "the channel isn't Nightly yet", and the dropdown
// is the only way to switch to Nightly. Disabling it would trap the
// user on whatever channel they booted with. Only the check button and
// the note reflect the unsupported state.
if (linuxNote) linuxNote.classList.remove('hidden');
channelSelect.disabled = true;
checkBtn.disabled = true;
// Reset the button out of any leftover "Restart now" state (e.g. an
// update was staged on nightly, then the user switched channels).
checkBtn.textContent = 'Check for updates';
checkBtn.dataset.mode = 'check';
statusEl.textContent = message || 'Auto-update is not available on this platform.';
}
@@ -266,61 +339,140 @@ export function setupAppUpdates() {
} catch (_) { return 'never'; }
}
// Render one status object. Always keeps the current version + channel
// visible and appends what's happening, so the download progress never
// obscures which build you're on.
function renderFrom(s, extra) {
// Diagnostic trace: log the raw status object before any branching —
// auto-captured by diagnostics.js's console wrap into the exportable
// ring buffer, so "Export Diagnostics" in this same Settings → System
// panel captures exactly what the app saw and decided, not just what
// the UI showed. Deduped so a steady poll doesn't flood the ring buffer
// (see _appUpdateLastRenderLog); a real state/percent change differs and
// still logs; the structured contribute() snapshot below is unconditional.
const logKey = `${JSON.stringify(s)}|${extra || ''}`;
if (logKey !== _appUpdateLastRenderLog) {
_appUpdateLastRenderLog = logKey;
console.log('[update-diag] renderFrom', JSON.stringify(s), extra ? `extra=${extra}` : '');
}
const view = _appUpdateStatusView(s, {
channelValue: channelSelect.value,
canApply: typeof updateApi.apply === 'function',
fmtTimestamp,
});
if (view.kind === 'unavailable') { statusEl.textContent = extra || 'Updater status unavailable.'; return; }
if (view.kind === 'unsupported') {
showLinuxFallback('Auto-update requires the AppImage build on the Nightly channel.');
return;
}
// Healthy for the current channel — clear any "unsupported" UI left
// over from a prior channel selection.
if (linuxNote) linuxNote.classList.add('hidden');
// The button is a little state machine (dataset.mode drives the click
// handler's restart-vs-check branch); the channel selector locks only
// while a check/download is active. See _appUpdateStatusView.
channelSelect.disabled = view.channelDisabled;
checkBtn.textContent = view.btnLabel;
checkBtn.dataset.mode = view.btnMode;
checkBtn.disabled = view.btnDisabled;
const line = view.line;
statusEl.textContent = extra ? `${extra} · ${line}` : line;
// Live structured snapshot (overwrites, not a log) via the existing
// diagnostics contribute() API — 'audio_engine' is feedBack-desktop's
// own registered plugin id, so the server's diagnostics export won't
// filter it out. Always current, no scrolling through console history
// needed to answer "what does the app think is going on right now."
try {
window.feedBack?.diagnostics?.contribute('audio_engine', {
update: {
channel: s.channel || channelSelect.value,
status: s.status,
currentVersion: s.currentVersion ?? null,
lastChecked: s.lastChecked ?? null,
percent: typeof s.percent === 'number' ? s.percent : null,
message: s.message ?? null,
rendered: line,
ts: Date.now(),
},
});
} catch (_) { /* diagnostics.js not loaded — never let this break rendering */ }
// A download runs in the background (the check returns immediately), so
// poll for the terminal state rather than relying solely on a one-shot
// "downloaded" event that could be missed or arrive out of order.
if (s.status === 'downloading' || s.status === 'checking') pollWhileBusy();
}
function renderStatus(extra) {
try {
// Wrap in Promise.resolve so a future getStatus() that returns
// synchronously won't blow up on .then().
void Promise.resolve(updateApi.getStatus()).then((s) => {
if (!s) { statusEl.textContent = extra || 'Updater status unavailable.'; return; }
if (s.status === 'unsupported' || s.platform === 'linux') {
showLinuxFallback('Auto-update is not available on Linux.');
return;
}
if (s.status === 'error') {
const errMsg = s.message ? `Update error: ${s.message}` : 'Update check failed.';
statusEl.textContent = extra ? `${extra} · ${errMsg}` : errMsg;
return;
}
const parts = [
`Version ${s.currentVersion || '?'}`,
`channel ${s.channel || channelSelect.value}`,
`last checked ${fmtTimestamp(s.lastChecked)}`,
];
statusEl.textContent = extra ? `${extra} · ${parts.join(' · ')}` : parts.join(' · ');
}).catch((e) => {
console.warn('[updater] getStatus failed:', e);
statusEl.textContent = extra || 'Failed to read updater status.';
});
void Promise.resolve(updateApi.getStatus())
.then((s) => renderFrom(s, extra))
.catch((e) => {
console.warn('[updater] getStatus failed:', e);
statusEl.textContent = extra || 'Failed to read updater status.';
});
} catch (e) {
console.warn('[updater] getStatus threw:', e);
statusEl.textContent = extra || 'Failed to read updater status.';
}
}
if (isLinux) {
showLinuxFallback('Auto-update is not available on Linux.');
// Keep main informed of the persisted channel even on Linux so
// cross-platform reasoning about the channel stays consistent.
// setChannel() may return a Promise — chain .catch() so a rejected
// promise doesn't surface as an unhandled rejection.
try {
void Promise.resolve(updateApi.setChannel(stored)).catch((e) => {
console.warn('[updater] setChannel(linux) failed:', e);
// While a download (or check) is active, re-read the authoritative status
// every ~1.5s and stop once it settles (downloaded / idle / error). This is
// what guarantees the panel leaves "downloading… 100%" and lands on "update
// ready" (or surfaces a swap error) even if the completion event is lost.
function pollWhileBusy() {
if (_appUpdatePollTimer) return;
_appUpdatePollTimer = setInterval(() => {
void Promise.resolve(updateApi.getStatus()).then((s) => {
renderFrom(s);
const st = s && s.status;
if (st !== 'downloading' && st !== 'checking') {
clearInterval(_appUpdatePollTimer);
_appUpdatePollTimer = null;
}
}).catch(() => {
clearInterval(_appUpdatePollTimer);
_appUpdatePollTimer = null;
});
} catch (e) {
console.warn('[updater] setChannel(linux) threw:', e);
}
return;
}, 1500);
}
// Inform main of the persisted channel on each load. setChannel() on
// main is idempotent when the channel already matches.
try {
void Promise.resolve(updateApi.setChannel(stored)).catch((e) => {
console.warn('[updater] setChannel(initial) failed:', e);
});
} catch (e) {
console.warn('[updater] setChannel(initial) threw:', e);
// Inform main of the persisted channel — but ONLY the first time this page
// wires up, not on every loadSettings() re-render. This used to run
// unconditionally on every call and was caught (via Export Diagnostics)
// stomping an in-flight check/download: a redundant setChannel() call
// mid-download bumps main's checkGeneration and resets progress state,
// so the download silently loses its ability to report completion even
// though the file swap itself still happens in the background. Once
// wired, the channel select's own 'change' handler is the only thing
// that needs to tell main about a channel switch.
if (!_appUpdatesWired) {
try {
// Render from THIS call's own result (same reasoning as the check
// button and the 'change' handler below), not just catch its
// errors. The unconditional renderStatus() at the bottom of this
// function fires a SEPARATE getStatus() round-trip immediately
// after — if that resolves before main has processed this
// setChannel() (e.g. main is still on its 'stable' boot default),
// the UI would render 'unsupported' and — since this call's own
// eventual success was never rendered — get stuck there
// permanently, even once main correctly switches channel a moment
// later. Rendering here too means whichever of the two calls
// resolves LAST wins and shows the true state, regardless of
// which order they land in.
void Promise.resolve(updateApi.setChannel(stored)).then((result) => {
_appUpdateAckedChannel = stored;
renderFrom(result);
}).catch((e) => {
console.warn('[updater] setChannel(initial) failed:', e);
});
} catch (e) {
console.warn('[updater] setChannel(initial) threw:', e);
}
}
if (!_appUpdatesWired) {
@@ -330,56 +482,82 @@ export function setupAppUpdates() {
channelSelect.addEventListener('change', async () => {
const val = channelSelect.value;
if (!APP_UPDATE_CHANNELS.includes(val)) return;
try { localStorage.setItem('feedBack-update-channel', val); localStorage.removeItem('slopsmith-update-channel'); } catch (_) {}
console.log('[update-diag] user switched channel to', val);
try {
// Await setChannel so the status line reflects what actually
// happened — rendering "Channel set" unconditionally would
// mislead users when the IPC rejects.
await Promise.resolve(updateApi.setChannel(val));
renderStatus(`Channel set to ${val}.`);
// Render from setChannel()'s own return value (same reasoning
// as the check button: it's computed synchronously at the
// moment of the switch, so it can't be stale, unlike a
// follow-up getStatus() call).
const result = await Promise.resolve(updateApi.setChannel(val));
// Only persist once main has actually acknowledged the switch —
// a failed setChannel() must never leave localStorage (or the
// dropdown) ahead of what main is really using.
_appUpdateAckedChannel = val;
try { localStorage.setItem('feedBack-update-channel', val); localStorage.removeItem('slopsmith-update-channel'); } catch (_) {}
renderFrom(result, `Channel set to ${val}.`);
} catch (e) {
console.warn('[updater] setChannel failed:', e);
channelSelect.value = _appUpdateAckedChannel ?? 'stable';
renderStatus(`Failed to set channel to ${val}: ${e?.message || e}`);
}
});
checkBtn.addEventListener('click', async () => {
// In restart mode (set by renderFrom once an update is staged) the
// button applies the update instead of checking again.
if (checkBtn.dataset.mode === 'restart') {
console.log('[update-diag] user clicked Restart now');
checkBtn.disabled = true;
checkBtn.textContent = 'Restarting…';
try {
const r = await updateApi.apply();
if (r?.status === 'error') {
console.warn('[updater] apply returned error:', r.message || 'unknown');
renderFrom(r, 'Restart failed.');
}
// On success the app quits + relaunches — nothing to render.
} catch (e) {
console.warn('[updater] apply failed:', e);
statusEl.textContent = `Restart failed: ${e?.message || e}`;
checkBtn.textContent = 'Restart now';
checkBtn.disabled = false;
}
return;
}
console.log('[update-diag] user clicked Check for updates');
checkBtn.disabled = true;
statusEl.textContent = 'Checking for updates…';
let reEnableBtn = true;
let result;
try {
const result = await updateApi.checkNow();
const status = result?.status || 'unknown';
let msg;
switch (status) {
case 'idle':
msg = "You're on the newest version in this channel.";
break;
case 'downloading':
msg = 'Update available — downloading…';
break;
case 'downloaded':
msg = 'Update downloaded — restart to apply.';
break;
case 'unsupported':
reEnableBtn = false;
showLinuxFallback('Auto-update is not available on Linux.');
return;
case 'error':
msg = `Update check failed${result?.message ? `: ${result.message}` : '.'}`;
break;
default:
msg = `Update check returned: ${status}`;
}
renderStatus(msg);
// The Linux check returns immediately (any download runs in the
// background).
result = await updateApi.checkNow();
} catch (e) {
console.warn('[updater] checkNow failed:', e);
statusEl.textContent = `Update check failed: ${e?.message || e}`;
} finally {
if (reEnableBtn) checkBtn.disabled = false;
checkBtn.disabled = false;
return;
}
// Render straight from checkNow()'s own return value rather than a
// follow-up getStatus() call. checkNow() computes that value
// synchronously at the moment it decides the outcome, so it can't
// be stale; a separate getStatus() round-trip right after it can
// race with anything that resets state in between (a concurrent
// channel switch, another in-flight check settling) and show a
// blanked "up to date · last checked never" even though this check
// just succeeded.
renderFrom(result);
});
// Main-process events (checkNow/download decisions in update-manager.ts)
// are invisible to this page's console — forward them into it so a
// single "Export Diagnostics" click captures both sides of the story.
if (typeof updateApi.onDiag === 'function') {
updateApi.onDiag((payload) => {
console.log('[update-diag:main]', payload?.message, payload?.data ? JSON.stringify(payload.data) : '');
});
}
_appUpdatesWired = true;
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+100
View File
@@ -0,0 +1,100 @@
// Generic gamepad menu navigation: Tab-order emulation.
//
// Every v3 screen except v3-songs (which has its own 2D grid nav) is built from
// real, natively-focusable <button>/<a> elements, so real Tab/Shift+Tab and real
// Enter/Space already work perfectly. The gap is that nothing ever calls
// .focus() on anything, and gamepad.js only ever synthesizes Arrow keydowns —
// it never sends Tab (browsers don't focus-traverse on a synthetic Tab anyway).
// This fills that gap by moving focus through the same set of elements Tab
// already visits, one step per Arrow press, treating Down/Right as "next" and
// Up/Left as "previous".
//
// Gated on !e.isTrusted so this NEVER touches real keyboard/mouse users — it
// only ever reacts to gamepad.js's synthetic events. Also bails whenever a more
// specific handler already claimed the key (songs.js's grid nav, shortcuts.js's
// legacy library arrow-nav, or the shortcuts registry's player-scope seek
// shortcuts all call preventDefault() before this listener runs, since script
// tag order puts them earlier in the document than this file).
(function () {
'use strict';
var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
var ARROWS = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 };
var TEXT_INPUT_TYPES = ['text', 'search', 'email', 'url', 'tel', 'password', 'number'];
function visible(el) {
return el.offsetParent !== null;
}
function focusScopeRoot() {
var modal = document.querySelector('[role="dialog"][aria-modal="true"], .feedBack-modal');
if (modal && visible(modal)) return [modal];
var nav = document.getElementById('v3-nav');
var screen = document.querySelector('.screen.active');
return [nav, screen].filter(Boolean);
}
function focusables() {
var roots = focusScopeRoot();
var els = [];
roots.forEach(function (root) {
Array.prototype.push.apply(els, root.querySelectorAll(FOCUSABLE));
});
return els.filter(visible);
}
function isTextInput(el) {
if (!el) return false;
if (el.tagName === 'TEXTAREA' || el.isContentEditable) return true;
return el.tagName === 'INPUT' && TEXT_INPUT_TYPES.includes((el.type || 'text').toLowerCase());
}
document.addEventListener('keydown', function (e) {
if (e.isTrusted || e.defaultPrevented) return;
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
// Chromium doesn't run the native "Enter/Space activates the focused
// link/button" default action for untrusted synthetic keydowns, even
// when dispatched straight at the focused element (confirmed by
// testing) — so without this, a focused sidebar link or dashboard
// button just sits there forever. click() works for untrusted events.
var active = document.activeElement;
if (active && active !== document.body && !isTextInput(active)) active.click();
return;
}
if (e.key === 'Escape') {
// Only 'player' and 'settings' have a registered Escape shortcut
// (shortcuts.js); every other screen (v3-songs, v3-plugins,
// v3-playlists, ...) leaves B with nothing to do — confirmed on-device,
// players get stuck unable to leave the library or any other screen.
// The app never pushes history entries on navigation (shell.js
// deliberately doesn't reflect screen changes into location.hash), so
// history.back() isn't a real "undo the last screen" — a fixed target
// is. Prefer an existing in-screen back button if one is visible
// (reuses each screen's own drill-down logic for free: v3-songs'
// artist/album pages, v3-playlists' list<->detail view), else fall
// back to the main menu, matching the direct showScreen() call the
// settings Escape shortcut already uses.
// querySelector alone would only ever look at the first match in
// DOM order across all three selectors — screens stay in the DOM
// (hidden, not removed) when you navigate away, so a hidden back
// button from a screen you're not on can sort before the visible
// one that actually applies. Check every match for visibility.
var backBtns = document.querySelectorAll('[data-ap-back], [data-albums-back], #v3-pl-back');
var backBtn = Array.prototype.find.call(backBtns, visible);
if (backBtn) backBtn.click();
else if (window.showScreen) window.showScreen('v3-home');
return;
}
var dir = ARROWS[e.key];
if (!dir) return;
var els = focusables();
if (!els.length) return;
var idx = els.indexOf(document.activeElement);
var next = idx === -1 ? 0 : Math.max(0, Math.min(els.length - 1, idx + dir));
els[next].focus();
});
})();
+195
View File
@@ -0,0 +1,195 @@
// Gamepad/controller support.
//
// Rather than a parallel gamepad->action mapping table, this polls
// navigator.getGamepads() and dispatches synthetic keydown events onto
// document with the same key/code pairs a physical keyboard would send.
// static/js/shortcuts.js's existing dispatcher (scope checks, text-field/
// modal guards, library grid nav, player shortcuts) handles the rest.
//
// Steam Deck: Steam Input re-emits the Deck's controls as a standard
// XInput-style virtual pad (both in Gaming Mode and in Desktop Mode when
// launched via a non-Steam shortcut with a controller template), so this
// reports mapping: 'standard' and the button layout below lines up with
// the Deck's physical ABXY. If a pad reports a non-standard mapping
// (e.g. raw HID with no Steam Input in between), this no-ops rather than
// guessing button order.
//
// Plain non-module script; degrades to a no-op without the Gamepad API.
(function () {
'use strict';
if (typeof navigator === 'undefined' || !navigator.getGamepads) return;
var BUTTON_KEYS = {
// Bottom face button (Xbox A / PS Cross "X") — play/pause on the player
// screen; also activates the currently-selected library card, since
// Space is already treated as an activation key there alongside Enter.
0: { key: ' ', code: 'Space' },
1: { key: 'Escape', code: 'Escape' }, // Xbox B / PS Circle
// 2 (Xbox X / PS Square) intentionally unmapped — undecided.
};
var RAIL_REVEAL_BUTTON = 3; // Y — reveals the player screen's left tool rail
// The player rail (#v3-player-rail) has no keyboard shortcut to reuse — it's
// shown via CSS on #v3-railzone:hover or :focus-within (see v3.css). So
// instead of a synthetic keydown, this directly focuses the rail's first
// icon, which the existing :focus-within rule already reveals it for —
// the same mechanism a Tab-key user gets for free.
function revealPlayerRail() {
var active = document.querySelector('.screen.active');
if (!active || active.id !== 'player') return;
var icon = document.querySelector('#v3-player-rail .v3-rail-icon');
if (icon) icon.focus();
}
var DPAD_BUTTONS = {
12: { key: 'ArrowUp', code: 'ArrowUp' },
13: { key: 'ArrowDown', code: 'ArrowDown' },
14: { key: 'ArrowLeft', code: 'ArrowLeft' },
15: { key: 'ArrowRight', code: 'ArrowRight' },
};
var STICK_DEADZONE = 0.5;
var REPEAT_DELAY_MS = 400;
var REPEAT_INTERVAL_MS = 120;
var polling = false;
var buttonWasDown = {}; // index -> bool, for edge-detection (no repeat)
var dirWasDown = {}; // 'up'/'down'/'left'/'right' -> bool
var dirRepeatAt = {}; // 'up'/'down'/'left'/'right' -> timestamp of next repeat
var connectedIndices = {}; // gamepad.index -> true, tracks which slots we've announced
function fireKey(spec) {
// Dispatch on the focused element (falling back to document when nothing
// is focused), not document itself. document.activeElement is always an
// ancestor-inclusive descendant of document, so this still bubbles up
// through every existing document-level listener exactly as before — but
// now a focused <button>/<a> also gets its native Enter/Space activation
// (which never fires for a document-targeted event, since that native
// behavior is wired to the genuinely-focused element receiving the key),
// and any element-scoped keydown handler sees it too.
(document.activeElement || document).dispatchEvent(new KeyboardEvent('keydown', {
key: spec.key, code: spec.code, bubbles: true, cancelable: true,
}));
}
function pollButtons(gp) {
for (var i = 0; i < gp.buttons.length; i++) {
var down = gp.buttons[i].pressed;
if (down && !buttonWasDown[i]) {
if (i === RAIL_REVEAL_BUTTON) revealPlayerRail();
else if (BUTTON_KEYS[i]) fireKey(BUTTON_KEYS[i]);
}
buttonWasDown[i] = down;
}
}
function stickDirections(gp) {
var x = gp.axes[0] || 0;
var y = gp.axes[1] || 0;
return {
left: x < -STICK_DEADZONE,
right: x > STICK_DEADZONE,
up: y < -STICK_DEADZONE,
down: y > STICK_DEADZONE,
};
}
function pollDirection(name, spec, down, now) {
var wasDown = !!dirWasDown[name];
if (down && !wasDown) {
fireKey(spec);
dirRepeatAt[name] = now + REPEAT_DELAY_MS;
} else if (down && wasDown && now >= (dirRepeatAt[name] || Infinity)) {
fireKey(spec);
dirRepeatAt[name] = now + REPEAT_INTERVAL_MS;
}
dirWasDown[name] = down;
}
function pollDpad(gp, now) {
var stick = stickDirections(gp);
Object.keys(DPAD_BUTTONS).forEach(function (idx) {
var spec = DPAD_BUTTONS[idx];
var name = spec.key.replace('Arrow', '').toLowerCase();
var down = (gp.buttons[idx] && gp.buttons[idx].pressed) || stick[name];
pollDirection(name, spec, down, now);
});
}
// A disconnected gamepad's slot stays in the array (gp.connected flips to
// false) rather than being removed — a plain truthiness check on the array
// entry treats a stale, frozen-state disconnected pad as "still there"
// forever, which both swallows the disconnect notice and (if the real
// reconnected pad lands at a different index) reads dead input forever.
function firstLiveStandardPad() {
var pads = navigator.getGamepads ? navigator.getGamepads() : [];
for (var i = 0; i < pads.length; i++) {
var p = pads[i];
if (p && p.connected && p.mapping === 'standard') return p;
}
return null;
}
// Same standard-mapping filter as firstLiveStandardPad — otherwise a
// still-connected non-standard raw mirror (or the real pad simply
// reporting a different mapping) can mask the actual pad's disconnect:
// the toast never fires and polling never stops, even though the pad
// this module can act on is gone.
function anyLiveStandardPad() {
var pads = navigator.getGamepads ? navigator.getGamepads() : [];
for (var i = 0; i < pads.length; i++) {
var p = pads[i];
if (p && p.connected && p.mapping === 'standard') return true;
}
return false;
}
function tick() {
var gp = firstLiveStandardPad();
if (gp) {
pollButtons(gp);
pollDpad(gp, performance.now());
}
if (polling) requestAnimationFrame(tick);
}
function notify(title, icon) {
if (window.fbNotify && typeof window.fbNotify.show === 'function') {
window.fbNotify.show({ title: title, icon: icon, accent: '#0ea5e9', durationMs: 3000 });
}
}
window.addEventListener('gamepadconnected', function (e) {
var idx = e.gamepad && e.gamepad.index;
// Non-standard slots (raw HID mirrors, or anything this module can't
// safely act on) are never tracked/toasted/polled for — only ever
// treat a standard-mapped pad as "a controller connected". Keeping a
// non-standard slot out of connectedIndices also keeps it out of
// anyLiveStandardPad's count, so it can't mask a real disconnect.
if (!e.gamepad || e.gamepad.mapping !== 'standard') return;
if (connectedIndices[idx]) return; // already-announced slot re-firing (focus regain, etc.)
// On the Deck, Steam Input mirrors a real pad with 1-2 virtual XInput
// slots of its own (same physical button presses, extra indices) — only
// toast for the first slot seen so plugging in one controller doesn't
// spam three "connected" notices.
var isFirstSlot = Object.keys(connectedIndices).length === 0;
connectedIndices[idx] = true;
if (isFirstSlot) notify('Controller connected', '🎮');
buttonWasDown = {};
dirWasDown = {};
dirRepeatAt = {};
if (!polling) {
polling = true;
requestAnimationFrame(tick);
}
});
window.addEventListener('gamepaddisconnected', function (e) {
var idx = e.gamepad && e.gamepad.index;
delete connectedIndices[idx];
if (!anyLiveStandardPad()) {
polling = false;
notify('Controller disconnected', '🔌');
}
});
})();
+10 -2
View File
@@ -133,6 +133,7 @@
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). -->
<script type="module" src="/static/capabilities/library-card-actions.js"></script>
<script type="module" src="/static/capabilities/visualization.js"></script>
<script type="module" src="/static/capabilities/chart-transform.js"></script>
<script type="module" src="/static/capabilities/note-detection.js"></script>
<script type="module" src="/static/capabilities/midi-input.js"></script>
<script type="module" src="/static/capabilities/interface-scale.js"></script>
@@ -741,6 +742,7 @@
<option value="rc">Release candidate</option>
<option value="beta">Beta</option>
<option value="alpha">Alpha</option>
<option value="nightly">Nightly</option>
</select>
<button id="app-update-check-now"
class="bg-accent hover:bg-accent-light px-4 py-2.5 rounded-xl text-sm font-medium text-white transition disabled:opacity-50">
@@ -749,8 +751,8 @@
</div>
<p id="app-update-status" class="text-xs text-gray-500 fb-srow-wide">Loading updater status…</p>
<p id="app-update-linux-note" class="hidden text-xs text-yellow-300 fb-srow-wide">
Auto-update is not available on Linux
<a href="https://github.com/got-feedback/feedback-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download new versions from GitHub Releases</a>.
Auto-update on Linux only works for the AppImage build on the Nightly channel
<a href="https://github.com/got-feedBack/feedBack-desktop/releases" target="_blank" rel="noopener" class="text-accent hover:text-accent-light underline">download other versions from GitHub Releases</a>.
</p>
</div>
<!-- Window options — desktop-only; setupWindowOptions() unhides. -->
@@ -1192,6 +1194,10 @@
<button id="arr-default-pin" type="button" onclick="pinCurrentArrangementDefault()" aria-pressed="false" aria-label="Select an arrangement to make it the default" class="v3-pop-pin" title="Select an arrangement to make it the default"></button>
</span>
</div>
<div class="v3-pop-row hidden" id="v3-drum-part-row">
<span class="v3-pop-label">Drum part</span>
<select id="drum-part-select" onchange="changeDrumPart(this.value)" class="v3-pop-select max-w-[130px]" title="Which drum chart to play — a song can carry several"></select>
</div>
<div class="v3-pop-row">
<span class="v3-pop-label" id="mastery-slider-label">Difficulty</span>
<span class="flex items-center gap-2">
@@ -1291,6 +1297,7 @@
<script defer src="/static/v3/theme-core.js"></script>
<script defer src="/static/v3/progression-core.js"></script>
<script defer src="/static/v3/notifications.js"></script>
<script defer src="/static/v3/gamepad.js"></script>
<script defer src="/static/v3/profile.js"></script>
<script defer src="/static/v3/progress.js"></script>
<script defer src="/static/v3/shop.js"></script>
@@ -1321,6 +1328,7 @@
the cover picker (window.__fbOpenImagePicker). -->
<script defer src="/static/v3/image-picker.js"></script>
<script defer src="/static/v3/songs.js"></script>
<script defer src="/static/v3/gamepad-nav.js"></script>
<script defer src="/static/v3/lessons.js"></script>
<script defer src="/static/v3/dashboard.js"></script>
<script defer src="/static/v3/settings.js"></script>
+4455 -4317
View File
File diff suppressed because it is too large Load Diff
+105
View File
@@ -0,0 +1,105 @@
// Unit tests for the App-updates panel's status → view-model state machine
// (_appUpdateStatusView in static/js/settings.js). settings.js pulls a large
// ES-module graph (highway-colors, library, player-controls), so rather than
// import it, the pure function is sliced out of source and evaluated on its
// own — it's DOM-free by construction, which is the whole point of extracting
// it. The slice marker is asserted so a rename fails loudly instead of testing
// nothing.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'js', 'settings.js'), 'utf8');
function extractFn(source, name) {
const marker = `export function ${name}`;
const start = source.indexOf(marker);
assert.notEqual(start, -1, `${name} must exist in settings.js`);
// Skip the parameter list first (it contains a destructured `{ … } = {}`
// default, so the body's opening brace isn't the first `{` after the name).
let pd = 0, i = source.indexOf('(', start);
for (; i < source.length; i++) {
if (source[i] === '(') pd++;
else if (source[i] === ')' && --pd === 0) break;
}
const open = source.indexOf('{', i);
let depth = 0;
for (let j = open; j < source.length; j++) {
if (source[j] === '{') depth++;
else if (source[j] === '}' && --depth === 0) {
return source.slice(start, j + 1).replace('export function', 'function');
}
}
throw new Error(`unbalanced braces extracting ${name}`);
}
const _appUpdateStatusView = new Function(
`${extractFn(SRC, '_appUpdateStatusView')}\nreturn _appUpdateStatusView;`,
)();
const FMT = () => 'just now';
const view = (s, opts) => _appUpdateStatusView(s, { channelValue: 'nightly', fmtTimestamp: FMT, ...opts });
test('null status → unavailable', () => {
assert.deepEqual(_appUpdateStatusView(null), { kind: 'unavailable' });
});
test('unsupported / any linux-platform status → unsupported', () => {
assert.equal(view({ status: 'unsupported', platform: 'linux' }).kind, 'unsupported');
assert.equal(view({ status: 'idle', platform: 'linux' }).kind, 'unsupported',
'a stray platform:linux still routes to the fallback, matching renderFrom');
});
test('idle shows "up to date" with the formatted last-checked time, controls enabled', () => {
const v = view({ status: 'idle', currentVersion: '1.2.3', channel: 'nightly', lastChecked: 123 });
assert.equal(v.kind, 'status');
assert.equal(v.line, 'Version 1.2.3 · nightly · up to date · last checked just now');
assert.equal(v.btnLabel, 'Check for updates');
assert.equal(v.btnMode, 'check');
assert.equal(v.btnDisabled, false);
assert.equal(v.channelDisabled, false);
});
test('checking and downloading disable the button AND lock the channel selector', () => {
const chk = view({ status: 'checking', currentVersion: '1', channel: 'nightly' });
assert.equal(chk.btnDisabled, true);
assert.equal(chk.channelDisabled, true);
assert.match(chk.line, /checking for updates…$/);
const dl = view({ status: 'downloading', currentVersion: '1', channel: 'nightly', percent: 42 });
assert.equal(dl.btnDisabled, true);
assert.equal(dl.channelDisabled, true);
assert.match(dl.line, /downloading update… 42%$/);
});
test('downloading without a percent falls back to the indeterminate label', () => {
const v = view({ status: 'downloading', currentVersion: '1', channel: 'nightly', percent: null });
assert.match(v.line, /update available — downloading…$/);
});
test('downloaded flips the button to Restart when apply() exists', () => {
const v = view({ status: 'downloaded', currentVersion: '1', channel: 'nightly' }, { canApply: true });
assert.equal(v.btnLabel, 'Restart now');
assert.equal(v.btnMode, 'restart');
assert.equal(v.channelDisabled, false, 'staged is not in-flight — channel stays switchable');
assert.match(v.line, /update ready$/);
});
test('downloaded on an older bridge (no apply) stays a plain check button with text-only guidance', () => {
const v = view({ status: 'downloaded', currentVersion: '1', channel: 'nightly' }, { canApply: false });
assert.equal(v.btnMode, 'check');
assert.equal(v.btnLabel, 'Check for updates');
assert.match(v.line, /update ready — restart to apply$/);
});
test('error surfaces the message, or a generic fallback when absent', () => {
assert.match(view({ status: 'error', currentVersion: '1', channel: 'nightly', message: 'boom' }).line, /update error: boom$/);
assert.match(view({ status: 'error', currentVersion: '1', channel: 'nightly' }).line, /update check failed$/);
});
test('missing version and channel fall back to "?" and the dropdown value', () => {
const v = view({ status: 'idle', lastChecked: 0 }, { channelValue: 'beta' });
assert.match(v.line, /^Version \? · beta · /);
});
+335
View File
@@ -0,0 +1,335 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const CHART_TRANSFORM_JS = path.join(ROOT, 'static', 'capabilities', 'chart-transform.js');
const STORAGE_KEY = 'feedBack.chartTransform.selectedProviderId';
const PLUGIN_ID = 'example_plugin';
const PROVIDER_ID = 'example-transform';
const PROVIDER_LABEL = 'Example Transform';
function makeFakeHighway() {
const calls = { set: [], refresh: 0 };
return {
calls,
setChartTransform(p) { calls.set.push(p); },
refreshChartTransform() { calls.refresh += 1; },
getChartTransform() { return calls.set.length ? calls.set[calls.set.length - 1] : null; },
};
}
function loadChartTransform(options = {}) {
const window = createWindow(options);
// The real bus provides feedBack.on; the harness only has emit →
// dispatchEvent. Shim `on` the same way app.js implements it so the
// module's bus mirroring (song:ready, chart-transform-failed) is live.
window.feedBack.on = (type, handler) => window.addEventListener(type, handler);
if (options.highway) window.highway = options.highway;
if (options.persistedSelection) window.localStorage.setItem(STORAGE_KEY, options.persistedSelection);
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(CHART_TRANSFORM_JS, 'utf8'), context, { filename: CHART_TRANSFORM_JS });
return window;
}
function captureEvents(api, eventNames) {
const events = [];
for (const name of eventNames) {
api.subscribe(name, (detail) => events.push(detail));
}
return events;
}
async function registerProvider(api, overrides = {}) {
return api.dispatch({
capability: 'chart-transform', command: 'register-provider',
source: overrides.source || PLUGIN_ID,
payload: {
providerId: overrides.providerId || PROVIDER_ID,
label: overrides.label || PROVIDER_LABEL,
transform: overrides.transform || ((input) => ({ notes: input.notes })),
},
});
}
test('chart-transform domain registers a safe provider-coordinator owner', () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
const pipeline = api.inspect('chart-transform');
assert.ok(pipeline, 'chart-transform pipeline exists');
const owner = (pipeline.participants || []).find(p => p.pluginId === 'core.chart-transform');
assert.ok(owner, 'core.chart-transform owner registered');
assert.equal(owner.safety, 'safe');
assert.ok(owner.commands.includes('select-provider'));
assert.ok(owner.commands.includes('refresh'));
assert.equal(window.feedBack.chartTransformDomain.version, 1);
});
test('register-provider requires a transform function', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
const result = await api.dispatch({
capability: 'chart-transform', command: 'register-provider',
source: PLUGIN_ID, payload: { providerId: PROVIDER_ID },
});
assert.equal(result.outcome, 'degraded');
assert.match(result.reason, /transform\(input\) function/);
});
test('register + select installs the provider on the highway and persists', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
const events = captureEvents(api, [
'chart-transform:provider-registered',
'chart-transform:transform-changed',
]);
const reg = await registerProvider(api);
assert.equal(reg.outcome, 'handled');
assert.ok(api.inspect('chart-transform').participants.some(p => p.pluginId === PLUGIN_ID));
const sel = await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
assert.equal(sel.outcome, 'handled');
assert.equal(sel.payload.active, PROVIDER_ID);
assert.equal(sel.payload.installed, true);
assert.equal(highway.calls.set.length, 1);
assert.equal(highway.calls.set[0].id, PROVIDER_ID);
assert.equal(typeof highway.calls.set[0].transform, 'function');
assert.equal(window.localStorage.getItem(STORAGE_KEY), PROVIDER_ID);
const names = events.map(e => e.event);
assert.ok(names.includes('provider-registered'));
assert.ok(names.includes('transform-changed'));
});
test('select-provider with an unknown id degrades', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
const result = await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: 'nope' },
});
assert.equal(result.outcome, 'degraded');
assert.match(result.reason, /Unknown chart-transform provider/);
});
test('selection without a highway is kept and installed on song:ready', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
await registerProvider(api);
const sel = await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
assert.equal(sel.outcome, 'handled');
assert.equal(sel.payload.installed, false, 'no highway yet');
const highway = makeFakeHighway();
window.highway = highway;
window.feedBack.emit('song:ready', {});
assert.equal(highway.calls.set.length, 1);
assert.equal(highway.calls.set[0].id, PROVIDER_ID);
assert.equal(window.feedBack.chartTransformDomain.snapshot().installed, true);
});
test('a persisted selection restores when its provider registers', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway, persistedSelection: PROVIDER_ID });
const api = window.feedBack.capabilities;
await registerProvider(api);
const snapshot = window.feedBack.chartTransformDomain.snapshot();
assert.equal(snapshot.active, PROVIDER_ID);
assert.equal(snapshot.activeSource, 'restore-selection');
assert.equal(highway.calls.set.length, 1);
});
test('unregister is registrant-only and detaches the active provider', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
const denied = await api.dispatch({
capability: 'chart-transform', command: 'unregister-provider',
source: 'someone_else', payload: { providerId: PROVIDER_ID },
});
assert.equal(denied.outcome, 'degraded');
assert.match(denied.reason, /original registrant/);
const ok = await api.dispatch({
capability: 'chart-transform', command: 'unregister-provider',
source: PLUGIN_ID, payload: { providerId: PROVIDER_ID },
});
assert.equal(ok.outcome, 'handled');
const snapshot = window.feedBack.chartTransformDomain.snapshot();
assert.equal(snapshot.active, null);
assert.equal(snapshot.providers.length, 0);
// Detach = a trailing setChartTransform(null) on the highway.
assert.equal(highway.calls.set[highway.calls.set.length - 1], null);
// Persisted selection survives so re-registration re-activates.
assert.equal(window.localStorage.getItem(STORAGE_KEY), PROVIDER_ID);
});
test('unregister keeps a participant while another provider still references it', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
await registerProvider(api, { providerId: 'provider-a', label: 'Provider A' });
await registerProvider(api, { providerId: 'provider-b', label: 'Provider B' });
let participant = api.inspect('chart-transform').participants
.find(p => p.pluginId === PLUGIN_ID);
assert.deepEqual(Array.from(participant.providerPolicy.providerIds), ['provider-a', 'provider-b']);
assert.deepEqual(
Array.from(participant.providerPolicy.providers, p => ({ id: p.id, label: p.label })),
[{ id: 'provider-a', label: 'Provider A' }, { id: 'provider-b', label: 'Provider B' }],
);
await api.dispatch({
capability: 'chart-transform', command: 'unregister-provider',
source: PLUGIN_ID, payload: { providerId: 'provider-b' },
});
participant = api.inspect('chart-transform').participants
.find(p => p.pluginId === PLUGIN_ID);
assert.ok(participant, 'the shared participant remains registered');
assert.deepEqual(Array.from(participant.providerPolicy.providerIds), ['provider-a']);
assert.deepEqual(
Array.from(participant.providerPolicy.providers, p => ({ id: p.id, label: p.label })),
[{ id: 'provider-a', label: 'Provider A' }],
);
assert.deepEqual(
Array.from(window.feedBack.chartTransformDomain.snapshot().providers, p => p.id),
['provider-a'],
);
await api.dispatch({
capability: 'chart-transform', command: 'unregister-provider',
source: PLUGIN_ID, payload: { providerId: 'provider-a' },
});
assert.ok(!api.inspect('chart-transform').participants
.some(p => p.pluginId === PLUGIN_ID), 'the final removal unregisters the participant');
});
test('clear-provider clears the highway hook and the persisted selection', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
const result = await api.dispatch({
capability: 'chart-transform', command: 'clear-provider', source: 'settings_ui',
});
assert.equal(result.outcome, 'handled');
assert.equal(highway.calls.set[highway.calls.set.length - 1], null);
assert.equal(window.localStorage.getItem(STORAGE_KEY), null);
});
test('refresh re-runs the installed transform', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
const result = await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: PLUGIN_ID });
assert.equal(result.outcome, 'handled');
assert.equal(result.payload.refreshed, true);
assert.equal(highway.calls.refresh, 1);
});
test('announced highway instances (splitscreen panels) get the active transform', async () => {
const primary = makeFakeHighway();
const window = loadChartTransform({ highway: primary });
const api = window.feedBack.capabilities;
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
assert.equal(window.feedBack.chartTransformDomain.snapshot().surfaces, 1);
// A splitscreen panel announces its own createHighway() instance.
const panel = makeFakeHighway();
window.feedBack.emit('highway:created', { highway: panel });
assert.equal(panel.calls.set.length, 1, 'panel receives the active transform');
assert.equal(panel.calls.set[0].id, PROVIDER_ID);
assert.equal(window.feedBack.chartTransformDomain.snapshot().surfaces, 2);
// Refresh reaches every surface.
await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: PLUGIN_ID });
assert.equal(primary.calls.refresh, 1);
assert.equal(panel.calls.refresh, 1);
// Clearing detaches every surface.
await api.dispatch({ capability: 'chart-transform', command: 'clear-provider', source: 'settings_ui' });
assert.equal(primary.calls.set[primary.calls.set.length - 1], null);
assert.equal(panel.calls.set[panel.calls.set.length - 1], null);
});
test('a panel announced before any selection installs on later select', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
const panel = makeFakeHighway();
window.feedBack.emit('highway:created', { highway: panel });
await registerProvider(api);
const sel = await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
assert.equal(sel.outcome, 'handled');
assert.equal(panel.calls.set.length, 1);
assert.equal(panel.calls.set[0].id, PROVIDER_ID);
});
test('highway failure events expose a fixed public reason', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
const events = captureEvents(api, ['chart-transform:transform-failed']);
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
window.feedBack.emit('highway:chart-transform-failed', {
id: PROVIDER_ID,
reason: 'token=secret https://example.test/private chart={notes:[...]}',
});
const snapshot = window.feedBack.chartTransformDomain.snapshot();
assert.equal(snapshot.lastFailure.providerId, PROVIDER_ID);
assert.equal(snapshot.lastFailure.reason, 'Chart transform provider failed');
assert.equal(events.length, 1);
assert.equal(events[0].payload.reason, 'Chart transform provider failed');
});
test('diagnostics contribution carries the schema and no song identity fields', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
await registerProvider(api);
const contributions = window.feedBack.diagnostics.snapshotContributions();
const diag = contributions['chart-transform-capability'];
assert.ok(diag, 'diagnostics contributed');
assert.equal(diag.schema, 'feedBack.chart_transform.diagnostics.v1');
const flat = JSON.stringify(diag);
assert.ok(!/filename|title|artist|arrangement/.test(flat), 'no song identity in diagnostics');
});
+189
View File
@@ -0,0 +1,189 @@
// Verify `countInBeats(startT)` in static/js/count-in.js sizes the count-in
// to the song's own bar rather than a hardcoded four clicks.
//
// Two behaviours are under test:
// 1. Meter — a 3/4 song gets three clicks, not four.
// 2. Pickup (anacrusis) — a first bar shorter than the meter shortens the
// count so the pickup enters on its real beat (1-beat pickup in 4/4 →
// "1 2 3", music on 4). A full four there puts the pickup where the
// downbeat belongs and the player comes in a beat late all song.
//
// The meter is read from the song_timeline beats (`window.highway.getBeats()`,
// `measure >= 0` on downbeats) because that is the only meter data the
// frontend holds — the `time_signatures` map is streamed to plugins, not
// stored here.
//
// Same extraction approach as loop_restart.test.js: pull the function source
// out of the module and evaluate it in a vm sandbox with a stubbed highway,
// rather than loading the ESM module and its DOM-coupled imports.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const COUNT_IN_JS = path.join(__dirname, '..', '..', 'static', 'js', 'count-in.js');
// Brace-match the function body out of the source. Brittle by design:
// a rename fails loudly here rather than silently skipping coverage.
function extractFunction(src, signature) {
const start = src.indexOf(signature);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found`);
const openBrace = src.indexOf('{', start + signature.length);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
if (depth !== 0) throw new Error(`extractFunction: unbalanced braces after '${signature}'`);
return src.slice(start, i);
}
const src = fs.readFileSync(COUNT_IN_JS, 'utf8');
// Drop the `export` keyword so the body evaluates as a plain declaration.
const fnSrc = extractFunction(src, 'export function countInBeats')
.replace(/^export\s+/, '');
// `beats` is the song_timeline shape: {time, measure}, measure >= 0 only on
// downbeats. `getBeats` may also be absent entirely (pre-chart / minigame).
function load(beats) {
const sandbox = {
window: beats === undefined
? { highway: {} }
: { highway: { getBeats: () => beats } },
};
vm.createContext(sandbox);
vm.runInContext(`${fnSrc}; globalThis.__fn = countInBeats;`, sandbox);
return sandbox.__fn;
}
// Build a beats array: `bars` full bars of `beatsPerBar`, optionally preceded
// by a pickup of `pickup` beats. One beat per 0.5 s throughout.
function makeBeats({ beatsPerBar = 4, bars = 4, pickup = 0 } = {}) {
const out = [];
let t = 0;
let measure = 0;
if (pickup > 0) {
for (let i = 0; i < pickup; i++) {
out.push({ time: t, measure: i === 0 ? measure : -1 });
t += 0.5;
}
measure++;
}
for (let b = 0; b < bars; b++) {
for (let i = 0; i < beatsPerBar; i++) {
out.push({ time: t, measure: i === 0 ? measure : -1 });
t += 0.5;
}
measure++;
}
return out;
}
// ── Meter ────────────────────────────────────────────────────────────────
test('countInBeats counts a full bar in 4/4', () => {
const countInBeats = load(makeBeats({ beatsPerBar: 4 }));
assert.equal(countInBeats(0), 4);
});
test('countInBeats counts three in 3/4 (was hardcoded four)', () => {
const countInBeats = load(makeBeats({ beatsPerBar: 3 }));
assert.equal(countInBeats(0), 3);
});
test('countInBeats counts six in 6/8', () => {
const countInBeats = load(makeBeats({ beatsPerBar: 6 }));
assert.equal(countInBeats(0), 6);
});
// ── Pickup (anacrusis) ───────────────────────────────────────────────────
test('countInBeats shortens the count by a 1-beat pickup in 4/4', () => {
const countInBeats = load(makeBeats({ beatsPerBar: 4, pickup: 1 }));
assert.equal(countInBeats(0), 3, 'counts 1-2-3 so the pickup lands on 4');
});
test('countInBeats shortens the count by a 2-beat pickup in 4/4', () => {
const countInBeats = load(makeBeats({ beatsPerBar: 4, pickup: 2 }));
assert.equal(countInBeats(0), 2);
});
test('countInBeats handles a pickup in 3/4', () => {
const countInBeats = load(makeBeats({ beatsPerBar: 3, pickup: 1 }));
assert.equal(countInBeats(0), 2);
});
test('countInBeats finds the meter when the song is only a pickup plus one bar', () => {
// Gap counts tie (one 1-beat gap, one 4-beat gap) — the longer bar is the
// meter, so this must be 3 rather than 0.
const countInBeats = load(makeBeats({ beatsPerBar: 4, bars: 1, pickup: 1 }));
assert.equal(countInBeats(0), 3);
});
// ── Resuming somewhere other than the song top ───────────────────────────
test('countInBeats counts a full bar at a mid-song downbeat, pickup notwithstanding', () => {
const beats = makeBeats({ beatsPerBar: 4, pickup: 1 });
const countInBeats = load(beats);
// Index 5 is the downbeat of the second full bar (1 pickup + 4 beats).
assert.equal(beats[5].measure >= 0, true, 'fixture sanity: index 5 is a downbeat');
assert.equal(countInBeats(beats[5].time), 4);
});
test('countInBeats counts a mid-song meter change by that bar, not as a pickup', () => {
// 4/4 throughout, except one 3-beat bar at index 8. Treating a short bar
// anywhere but the song's first as a pickup would count a single click.
const beats = [];
let t = 0;
const push = (n, measure) => {
for (let i = 0; i < n; i++) { beats.push({ time: t, measure: i === 0 ? measure : -1 }); t += 0.5; }
};
push(4, 0); push(4, 1); push(3, 2); push(4, 3); push(4, 4);
const countInBeats = load(beats);
assert.equal(beats[8].measure, 2, 'fixture sanity: index 8 opens the 3-beat bar');
assert.equal(countInBeats(beats[8].time), 3, 'counts the short bar itself');
assert.equal(countInBeats(0), 4, 'the 4/4 opening is unaffected');
});
test('countInBeats counts a full bar when resuming mid-bar', () => {
const beats = makeBeats({ beatsPerBar: 4 });
const countInBeats = load(beats);
assert.equal(countInBeats(beats[2].time), 4); // third beat of bar 1
});
test('countInBeats tolerates a start time slightly past the beat (seek slop)', () => {
const countInBeats = load(makeBeats({ beatsPerBar: 4, pickup: 1 }));
assert.equal(countInBeats(0.02), 3);
});
// ── Fallbacks ────────────────────────────────────────────────────────────
test('countInBeats falls back to four without a beats array', () => {
assert.equal(load(undefined)(0), 4, 'no getBeats (pre-chart / minigame)');
assert.equal(load([])(0), 4, 'empty beats');
assert.equal(load(null)(0), 4, 'null beats');
});
test('countInBeats falls back to four when beats carry no downbeat labels', () => {
const beats = [0, 0.5, 1.0, 1.5, 2.0].map(time => ({ time, measure: -1 }));
assert.equal(load(beats)(0), 4);
});
test('countInBeats falls back to four with only one downbeat', () => {
const beats = [
{ time: 0, measure: 0 },
{ time: 0.5, measure: -1 },
{ time: 1.0, measure: -1 },
];
assert.equal(load(beats)(0), 4);
});
test('countInBeats counts a full bar past the last beat', () => {
const beats = makeBeats({ beatsPerBar: 3 });
assert.equal(load(beats)(9999), 3);
});
+182
View File
@@ -0,0 +1,182 @@
// Behavioral tests for static/v3/gamepad.js — the controller polling state
// machine. gamepad.js is a plain IIFE with no exports, so it's loaded into a vm
// with a fake navigator/window/document and driven frame-by-frame through a
// manual requestAnimationFrame queue. This exercises the parts that were only
// ever checked on a real Steam Deck: standard-mapping filtering, Steam Input's
// duplicate-slot dedup, disconnect masking, button edge-detection, d-pad/stick
// key-repeat timing, and the analog-stick deadzone.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'gamepad.js'), 'utf8');
function pad(index, opts = {}) {
return {
index,
connected: opts.connected !== false,
mapping: opts.mapping || 'standard',
buttons: (opts.buttons || []).map(p => ({ pressed: !!p })),
axes: opts.axes || [0, 0],
};
}
// Load a fresh gamepad.js instance with a controllable environment.
function load() {
let pads = [];
const listeners = {};
const rafQueue = [];
const fired = []; // synthetic key codes dispatched at the focused element
const toasts = []; // {title,...} from fbNotify.show
let clock = 0;
const activeElement = { dispatchEvent(evt) { fired.push(evt.code); return true; } };
const sandbox = {
console: { log() {}, error() {} },
performance: { now: () => clock },
requestAnimationFrame: (fn) => { rafQueue.push(fn); return rafQueue.length; },
navigator: { getGamepads: () => pads },
KeyboardEvent: class { constructor(type, init) { this.type = type; Object.assign(this, init); } },
document: {
activeElement,
// revealPlayerRail() looks these up; returning null makes button 3 a no-op.
querySelector: () => null,
},
window: {
addEventListener: (t, fn) => { (listeners[t] || (listeners[t] = [])).push(fn); },
fbNotify: { show: (o) => toasts.push(o) },
},
};
vm.runInNewContext(SRC, sandbox);
const emit = (type, gamepad) => (listeners[type] || []).forEach(fn => fn({ gamepad }));
return {
setPads: (arr) => { pads = arr; },
connect: (gp) => emit('gamepadconnected', gp),
disconnect: (gp) => emit('gamepaddisconnected', gp),
tick: () => { const fn = rafQueue.shift(); if (fn) fn(); },
polling: () => rafQueue.length > 0, // a live tick re-queues itself only while polling
setClock: (t) => { clock = t; },
fired, toasts,
};
}
test('a non-standard pad is ignored entirely (no toast, no polling)', () => {
const g = load();
const p = pad(0, { mapping: 'xbox-nonstandard' });
g.setPads([p]);
g.connect(p);
assert.equal(g.toasts.length, 0);
assert.equal(g.polling(), false);
});
test('a standard pad connecting toasts once and starts polling', () => {
const g = load();
const p = pad(0);
g.setPads([p]);
g.connect(p);
assert.equal(g.toasts.length, 1);
assert.equal(g.toasts[0].title, 'Controller connected');
assert.equal(g.polling(), true);
});
test("Steam Input's duplicate virtual slots only toast once", () => {
const g = load();
const a = pad(0), b = pad(1);
g.setPads([a, b]);
g.connect(a);
g.connect(b); // same physical controller, second XInput mirror slot
assert.equal(g.toasts.length, 1, 'one physical controller = one toast');
});
test('face buttons edge-detect: fire once per press, not once per frame', () => {
const g = load();
const p = pad(0, { buttons: [true] }); // button 0 held down
g.setPads([p]);
g.connect(p);
g.tick();
g.tick(); // still held on the next frame
assert.deepEqual(g.fired, ['Space'], 'held button must not auto-repeat');
p.buttons[0].pressed = false; g.tick(); // release
p.buttons[0].pressed = true; g.tick(); // press again
assert.deepEqual(g.fired, ['Space', 'Space'], 'a fresh press fires again');
});
test('button 1 maps to Escape; button 3 (rail reveal) fires no key', () => {
const g = load();
const p = pad(0, { buttons: [false, true, false, true] });
g.setPads([p]);
g.connect(p);
g.tick();
assert.deepEqual(g.fired, ['Escape'], 'B=Escape, Y=rail-reveal (no synthetic key)');
});
test('d-pad / stick repeat: initial fire, delay, then interval repeats', () => {
const g = load();
const p = pad(0, { buttons: [] }); // no buttons; drive via the d-pad indices
p.buttons = Array.from({ length: 16 }, () => ({ pressed: false }));
p.buttons[13].pressed = true; // ArrowDown
g.setPads([p]);
g.connect(p);
g.setClock(0); g.tick(); // initial press
g.setClock(399); g.tick(); // before the 400ms repeat delay
g.setClock(400); g.tick(); // repeat delay elapsed
assert.deepEqual(g.fired, ['ArrowDown', 'ArrowDown'], 'one initial + one repeat at 400ms, nothing at 399ms');
});
test('analog stick honors the deadzone', () => {
const g = load();
const p = pad(0);
p.buttons = Array.from({ length: 16 }, () => ({ pressed: false }));
g.setPads([p]);
g.connect(p);
p.axes = [0, 0.4]; g.setClock(0); g.tick(); // below 0.5 deadzone → nothing
assert.deepEqual(g.fired, [], 'sub-deadzone deflection is ignored');
p.axes = [0.6, 0]; g.setClock(1); g.tick(); // right, past deadzone
assert.deepEqual(g.fired, ['ArrowRight']);
});
test('disconnecting one of two live slots does not stop polling or toast', () => {
const g = load();
const a = pad(0), b = pad(1);
g.setPads([a, b]);
g.connect(a); g.connect(b);
g.toasts.length = 0;
b.connected = false; // Steam mirror slot drops
g.setPads([a, b]);
g.disconnect(b);
assert.equal(g.toasts.length, 0, 'a still-live standard pad masks the mirror disconnect');
assert.equal(g.polling(), true);
});
test('disconnecting the last live pad stops polling and toasts', () => {
const g = load();
const a = pad(0);
g.setPads([a]);
g.connect(a);
a.connected = false;
g.setPads([a]);
g.disconnect(a);
assert.equal(g.toasts.some(t => t.title === 'Controller disconnected'), true);
// Drain the final queued tick; polling must not re-queue itself.
g.tick();
assert.equal(g.polling(), false);
});
test('polling acts only on the live standard pad, skipping stale/non-standard slots', () => {
const g = load();
const dead = pad(0, { connected: false, buttons: [true] }); // frozen, disconnected
const raw = pad(1, { mapping: 'raw-hid', buttons: [true] }); // non-standard
const live = pad(2, { buttons: [true] }); // standard, button 0 down
g.setPads([dead, raw, live]);
g.connect(live);
g.tick();
assert.deepEqual(g.fired, ['Space'], 'input read from the live standard pad only');
});
+157
View File
@@ -0,0 +1,157 @@
// Behavioral tests for static/v3/gamepad-nav.js — the generic Tab-order
// emulation layer. Loaded into a vm with a minimal fake DOM; the module's
// single keydown listener is captured and fed synthetic events. Covers the
// three things it does: arrow-key focus traversal (with clamping), Enter/Space
// activation via .click() (Chromium won't natively activate untrusted keys),
// and the Escape "go back" fallback — plus the !isTrusted / defaultPrevented
// gating that keeps it off real keyboard users.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const SRC = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'v3', 'gamepad-nav.js'), 'utf8');
function load() {
const state = { focused: null, clicked: [], screens: [] };
const body = { tagName: 'BODY' };
const cfg = { modal: null, nav: null, screen: null, backButtons: [], activeEl: body };
let handler = null;
function elem(opts = {}) {
return {
tagName: opts.tagName || 'BUTTON',
type: opts.type,
isContentEditable: !!opts.isContentEditable,
offsetParent: opts.visible === false ? null : {},
_focusables: opts.focusables || [],
querySelectorAll() { return this._focusables; },
focus() { state.focused = this; },
click() { state.clicked.push(this); },
};
}
const document = {
body,
get activeElement() { return cfg.activeEl; },
addEventListener(type, fn) { if (type === 'keydown') handler = fn; },
querySelector(sel) {
if (sel.includes('dialog') || sel.includes('modal')) return cfg.modal;
if (sel.includes('screen.active')) return cfg.screen;
return null;
},
getElementById(id) { return id === 'v3-nav' ? cfg.nav : null; },
querySelectorAll() { return cfg.backButtons; }, // only the Escape back-button lookup uses this
};
const sandbox = { document, window: { showScreen: (id) => state.screens.push(id) } };
vm.runInNewContext(SRC, sandbox);
const fire = (over) => handler(Object.assign({ isTrusted: false, defaultPrevented: false, key: '' }, over));
return { cfg, state, body, elem, fire };
}
// Build a screen holding `n` visible focusables; expose them for cfg.activeEl.
function screenWith(g, n) {
const items = Array.from({ length: n }, () => g.elem());
g.cfg.screen = g.elem({ focusables: items });
g.cfg.nav = g.elem({ focusables: [] });
return items;
}
test('real keyboard input (isTrusted) is never touched', () => {
const g = load();
const items = screenWith(g, 3);
g.cfg.activeEl = items[0];
g.fire({ isTrusted: true, key: 'ArrowDown' });
assert.equal(g.state.focused, null, 'trusted events must pass through untouched');
});
test('a key already handled by another listener (defaultPrevented) is skipped', () => {
const g = load();
const items = screenWith(g, 3);
g.cfg.activeEl = items[0];
g.fire({ defaultPrevented: true, key: 'ArrowDown' });
assert.equal(g.state.focused, null);
});
test('ArrowDown/Right moves to the next focusable; ArrowUp/Left to the previous', () => {
const g = load();
const items = screenWith(g, 3);
g.cfg.activeEl = items[1];
g.fire({ key: 'ArrowDown' });
assert.equal(g.state.focused, items[2], 'Down = next');
g.cfg.activeEl = items[1];
g.fire({ key: 'ArrowLeft' });
assert.equal(g.state.focused, items[0], 'Left = previous');
});
test('traversal clamps at both ends', () => {
const g = load();
const items = screenWith(g, 3);
g.cfg.activeEl = items[2];
g.fire({ key: 'ArrowDown' });
assert.equal(g.state.focused, items[2], 'no wrap past the last item');
g.cfg.activeEl = items[0];
g.fire({ key: 'ArrowUp' });
assert.equal(g.state.focused, items[0], 'no wrap before the first item');
});
test('with nothing relevant focused, the first arrow lands on the first item', () => {
const g = load();
const items = screenWith(g, 3);
g.cfg.activeEl = g.body; // not in the focusable list
g.fire({ key: 'ArrowRight' });
assert.equal(g.state.focused, items[0]);
});
test('hidden focusables are skipped (offsetParent visibility)', () => {
const g = load();
const visibleA = g.elem();
const hidden = g.elem({ visible: false });
const visibleB = g.elem();
g.cfg.screen = g.elem({ focusables: [visibleA, hidden, visibleB] });
g.cfg.nav = g.elem({ focusables: [] });
g.cfg.activeEl = visibleA;
g.fire({ key: 'ArrowDown' });
assert.equal(g.state.focused, visibleB, 'the hidden element is not a traversal stop');
});
test('Enter/Space activates the focused control via click()', () => {
const g = load();
const btn = g.elem({ tagName: 'BUTTON' });
g.cfg.activeEl = btn;
g.fire({ key: 'Enter' });
g.fire({ key: ' ' });
assert.deepEqual(g.state.clicked, [btn, btn], 'both Enter and Space activate');
});
test('activation never clicks a focused text field or the body', () => {
const g = load();
g.cfg.activeEl = g.elem({ tagName: 'INPUT', type: 'text' });
g.fire({ key: 'Enter' });
g.cfg.activeEl = g.body;
g.fire({ key: ' ' });
assert.deepEqual(g.state.clicked, [], 'no synthetic click into a text input or the bare body');
});
test('Escape clicks the visible in-screen back button when one exists', () => {
const g = load();
const hiddenBack = g.elem({ visible: false }); // a back button from another, now-hidden screen
const visibleBack = g.elem();
g.cfg.backButtons = [hiddenBack, visibleBack];
g.fire({ key: 'Escape' });
assert.deepEqual(g.state.clicked, [visibleBack], 'the visible back button wins, not DOM order');
assert.deepEqual(g.state.screens, [], 'no home fallback while a back button handled it');
});
test('Escape with no visible back button falls back to the home screen', () => {
const g = load();
g.cfg.backButtons = [g.elem({ visible: false })];
g.fire({ key: 'Escape' });
assert.deepEqual(g.state.screens, ['v3-home']);
assert.deepEqual(g.state.clicked, []);
});
@@ -0,0 +1,217 @@
// Regression coverage for the first-chart-data camera bootstrap in
// plugins/highway_3d/screen.js.
//
// The event selector is pure and tested behaviourally. The renderer lifecycle
// wiring remains source-level, matching the existing highway_3d camera tests:
// constructing a full Three.js renderer in Node would test a large fake DOM/GL
// harness rather than the bootstrap contract itself.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const src = fs.readFileSync(SCREEN_JS, 'utf8');
function extractFn(source, name) {
const start = source.indexOf('function ' + name);
assert.ok(start >= 0, `function ${name} must exist`);
const open = source.indexOf('{', start);
let depth = 0;
for (let i = open; i < source.length; i++) {
if (source[i] === '{') depth++;
else if (source[i] === '}' && --depth === 0) return source.slice(start, i + 1);
}
throw new Error(`unbalanced braces extracting ${name}`);
}
function sourceBetween(startText, endText) {
const start = src.indexOf(startText);
assert.ok(start >= 0, `missing source anchor: ${startText}`);
const end = src.indexOf(endText, start);
assert.ok(end > start, `missing source end anchor: ${endText}`);
return src.slice(start, end);
}
const hwyFirstRelevantFrettedTime = new Function(
'"use strict";'
+ extractFn(src, 'hwyFirstRelevantFrettedTime')
+ '\nreturn hwyFirstRelevantFrettedTime;',
)();
test('long intros bootstrap from the earliest future fretted note', () => {
const notes = [
{ t: 13.22, s: 2, f: 7 },
{ t: 15.0, s: 1, f: 4 },
];
const chords = [
{ t: 14.0, notes: [{ s: 0, f: 3 }, { s: 1, f: 5 }] },
];
assert.equal(hwyFirstRelevantFrettedTime(notes, chords, 0.4, 0.2, 6), 13.22);
});
test('chord-only charts bootstrap from fretted chord members', () => {
const chords = [
{ t: 4.0, notes: [{ s: 0, f: 0 }, { s: 1, f: 0 }] },
{ t: 8.5, notes: [{ s: 0, f: 0 }, { s: 1, f: 9 }] },
];
assert.equal(hwyFirstRelevantFrettedTime([], chords, 0, 0.2, 6), 8.5);
});
test('empty and all-open charts keep the default camera', () => {
assert.equal(hwyFirstRelevantFrettedTime([], [], 0, 0.2, 6), null);
assert.equal(hwyFirstRelevantFrettedTime(
[{ t: 2, s: 0, f: 0 }],
[{ t: 3, notes: [{ s: 1, f: 0 }, { s: 2, f: 0 }] }],
0,
0.2,
6,
), null);
});
test('bootstrap ignores malformed strings but supports extended-range charts', () => {
const notes = [
{ t: 1, s: -1, f: 4 },
{ t: 2, s: 7, f: 5 },
{ t: 3, s: 6, f: 8 },
];
assert.equal(hwyFirstRelevantFrettedTime(notes, [], 0, 0.2, 6), null);
assert.equal(hwyFirstRelevantFrettedTime(notes, [], 0, 0.2, 7), 3);
});
test('active sustains bootstrap at now and fully expired events are skipped', () => {
const now = 10;
assert.equal(hwyFirstRelevantFrettedTime(
[{ t: 6, sus: 5, s: 2, f: 7 }],
[],
now,
0.2,
6,
), now);
assert.equal(hwyFirstRelevantFrettedTime(
[{ t: 6, sus: 1, s: 2, f: 7 }, { t: 15, s: 2, f: 9 }],
[],
now,
0.2,
6,
), 15);
});
test('recent onsets inside the behind-window bootstrap at now', () => {
assert.equal(hwyFirstRelevantFrettedTime(
[{ t: 9.9, s: 2, f: 7 }],
[],
10,
0.2,
6,
), 10);
});
test('bootstrap runs once when complete chart arrays arrive', () => {
const bootstrap = sourceBetween(
'// ── Camera bootstrap (first chart data)',
' pbBeg(4);',
);
assert.match(
bootstrap,
/if\s*\(\s*!_camSnapped\s*&&\s*!_camPreScanned\s*&&\s*notes\s*&&\s*chords\s*\)/,
'chart bootstrap must be gated to one pass after both arrays arrive',
);
assert.match(
bootstrap,
/hwyFirstRelevantFrettedTime\(\s*notes\s*,\s*chords\s*,\s*now\s*,\s*CAM_TGT_BEHIND\s*,\s*nStr\s*\)/,
'bootstrap must select the first relevant event using the active string count',
);
assert.match(
bootstrap,
/firstFrettedTime\s*===\s*null[\s\S]*?_camSnapped\s*=\s*true/,
'all-open/empty charts without lookahead bounds must permanently disable bootstrap work',
);
});
test('steady and lookahead modes initialize immediately from future chart data', () => {
const bootstrap = sourceBetween(
'// ── Camera bootstrap (first chart data)',
' pbBeg(4);',
);
assert.match(
bootstrap,
/cameraMode\s*===\s*'lookahead'[\s\S]*?lookaheadBoundsNow\s*\|\|\s*firstFrettedTime\s*!==\s*null/,
'lookahead anchor bounds must bootstrap even on an all-open chart',
);
assert.match(
bootstrap,
/lookaheadBootstrapTime\(\s*now\s*,\s*firstFrettedTime\s*\)/,
'lookahead mode must project to the first window that reaches the phrase',
);
assert.match(
bootstrap,
/lookaheadBoundsNow\s*\?\s*now\s*:\s*lookaheadBootstrapTime/,
'already-live anchor/note bounds must win over a projected lookahead',
);
assert.match(
bootstrap,
/Math\.max\(\s*now\s*,\s*firstFrettedTime\s*-\s*camAhead\s*\)/,
'steady mode must sample when the first event enters its normal target window',
);
assert.match(
bootstrap,
/curX\s*=\s*tgtX\s*;[\s\S]*?curDist\s*=\s*tgtDist\s*;/,
'the initial base position must be applied before the note draw loop',
);
});
test('silent-intro hold hands off only when live framing is ready', () => {
const target = sourceBetween(
'// ── Camera target',
'// ── Chord diagram:',
);
assert.match(
target,
/cameraMode\s*===\s*'lookahead'\s*\?\s*lookaheadBoundsNow\s*!==\s*null\s*:\s*camDistGot/,
'lookahead and steady modes must use their own live-ready signal',
);
assert.match(
target,
/if\s*\(\s*bootstrapHoldActive\s*\)[\s\S]*?lockActive\s*=\s*prevLockActive/,
'the bootstrap target must remain untouched while the live window is empty',
);
assert.match(
target,
/_camBootstrapMode\s*!==\s*cameraMode[\s\S]*?_camBootstrapHolding\s*=\s*false/,
'a live camera-mode change must safely release the old-mode hold',
);
});
test('song changes and teardown reset every bootstrap state field', () => {
const resetAssignments = src.match(
/_camSnapped\s*=\s*false\s*;\s*\r?\n\s*_camPreScanned\s*=\s*false\s*;\s*\r?\n\s*_camBootstrapHolding\s*=\s*false\s*;\s*\r?\n\s*_camBootstrapMode\s*=\s*null\s*;/g,
) || [];
assert.equal(
resetAssignments.length,
2,
'song-change and teardown paths must both reset bootstrap state',
);
});
test('Camera Director still layers after the bootstrapped auto-framing base', () => {
const bootstrap = sourceBetween(
'// ── Camera bootstrap (first chart data)',
' pbBeg(4);',
);
assert.doesNotMatch(
bootstrap,
/_freeCam|__h3dCamCtl/,
'bootstrap must only initialize base framing, never mutate Camera Director state',
);
const camUpdate = extractFn(src, 'camUpdate');
const baseIndex = camUpdate.indexOf('curX += (tgtX - curX) * lerp');
const directorIndex = camUpdate.indexOf('if (_freeCam && _freeCam.enabled)');
const positionIndex = camUpdate.indexOf('cam.position.set(_camX, _camY, _camZ)');
assert.ok(
baseIndex >= 0 && directorIndex > baseIndex && positionIndex > directorIndex,
'Camera Director transforms must remain layered after base framing and before camera placement',
);
});
+355
View File
@@ -0,0 +1,355 @@
// Source-level coverage is used because createHighway's browser closure is too
// large for the Node harness. Critical staging helpers are exercised directly.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
const highwayDrawJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-draw.js');
function extractBlock(src, marker) {
const start = src.indexOf(marker);
assert.ok(start >= 0, `${marker} present`);
const open = src.indexOf('{', start);
assert.ok(open >= 0, `${marker} has a body`);
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth += 1;
else if (src[i] === '}') {
depth -= 1;
if (depth === 0) return src.slice(start, i + 1);
}
}
assert.fail(`${marker} body is balanced`);
}
test('highway public API exposes the chart-transform hook', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /setChartTransform\s*\(\s*p\s*\)\s*\{/, 'setChartTransform exists');
assert.match(src, /getChartTransform\s*\(\s*\)\s*\{[^}]*_xfProvider/, 'getChartTransform returns the provider');
assert.match(src, /refreshChartTransform\s*\(\s*\)\s*\{[^}]*_restageChartTransform/, 'refreshChartTransform restages');
});
test('restage runs at BOTH exits of _rebuildMasteryFilter (transform after difficulty)', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fnStart = src.indexOf('function _rebuildMasteryFilter()');
const fnEnd = src.indexOf('function _clearChartTransformStage');
assert.ok(fnStart > -1 && fnEnd > fnStart, 'both functions present in order');
const body = src.slice(fnStart, fnEnd);
const calls = body.match(/_restageChartTransform\(\);/g) || [];
assert.equal(calls.length, 2, 'restage at the early return and the normal exit');
});
test('restage consumes the difficulty-filtered arrays, not the raw chart', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
assert.match(fn, /notes:\s*filterActive\s*\?\s*hwState\._filteredNotes\s*:\s*hwState\.notes/);
assert.match(fn, /allNotes:\s*hwState\.notes/, 'full-difficulty views passed alongside');
});
test('a throwing provider clears the stage and emits highway:chart-transform-failed', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
const report = extractBlock(src, 'function _reportChartTransformFailure(provider, error)');
assert.match(fn, /catch\s*\(e\)\s*\{[\s\S]*_reportChartTransformFailure\(p, e\)[\s\S]*return;/);
assert.match(fn, /^\s*_clearChartTransformStage\(\);/m, 'stage cleared before the provider runs');
// Execute the extracted reporter with a sentinel error: the emitted
// payload must contain ONLY the approved field (id) — nothing derived
// from the exception — while the raw error stays on the local console.
const sandbox = { cleared: 0, emitted: [], logged: [] };
vm.runInNewContext(`
const _clearChartTransformStage = () => { cleared += 1; };
const console = { error: (...args) => logged.push(args) };
const window = { feedBack: { emit: (type, detail) => emitted.push({ type, detail }) } };
${report}
_reportChartTransformFailure({ id: 'prov-1' }, new Error('sentinel: /Users/someone/secret.sloppak'));
`, sandbox);
assert.equal(sandbox.cleared, 1, 'failure clears the stage');
assert.equal(sandbox.emitted.length, 1, 'exactly one failure event');
assert.equal(sandbox.emitted[0].type, 'highway:chart-transform-failed');
assert.deepEqual(Object.keys(sandbox.emitted[0].detail), ['id'],
'payload carries only the approved field — no exception-derived fields');
assert.equal(sandbox.emitted[0].detail.id, 'prov-1');
assert.ok(!JSON.stringify(sandbox.emitted[0].detail).includes('sentinel'),
'nothing exception-derived leaks into the event');
assert.equal(sandbox.logged.length, 1, 'raw exception stays on the local console');
assert.ok(sandbox.logged[0].some((arg) => String(arg).includes('sentinel')),
'the local console received the actual error');
});
test('restage is a pre-ready no-op: provider stays attached, ready path runs the first staging', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
const guardAt = fn.indexOf('if (!hwState.ready) return;');
const invokeAt = fn.indexOf('p.transform(');
assert.ok(guardAt > -1, 'ready guard present');
assert.ok(invokeAt > guardAt, 'guard sits before the provider is invoked');
// The ready handler must flip hwState.ready BEFORE rebuilding the
// filter, or the guard would skip the first real staging.
const readyCase = src.indexOf("case 'ready':");
const readyFlip = src.indexOf('hwState.ready = true;', readyCase);
const readyRebuild = src.indexOf('_rebuildMasteryFilter();', readyCase);
assert.ok(readyCase > -1 && readyFlip > -1 && readyRebuild > readyFlip,
'ready handler sets hwState.ready before the rebuild that restages');
});
test('bundle assembly prefers the staged transform views', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /b\.notes = hwState\._xfNotes !== null \? hwState\._xfNotes/);
assert.match(src, /b\.chords = hwState\._xfChords !== null \? hwState\._xfChords/);
assert.match(src, /b\.anchors = hwState\._xfAnchors !== null \? hwState\._xfAnchors/);
assert.match(src, /b\.chordTemplates = hwState\._xfChordTemplates !== null/);
assert.match(src, /b\.stringCount = hwState\._xfStringCount !== null/);
assert.match(src, /b\.tuning = hwState\._xfTuning !== null/);
assert.match(src, /b\.capo = hwState\._xfCapo !== null/);
assert.match(src, /b\.handShapes = hwState\._xfHandShapes !== null \? hwState\._xfHandShapes/);
assert.match(src, /b\.centOffset = hwState\._xfCentOffset !== null/);
});
test('transform input carries the effective handShapes; output stages handShapes/centOffset', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
assert.match(fn, /handShapes: \(hwState\._filteredHandShapes !== null && hwState\._phrasesHaveHandShapes\)/,
'input handShapes uses the same effective selection as the bundle');
assert.match(fn, /_sortedChartTransformArray\(out\.handShapes, 'start_time'\)/);
assert.match(fn, /if \(Number\.isFinite\(out\.centOffset\)\) hwState\._xfCentOffset = out\.centOffset;/);
});
test('unordered provider timelines are copied and normalized for searches and anchor scans', () => {
const highwaySrc = fs.readFileSync(highwayJs, 'utf8');
const drawSrc = fs.readFileSync(highwayDrawJs, 'utf8');
const snippets = [
extractBlock(highwaySrc, 'function _clearChartTransformStage()'),
extractBlock(highwaySrc, 'function _cloneChartTransformValue(value, seen = new WeakMap())'),
extractBlock(highwaySrc, 'function _sortedChartTransformArray(items, key)'),
extractBlock(highwaySrc, 'function _reportChartTransformFailure(provider, error)'),
extractBlock(highwaySrc, 'function _restageChartTransform()'),
extractBlock(highwaySrc, 'function bsearchTime(arr, time)'),
extractBlock(highwaySrc, 'function getAnchorAt(t)'),
extractBlock(highwaySrc, 'function getMaxFretInWindow(t)'),
extractBlock(drawSrc, 'export function bsearch(arr, time)').replace('export ', ''),
].join('\n');
const providerOutput = {
notes: [{ t: 9 }, { t: 1 }, { t: 5 }],
chords: [{ t: 8 }, { t: 2 }],
anchors: [
{ time: 10, fret: 20, width: 2 },
{ time: 0, fret: 1, width: 3 },
{ time: 5, fret: 10, width: 4 },
],
allNotes: [{ t: 7 }, { t: 0 }, { t: 3 }],
allChords: [{ t: 6 }, { t: 4 }],
handShapes: [{ start_time: 9 }, { start_time: 1 }],
stringCount: 4,
tuning: [-2, -2, -2, -2],
capo: 2,
centOffset: -12.5,
};
const hwState = {
ready: true,
_xfProvider: { id: 'unordered', transform: () => providerOutput },
_filteredNotes: [],
_filteredChords: [],
_filteredAnchors: [],
_filteredHandShapes: [],
_phrasesHaveHandShapes: true,
notes: [], chords: [], anchors: [], handShapes: [], chordTemplates: [],
stringCount: 6, songInfo: {},
};
const helpers = new Function('hwState', 'window', 'VISIBLE_SECONDS', 'console', `
${snippets}
return { _restageChartTransform, bsearch, bsearchTime, getAnchorAt, getMaxFretInWindow };
`)(hwState, {}, 3, { error() {} });
helpers._restageChartTransform();
assert.deepEqual(hwState._xfNotes.map(n => n.t), [1, 5, 9]);
assert.deepEqual(hwState._xfChords.map(ch => ch.t), [2, 8]);
assert.deepEqual(hwState._xfNotesAll.map(n => n.t), [0, 3, 7]);
assert.deepEqual(hwState._xfChordsAll.map(ch => ch.t), [4, 6]);
assert.deepEqual(hwState._xfAnchors.map(a => a.time), [0, 5, 10]);
assert.deepEqual(hwState._xfHandShapes.map(h => h.start_time), [1, 9]);
assert.equal(hwState._xfStringCount, 4);
assert.deepEqual(hwState._xfTuning, [-2, -2, -2, -2]);
assert.equal(hwState._xfCapo, 2);
assert.equal(hwState._xfCentOffset, -12.5);
assert.deepEqual(providerOutput.notes.map(n => n.t), [9, 1, 5], 'provider output is not mutated');
providerOutput.tuning[0] = 99;
assert.equal(hwState._xfTuning[0], -2, 'staged metadata is detached from provider output');
assert.equal(helpers.bsearch(hwState._xfNotes, 5), 1);
assert.equal(helpers.bsearchTime(hwState._xfAnchors, 5), 1);
assert.equal(helpers.getAnchorAt(6).time, 5);
assert.equal(helpers.getMaxFretInWindow(0), 14);
hwState._filteredNotes = null;
hwState._filteredChords = null;
hwState._xfProvider.transform = () => ({
notes: [{ t: 4 }, { t: 2 }],
chords: [{ t: 3 }, { t: 1 }],
});
helpers._restageChartTransform();
assert.deepEqual(hwState._xfNotesAll.map(n => n.t), [2, 4], 'unfiltered notes still fall back');
assert.deepEqual(hwState._xfChordsAll.map(ch => ch.t), [1, 3], 'unfiltered chords still fall back');
});
test('provider inputs and staged outputs are isolated from provider mutation', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const snippets = [
extractBlock(src, 'function _clearChartTransformStage()'),
extractBlock(src, 'function _cloneChartTransformValue(value, seen = new WeakMap())'),
extractBlock(src, 'function _sortedChartTransformArray(items, key)'),
extractBlock(src, 'function _reportChartTransformFailure(provider, error)'),
extractBlock(src, 'function _restageChartTransform()'),
].join('\n');
const sourceNote = { t: 1, bendValues: [{ t: 0, v: 1 }] };
const sourceInfo = { tuning: [0, 0], nested: { value: 1 } };
const events = [];
const hwState = {
ready: true,
_xfProvider: null,
_filteredNotes: null, _filteredChords: null, _filteredAnchors: null,
_filteredHandShapes: null, _phrasesHaveHandShapes: false,
notes: [sourceNote], chords: [], anchors: [], handShapes: [], chordTemplates: [],
stringCount: 2, songInfo: sourceInfo,
};
const helpers = new Function('hwState', 'window', 'console', `
${snippets}
return { _restageChartTransform };
`)(hwState, { feedBack: { emit(name, detail) { events.push({ name, detail }); } } }, { error() {} });
hwState._xfProvider = {
id: 'mutating-provider',
transform(input) {
input.notes[0].t = 99;
input.notes[0].bendValues[0].v = 7;
input.songInfo.nested.value = 8;
throw new Error('private provider detail');
},
};
helpers._restageChartTransform();
assert.equal(sourceNote.t, 1);
assert.equal(sourceNote.bendValues[0].v, 1);
assert.equal(sourceInfo.nested.value, 1);
assert.equal(hwState._xfNotes, null);
assert.deepEqual(events.map(event => event.name), ['highway:chart-transform-failed']);
const output = { notes: [{ t: 2, nested: { value: 3 } }] };
hwState._xfProvider = { id: 'stable-provider', transform: () => output };
helpers._restageChartTransform();
output.notes[0].t = 20;
output.notes[0].nested.value = 30;
assert.equal(hwState._xfNotes[0].t, 2);
assert.equal(hwState._xfNotes[0].nested.value, 3);
});
test('async and malformed provider outputs fail closed without a partial stage', async () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const snippets = [
extractBlock(src, 'function _clearChartTransformStage()'),
extractBlock(src, 'function _cloneChartTransformValue(value, seen = new WeakMap())'),
extractBlock(src, 'function _sortedChartTransformArray(items, key)'),
extractBlock(src, 'function _reportChartTransformFailure(provider, error)'),
extractBlock(src, 'function _restageChartTransform()'),
].join('\n');
const events = [];
const errors = [];
const hwState = {
ready: true,
_xfProvider: { id: 'async-provider', transform: async () => { throw new Error('async detail'); } },
_filteredNotes: null, _filteredChords: null, _filteredAnchors: null,
_filteredHandShapes: null, _phrasesHaveHandShapes: false,
notes: [], chords: [], anchors: [], handShapes: [], chordTemplates: [],
stringCount: 6, songInfo: {},
};
const helpers = new Function('hwState', 'window', 'console', `
${snippets}
return { _restageChartTransform };
`)(hwState, { feedBack: { emit(name) { events.push(name); } } }, { error(...args) { errors.push(args); } });
helpers._restageChartTransform();
assert.equal(hwState._xfNotes, null);
assert.equal(events.length, 1);
assert.match(String(errors[0][1]), /must return synchronously/);
await new Promise(resolve => setImmediate(resolve));
assert.match(String(errors[1][1]), /async detail/, 'async rejection stays in the local console');
const output = { chords: [{ t: 1 }] };
Object.defineProperty(output, 'notes', { enumerable: true, get() { throw new Error('bad getter'); } });
hwState._xfProvider = { id: 'getter-provider', transform: () => output };
helpers._restageChartTransform();
assert.equal(hwState._xfNotes, null);
assert.equal(hwState._xfChords, null);
assert.equal(events.length, 2);
});
test('createHighway announces each instance via highway:created', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /emit\('highway:created', \{ highway: api \}\)/,
'factory emits highway:created with the api instance');
});
test('public getters fall through transformed → filtered → raw', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /getNotes\(\)\s*\{\s*return hwState\._xfNotesAll !== null/);
assert.match(src, /getChords\(\)\s*\{\s*return hwState\._xfChordsAll !== null/);
assert.match(src, /getFilteredNotes\(\)\s*\{\s*if \(hwState\._xfNotes !== null\) return hwState\._xfNotes;/);
assert.match(src, /getFilteredChords\(\)\s*\{\s*if \(hwState\._xfChords !== null\) return hwState\._xfChords;/);
assert.match(src, /getChordTemplates\(\)\s*\{\s*return hwState\._xfChordTemplates !== null/);
assert.match(src, /getStringCount\(\)\s*\{\s*return hwState\._xfStringCount !== null/);
assert.match(src, /getTuning\(\)\s*\{\s*return hwState\._xfTuning !== null/);
assert.match(src, /getCapo\(\)\s*\{\s*return hwState\._xfCapo !== null/);
assert.match(src, /getCentOffset\(\)\s*\{\s*return hwState\._xfCentOffset !== null/);
assert.match(src, /getSongInfo\(\)\s*\{\s*return hwState\.songInfo;\s*\}/,
'getSongInfo keeps the original chart metadata contract');
});
test('anchor zoom helpers read the staged anchors first', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const anchorSites = src.match(/hwState\._xfAnchors !== null \? hwState\._xfAnchors\s*\n?\s*: hwState\._filteredAnchors !== null/g) || [];
assert.ok(anchorSites.length >= 2, 'getAnchorAt and getMaxFretInWindow both staged-aware');
});
test('init and reconnect clear the stage but keep the provider', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const initBody = extractBlock(src, 'init(canvasEl, container)');
const reconnectBody = extractBlock(src, 'reconnect(filename, arrangement, drumPart)');
assert.match(initBody, /_clearChartTransformStage\(\);/, 'init clears the stage');
assert.match(reconnectBody, /_clearChartTransformStage\(\);/, 'reconnect clears the stage');
assert.ok(!/init\([\s\S]{0,2000}_xfProvider = null/.test(src.slice(src.indexOf('const api = {'))),
'api reset paths never drop the installed provider');
});
test('default 2D draw path prefers the staged views (drawNotes/drawChords/drawSustains)', () => {
const src = fs.readFileSync(highwayDrawJs, 'utf8');
const noteSites = src.match(/hwState\._xfNotes !== null \? hwState\._xfNotes/g) || [];
assert.ok(noteSites.length >= 2, 'drawNotes and drawSustains staged-aware');
assert.match(src, /hwState\._xfChords !== null \? hwState\._xfChords/, 'drawChords staged-aware');
});
test('highway_3d nut labels prefer the transform-aware bundle tuning/capo', () => {
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'), 'utf8');
assert.match(src, /let tuning = Array\.isArray\(bundle\.tuning\) \? bundle\.tuning : \(songInfo && songInfo\.tuning\)/,
'label derivation reads a well-formed bundle.tuning first, songInfo otherwise');
assert.match(src, /let cap = bundle\.capo;/,
'label derivation reads bundle.capo first');
// Both cache paths must key on the same bundle-first capo the labels
// use (songInfo stays as the fallback branch of each ternary), and all
// three sites share the same final fallback (0) so cache signatures
// match rendered output.
assert.match(src, /const capo =\s*\n\s*bundle && Number\.isFinite\(bundle\.capo\) \? bundle\.capo\s*\n\s*: \(si && Number\.isFinite\(si\.capo\) \? si\.capo : 0\)/,
'label signature keys on bundle.capo first with a 0 fallback');
assert.match(src, /const capo =\s*\n\s*Number\.isFinite\(bundle\.capo\) \? bundle\.capo\s*\n\s*: \(si && Number\.isFinite\(si\.capo\) \? si\.capo : 0\)/,
'cheap-key fast path keys on bundle.capo first with a 0 fallback');
});
test('chord template reads route through the effective-templates helper', () => {
const src = fs.readFileSync(highwayDrawJs, 'utf8');
assert.match(src, /export function _effChordTemplates\(hwState\)/);
assert.ok(!/getChordTemplateInfo\([^)]*,\s*hwState\.chordTemplates\)/.test(src),
'no direct hwState.chordTemplates read remains at template-info call sites');
assert.match(src, /_chordRenderCacheTemplates !== effTemplates/, 'render cache keys on effective templates');
});
+4 -2
View File
@@ -51,8 +51,10 @@ test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () =
);
assert.match(src, eqEither('hwState\\._chordRenderCacheSrc', 'src'), 'cache must key on src');
assert.match(src, eqEither('hwState\\._chordRenderCacheInverted', 'hwState\\._inverted'), 'cache must key on _inverted');
assert.match(src, neqEither('hwState\\._chordRenderCacheTemplates', 'hwState\\.chordTemplates'),
'cache must key on chordTemplates (detected via !== for change-flag)');
assert.match(src, neqEither('hwState\\._chordRenderCacheTemplates', 'effTemplates'),
'cache must key on the effective chordTemplates (detected via !== for change-flag)');
assert.match(src, /_effChordTemplates\(hwState\)\s*\{\s*\n?\s*return hwState\._xfChordTemplates !== null \? hwState\._xfChordTemplates : hwState\.chordTemplates;/,
'effective templates must derive from hwState.chordTemplates');
});
test('chordTemplates change resets fretline preview and frame-mismatch warner', () => {
+4
View File
@@ -88,6 +88,10 @@ function buildSandbox() {
playClick: () => {},
showCountOverlay: () => {},
hideCountOverlay: () => {},
// beginCount sizes the count to the bar at loop A; the wrap-path
// assertions below don't depend on how many clicks it decides on.
// Covered directly in count_in_beats.test.js.
countInBeats: () => 4,
// Stubbed DOM access. Anything querying for a button just gets a
// permissive object that ignores writes.
+61 -2
View File
@@ -83,10 +83,25 @@ def test_download_without_published_pack_404s(client):
def test_download_locked_venue_403s(client, monkeypatch):
club = career_routes._venue("club")
monkeypatch.setitem(club, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
monkeypatch.setitem(club, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64, "bytes": 123})
assert client.post("/api/plugins/career/packs/club/download").status_code == 403
def test_placeholder_pack_is_not_offered_until_published(client, monkeypatch):
# A committed manifest carries a 0-byte placeholder until its release is
# published. Such a pack must not be offered (has_pack False) and its
# download must 404 — else the UI shows a button that can only fail.
monkeypatch.setattr(career_routes, "_bundled", lambda vid: False)
club = career_routes._venue("club")
monkeypatch.setitem(club, "pack",
{"url": "http://x/c.zip", "sha256": "0" * 64, "bytes": 0})
by_id = {v["id"]: v for v in client.get("/api/plugins/career/state").json()["venues"]}
assert by_id["club"]["has_pack"] is False # placeholder → not offered
assert by_id["arena"]["has_pack"] is True # arena ships real bytes
# Even forced, an unpublished pack won't start a download.
assert client.post("/api/plugins/career/packs/club/download").status_code == 404
def test_bundled_bar_pack_is_installed_and_served(client):
state = client.get("/api/plugins/career/state").json()
bar = {v["id"]: v for v in state["venues"]}["bar"]
@@ -167,9 +182,53 @@ def test_download_worker_end_to_end(client, tmp_path):
assert "sha256" in bad["error"]
def test_content_packs_build_roundtrips_through_download(client, tmp_path):
# tools/content_packs.py must produce a zip the real career worker accepts:
# build_pack → manifest_entry → _download_pack → installed.
from tools import content_packs
src = tmp_path / "bar"
src.mkdir()
for s in career_routes.REQUIRED_LOOPS:
(src / f"{s}.mp4").write_bytes(b"fake-" + s.encode())
(src / "cheer.mp4").write_bytes(b"fake-cheer")
(src / "manifest.json").write_text(json.dumps({
"venue": "bar", "version": 1,
"loops": {s: f"{s}.mp4" for s in career_routes.REQUIRED_LOOPS},
"stingers": {"cheer": "cheer.mp4"},
}))
out_dir = tmp_path / "packs"
zip_path = out_dir / content_packs.pack_asset("bar", 1)
info = content_packs.build_pack(src, zip_path)
entry = content_packs.manifest_entry(zip_path, zip_path.resolve().as_uri())
assert entry["sha256"] == info["sha256"] and entry["bytes"] == info["bytes"]
progress = {"status": "running", "bytes_done": 0, "bytes_total": 0, "error": None}
career_routes._download_pack("bar", entry, progress)
assert progress["status"] == "done", progress["error"]
assert career_routes._installed("bar")
def test_content_packs_rejects_files_the_downloader_would_refuse(tmp_path):
# A stray file (e.g. macOS .DS_Store) must fail the build, not get published
# and then break every client's download at _validate_pack_dir.
from tools import content_packs
src = tmp_path / "bar"
src.mkdir()
(src / "bored.mp4").write_bytes(b"fake")
(src / ".DS_Store").write_bytes(b"junk")
try:
content_packs.build_pack(src, tmp_path / "bar-pack-v1.zip")
except ValueError as e:
assert "downloader will reject" in str(e)
else:
raise AssertionError("build_pack accepted a .DS_Store the downloader rejects")
def test_double_download_409s(client, monkeypatch):
bar = career_routes._venue("bar")
monkeypatch.setitem(bar, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64})
monkeypatch.setitem(bar, "pack", {"url": "http://x/pack.zip", "sha256": "0" * 64, "bytes": 123})
# Pretend one is already running.
career_routes._state["downloads"]["bar"] = {"status": "running"}
assert client.post("/api/plugins/career/packs/bar/download").status_code == 409
+91
View File
@@ -0,0 +1,91 @@
"""VST-pack slicing (tools/content_packs.build_vst_pack): each platform pack
keeps only its own binaries + the shared bundle files, drops the rest, and is
reproducible."""
import zipfile
from pathlib import Path
from tools import content_packs
def _fake_vst_tree(root: Path):
# One fat .vst3 with all three platform binaries + shared files, plus a
# src/ build tree that must never ship.
c = root / "amps" / "Foo.vst3" / "Contents"
(c / "MacOS").mkdir(parents=True)
(c / "x86_64-win").mkdir(parents=True)
(c / "x86_64-linux").mkdir(parents=True)
(c / "Resources").mkdir(parents=True)
(c / "MacOS" / "Foo").write_bytes(b"mac-binary")
(c / "x86_64-win" / "Foo.vst3").write_bytes(b"win-binary")
(c / "x86_64-linux" / "Foo.so").write_bytes(b"linux-binary")
(c / "Info.plist").write_bytes(b"<plist/>")
(c / "Resources" / "moduleinfo.json").write_bytes(b"{}")
(root / "src" / "build").mkdir(parents=True)
(root / "src" / "build" / "junk.o").write_bytes(b"objfile")
def _names(zip_path):
with zipfile.ZipFile(zip_path) as zf:
return set(zf.namelist())
def test_slice_keeps_target_platform_and_shared_drops_foreign(tmp_path):
root = tmp_path / "vst"
_fake_vst_tree(root)
content_packs.build_vst_pack(root, tmp_path / "mac.zip", "mac")
names = _names(tmp_path / "mac.zip")
base = "amps/Foo.vst3/Contents"
assert f"{base}/MacOS/Foo" in names # target binary kept
assert f"{base}/Info.plist" in names # shared kept
assert f"{base}/Resources/moduleinfo.json" in names # shared kept
assert f"{base}/x86_64-win/Foo.vst3" not in names # foreign dropped
assert f"{base}/x86_64-linux/Foo.so" not in names # foreign dropped
assert not any(n.startswith("src/") for n in names) # build trees never ship
def test_each_platform_gets_its_own_binary(tmp_path):
root = tmp_path / "vst"
_fake_vst_tree(root)
wanted = {"mac": "MacOS/Foo", "win": "x86_64-win/Foo.vst3", "linux": "x86_64-linux/Foo.so"}
for plat, rel in wanted.items():
content_packs.build_vst_pack(root, tmp_path / f"{plat}.zip", plat)
names = _names(tmp_path / f"{plat}.zip")
assert f"amps/Foo.vst3/Contents/{rel}" in names
others = [v for k, v in wanted.items() if k != plat]
for o in others:
assert f"amps/Foo.vst3/Contents/{o}" not in names
def test_slice_is_reproducible(tmp_path):
root = tmp_path / "vst"
_fake_vst_tree(root)
a = content_packs.build_vst_pack(root, tmp_path / "a.zip", "linux")
b = content_packs.build_vst_pack(root, tmp_path / "b.zip", "linux")
assert a == b and a["sha256"]
def test_slice_pins_create_system_for_cross_runner_reproducibility(tmp_path, monkeypatch):
# ZipInfo defaults create_system from the host OS (0 on Windows, 3 on Unix),
# and it lands in the central directory — so without an explicit pin the same
# tree hashes differently on a Windows runner, breaking the precomputable-hash
# guarantee exactly where it matters (native .vst3 are built on Windows). A
# same-machine reproducibility test can't catch that; simulate win32 and
# assert the pin forces 3 regardless.
monkeypatch.setattr(zipfile.sys, "platform", "win32")
root = tmp_path / "vst"
_fake_vst_tree(root)
content_packs.build_vst_pack(root, tmp_path / "w.zip", "linux")
with zipfile.ZipFile(tmp_path / "w.zip") as zf:
assert all(i.create_system == 3 for i in zf.infolist())
def test_unknown_platform_rejected(tmp_path):
root = tmp_path / "vst"
_fake_vst_tree(root)
try:
content_packs.build_vst_pack(root, tmp_path / "x.zip", "bsd")
except ValueError as e:
assert "unknown platform" in str(e)
else:
raise AssertionError("build_vst_pack accepted an unknown platform")
+53
View File
@@ -0,0 +1,53 @@
"""Regression: the GP→arrangement-XML writers must pin UTF-8.
A bare ``Path.write_text(xml_str)`` uses the platform's *default* text
encoding. On Windows that is cp1252, which encodes a non-ASCII metadata
character e.g. the © in an album name like "Chrysalis©1982" as the lone
byte 0xA9. The XML is then read back as UTF-8 (expat's default), where 0xA9
is an invalid start byte, so parsing dies with
not well-formed (invalid token): line N, column 22
CI runs on Linux (UTF-8 default), so the bug is invisible there and a plain
functional test would pass on the old code too. These assertions instead pin
the locale-independent contract directly.
"""
import inspect
import re
import xml.etree.ElementTree as ET
import gp2rs
import gp2rs_gpx
def test_arrangement_xml_writes_specify_utf8():
# Every write of the arrangement XML string must pass encoding="utf-8"
# so non-ASCII metadata survives regardless of the host locale.
for mod in (gp2rs, gp2rs_gpx):
src = inspect.getsource(mod)
bare = re.findall(r"\.write_text\(\s*xml_str\s*\)", src)
assert not bare, (
f"{mod.__name__}: XML write must pass encoding=\"utf-8\" — a bare "
f"write_text() uses the platform default (cp1252 on Windows) and "
f"mangles non-ASCII metadata into invalid UTF-8"
)
assert 'write_text(xml_str, encoding="utf-8")' in src, (
f"{mod.__name__}: expected a UTF-8-pinned arrangement XML write"
)
def test_utf8_write_round_trips_non_ascii_album():
# The behavioural end of the contract: a © album name written as UTF-8
# parses cleanly and reads back intact (the cp1252 write does not).
from pathlib import Path
import tempfile
xml_str = (
'<?xml version="1.0"?>\n<song>\n'
" <albumName>Chrysalis©1982</albumName>\n</song>\n"
)
path = Path(tempfile.mkdtemp()) / "arr.xml"
path.write_text(xml_str, encoding="utf-8")
root = ET.parse(path).getroot()
assert root.findtext("albumName") == "Chrysalis©1982"
+142 -3
View File
@@ -38,18 +38,25 @@ from gp_autosync import (
# ── helpers ───────────────────────────────────────────────────────────────────
def _gpif_bytes(asset_id: str = "abc-123") -> bytes:
def _gpif_bytes(asset_id: str = "abc-123", registry=None) -> bytes:
"""`registry` maps Asset id -> EmbeddedFilePath, mirroring real GP8 files."""
root = ET.Element("GPIF")
bt = ET.SubElement(root, "BackingTrack")
ET.SubElement(bt, "AssetId").text = asset_id
if registry:
assets = ET.SubElement(root, "Assets")
for aid, path in registry.items():
a = ET.SubElement(assets, "Asset")
a.set("id", aid)
ET.SubElement(a, "EmbeddedFilePath").text = path
return ET.tostring(root)
def _make_gp_zip(asset_id="abc-123", ogg_stems=("abc-123",),
asset_ext=".ogg") -> zipfile.ZipFile:
asset_ext=".ogg", registry=None) -> zipfile.ZipFile:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("Content/score.gpif", _gpif_bytes(asset_id))
zf.writestr("Content/score.gpif", _gpif_bytes(asset_id, registry))
for stem in ogg_stems:
zf.writestr(f"Content/Assets/{stem}{asset_ext}", b"fake-audio")
buf.seek(0)
@@ -284,3 +291,135 @@ def test_extract_sync_points_empty_when_no_bars():
root = ET.Element("GPIF") # no MasterBars
_, wp, times, sr, hop = _identity_setup()
assert _extract_sync_points(wp, root, times, times, sr, hop, 4) == []
# ── AssetId is a key into <Assets>, not a filename stem ──────────────────────
# Real GP8 files name embedded audio by hash while AssetId is a small
# integer, so the stem match never hit: every such file warned and fell
# through to "first audio asset". Silently correct with ONE asset; with two,
# a backing track declaring id 1 resolved to asset 0 — the wrong recording.
_REAL_SHAPE = {"0": "Content/Assets/1312f2aa-10ee-5f35-a4d5-e999eee1d9d0.mp3"}
def test_asset_id_resolves_through_the_registry_not_the_stem():
zf = _make_gp_zip(
asset_id="0",
ogg_stems=("1312f2aa-10ee-5f35-a4d5-e999eee1d9d0",),
asset_ext=".mp3",
registry=_REAL_SHAPE,
)
stem, path = _resolve_audio_asset(zf)
assert path == "Content/Assets/1312f2aa-10ee-5f35-a4d5-e999eee1d9d0.mp3"
assert stem == "1312f2aa-10ee-5f35-a4d5-e999eee1d9d0"
def test_the_second_asset_is_reachable():
"""The actual bug: id 1 used to resolve to asset 0."""
zf = _make_gp_zip(
asset_id="1",
ogg_stems=("first-track", "second-track"),
registry={
"0": "Content/Assets/first-track.ogg",
"1": "Content/Assets/second-track.ogg",
},
)
stem, path = _resolve_audio_asset(zf)
assert path == "Content/Assets/second-track.ogg", "declared id 1 must win"
assert stem == "second-track"
def test_registry_entry_pointing_at_a_missing_file_falls_through():
zf = _make_gp_zip(
asset_id="0",
ogg_stems=("real-track",),
registry={"0": "Content/Assets/deleted-track.ogg"},
)
stem, path = _resolve_audio_asset(zf)
assert path == "Content/Assets/real-track.ogg"
def test_backslash_separators_in_the_registry_are_normalised():
zf = _make_gp_zip(
asset_id="0",
ogg_stems=("winpath",),
registry={"0": r"Content\Assets\winpath.ogg"},
)
_, path = _resolve_audio_asset(zf)
assert path == "Content/Assets/winpath.ogg"
def test_registry_prefers_ogg_among_same_stem_duplicates():
"""Quality behaviour is preserved: OGG is copied out, others transcoded."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("Content/score.gpif", _gpif_bytes(
"0", {"0": "Content/Assets/dual.mp3"}))
zf.writestr("Content/Assets/dual.mp3", b"fake")
zf.writestr("Content/Assets/dual.ogg", b"fake")
buf.seek(0)
_, path = _resolve_audio_asset(zipfile.ZipFile(buf))
assert path.endswith(".ogg")
def test_a_malformed_registry_does_not_break_resolution():
for reg in ({"0": ""}, {"9": "Content/Assets/other.ogg"}, {}):
zf = _make_gp_zip(asset_id="0", ogg_stems=("fallback",), registry=reg)
_, path = _resolve_audio_asset(zf)
assert path == "Content/Assets/fallback.ogg"
def test_legacy_stem_match_still_works_without_a_registry():
"""Files whose stem IS the id keep resolving — step 2 of the ladder."""
zf = _make_gp_zip(asset_id="abc-123", ogg_stems=("zzz", "abc-123"))
stem, path = _resolve_audio_asset(zf)
assert stem == "abc-123"
assert path == "Content/Assets/abc-123.ogg"
def test_a_same_stem_file_in_another_directory_cannot_stand_in():
"""The registry names a PATH, not just a name.
Resolution matches on stem so a format variant of the same recording can
win, but an unrelated file that merely shares the stem must not satisfy
the declaration that substitution is what the registry lookup exists to
prevent. The declared asset is genuinely absent here, so the right answer
is the documented fall-through, not the decoy.
ZIP order matters to this test: `real.ogg` is written FIRST so the
fall-through target differs from the decoy. Otherwise both the fixed and
unfixed code return the same file and the test proves nothing.
"""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("Content/score.gpif", _gpif_bytes(
"0", {"0": "Content/Audio/track.ogg"}))
zf.writestr("Content/Assets/real.ogg", b"fake") # fall-through target
zf.writestr("Content/Assets/track.ogg", b"decoy") # shares the stem only
buf.seek(0)
_, path = _resolve_audio_asset(zipfile.ZipFile(buf))
assert path == "Content/Assets/real.ogg", (
"a same-stem file in a directory the registry never named must not "
"satisfy the declaration"
)
def test_the_declared_directory_still_resolves_its_own_format_variants():
"""The directory constraint must not cost us the OGG preference.
The shallower decoy is written FIRST, so unfixed code (which searches
every directory) picks it and this test fails.
"""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("Content/score.gpif", _gpif_bytes(
"0", {"0": "Content/Assets/nested/take.mp3"}))
zf.writestr("Content/Assets/take.ogg", b"decoy-one-level-up")
zf.writestr("Content/Assets/nested/take.mp3", b"declared")
zf.writestr("Content/Assets/nested/take.ogg", b"same-take-lossless")
buf.seek(0)
stem, path = _resolve_audio_asset(zipfile.ZipFile(buf))
assert path == "Content/Assets/nested/take.ogg", (
"the OGG variant in the DECLARED directory wins over a shallower decoy"
)
assert stem == "take"
+17
View File
@@ -0,0 +1,17 @@
"""Wire-compatibility coverage for selectable drum parts."""
from routers.ws_highway import _drum_part_id_for_wire
def test_single_synthesized_part_keeps_legacy_frame_without_part_id():
parts = [{"id": "drums", "name": "Drums", "drum_tab": {}}]
assert _drum_part_id_for_wire(parts, "drums") is None
def test_multiple_parts_expose_selected_part_id():
parts = [
{"id": "drums", "name": "Drums", "drum_tab": {}},
{"id": "drums-2", "name": "Aux", "drum_tab": {}},
]
assert _drum_part_id_for_wire(parts, "drums-2") == "drums-2"
assert _drum_part_id_for_wire(parts, None) is None
+52 -1
View File
@@ -34,7 +34,7 @@ def test_decode_wire_notes_unpacks_midi_and_sorts():
arr = {"notes": [_wire(1.0, 67, 0.5), _wire(0.0, 60)]}
out = nl.decode_wire_notes(arr)
assert [n["midi"] for n in out] == [60, 67]
assert out[0] == {"t": 0.0, "midi": 60, "sus": 0.0}
assert out[0] == {"t": 0.0, "midi": 60, "sus": 0.0, "hand": None}
assert out[1]["sus"] == 0.5
@@ -105,6 +105,57 @@ def test_split_hands_middle_c_split_falls_back_when_it_makes_unplayable_hand():
assert sorted(n["midi"] for n in hands["rh"]) == [59, 62, 67]
def test_split_hands_authored_hand_always_wins():
# An authored 'lh' melody note ABOVE middle C (a crossing-hands texture):
# the heuristic alone would call midi 65 rh; the authored hand wins.
notes = [{"t": 0.0, "midi": 65, "sus": 0, "hand": "lh"}]
hands = nl.split_hands(notes)
assert [n["midi"] for n in hands["lh"]] == [65]
assert "rh" not in hands
def test_split_hands_explicit_notes_leave_the_group_before_heuristic_math():
# Group [C3(authored rh!), C4, E4]: without removal, C3=48 drags the mean
# to (48+60+64)/3 ≈ 57.3 < 60 → the WHOLE group would flip lh. With the
# authored note removed first, the remaining [C4, E4] mean 62 ≥ 60 → rh.
notes = [
{"t": 0.0, "midi": 48, "sus": 0, "hand": "rh"},
{"t": 0.0, "midi": 60, "sus": 0},
{"t": 0.0, "midi": 64, "sus": 0},
]
hands = nl.split_hands(notes)
assert sorted(n["midi"] for n in hands["rh"]) == [48, 60, 64]
assert "lh" not in hands
def test_split_hands_all_explicit_group_skips_heuristic_entirely():
notes = [
{"t": 0.0, "midi": 40, "sus": 0, "hand": "rh"}, # deliberately "wrong"
{"t": 0.0, "midi": 72, "sus": 0, "hand": "lh"}, # crossing hands
]
hands = nl.split_hands(notes)
assert [n["midi"] for n in hands["rh"]] == [40]
assert [n["midi"] for n in hands["lh"]] == [72]
def test_split_hands_junk_hand_values_fall_to_the_heuristic():
for junk in ("LH", "left", "", True, 3, None):
hands = nl.split_hands([{"t": 0.0, "midi": 72, "sus": 0, "hand": junk}])
assert [n["midi"] for n in hands.get("rh", [])] == [72], repr(junk)
def test_decode_wire_notes_carries_hand_with_strict_enum():
arr = {"notes": [
{"t": 0.0, "s": 2, "f": 0, "sus": 0.5, "hand": "lh"},
{"t": 0.5, "s": 2, "f": 12, "sus": 0.5, "hand": "LH"}, # junk case
{"t": 1.0, "s": 2, "f": 14, "sus": 0.5},
], "chords": [
{"t": 1.5, "notes": [{"s": 3, "f": 0, "sus": 0.5, "hand": "rh"}]},
]}
decoded = nl.decode_wire_notes(arr)
assert [n["hand"] for n in decoded] == ["lh", None, None, "rh"]
# ── Timing ───────────────────────────────────────────────────────────────────
def test_downbeat_times_filters_non_downbeats_and_sorts():
+295
View File
@@ -0,0 +1,295 @@
"""Loader coverage for MULTIPLE drum parts (feedpak 1.17.0 "drums as
arrangements").
A drum part rides the manifest as a `type: drums` arrangement entry carrying
a per-arrangement `drum_tab` file pointer and NO note `file`. The loader:
- NEVER turns a pointer entry into a fretted Arrangement that skip is
the grading invariant (an empty drum chart must not reach the fretted
pipeline, where note detection would grade it as garbage);
- resolves the parts into `LoadedSloppak.drum_parts`, primary FIRST: the
entry aliasing the song-level `drum_tab:` file contributes its id/name
but is never loaded twice (its payload IS `loaded.drum_tab`);
- loads each extra part's file with the same permissive posture as the
song-level tab (a bad part disables that part only, never the load);
- copes with a pointer-only pack (no song-level key): the first part
becomes the primary so every legacy consumer keeps working.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
import yaml
import sloppak as sloppak_mod
def _tab(name: str, hits: list[dict] | None = None) -> dict:
return {
"version": 1,
"name": name,
"kit": [{"id": "kick", "name": "Kick"}],
"hits": hits if hits is not None else [{"t": 1.0, "p": "kick", "v": 100}],
}
def _write_pak(root: Path, manifest_extras: dict, files: dict[str, dict | str]) -> Path:
"""A minimal directory-form sloppak with one Lead arrangement plus the
given extra files ({relpath: json-dict-or-raw-text})."""
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))
for rel, payload in files.items():
text = payload if isinstance(payload, str) else json.dumps(payload)
(pak / rel).write_text(text)
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)
def _two_part_manifest() -> dict:
"""The exact shape the editor writes: primary alias entry + one extra."""
return {
"drum_tab": "drum_tab.json",
"arrangements": [
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
{"id": "drums", "name": "Drums", "type": "drums",
"drum_tab": "drum_tab.json"},
{"id": "drums-2", "name": "Drums (Live)", "type": "drums",
"drum_tab": "drum_tab_drums-2.json"},
],
}
# ── The grading invariant ────────────────────────────────────────────────────
def test_pointer_entries_never_become_fretted_arrangements(tmp_path: Path):
pak = _write_pak(tmp_path, _two_part_manifest(), {
"drum_tab.json": _tab("Drums"),
"drum_tab_drums-2.json": _tab("Drums (Live)"),
})
loaded = _load(pak, tmp_path)
# Only the Lead chart is an Arrangement — neither drum part enters the
# fretted pipeline (song.arrangements is what note detection grades).
assert [a.name for a in loaded.song.arrangements] == ["Lead"]
# And the ids list stays parallel to song.arrangements (skipped entries
# contribute nothing) — a misalignment here would remap every chart edit.
assert loaded.arrangement_ids == ["lead"]
def test_drums_typed_entry_with_note_file_never_frets(tmp_path: Path):
# A malformed entry: type:drums but ALSO carrying a note `file`. Keying the
# skip on file absence would let it through as a fretted, selectable,
# gradeable Arrangement (spec §5.2/§7.5 MUST-NOT). Routing on `type` first
# drops it instead — it never reaches song.arrangements.
bogus = {
"name": "Bogus", "tuning": [0, 0, 0, 0, 0, 0], "capo": 0,
"notes": [], "chords": [], "anchors": [], "handshapes": [],
"templates": [], "beats": [], "sections": [],
}
pak = _write_pak(tmp_path, {
"arrangements": [
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
{"id": "bad", "name": "Bogus", "type": "drums",
"file": "arrangements/bogus.json"},
],
}, {"arrangements/bogus.json": bogus})
loaded = _load(pak, tmp_path)
assert [a.name for a in loaded.song.arrangements] == ["Lead"]
assert loaded.arrangement_ids == ["lead"]
# ── Parts resolution ─────────────────────────────────────────────────────────
def test_two_parts_resolve_primary_first_with_alias_identity(tmp_path: Path):
pak = _write_pak(tmp_path, _two_part_manifest(), {
"drum_tab.json": _tab("Drums"),
"drum_tab_drums-2.json": _tab("Drums (Live)", [{"t": 2.0, "p": "kick", "v": 90}]),
})
loaded = _load(pak, tmp_path)
assert loaded.drum_parts is not None
assert [(p["id"], p["name"]) for p in loaded.drum_parts] == [
("drums", "Drums"), ("drums-2", "Drums (Live)"),
]
# The primary's payload IS the song-level tab — same object, loaded once.
assert loaded.drum_parts[0]["drum_tab"] is loaded.drum_tab
assert loaded.drum_parts[1]["drum_tab"]["hits"][0]["t"] == 2.0
def test_primary_pointer_equivalent_path_is_not_duplicated(tmp_path: Path):
manifest = {
"drum_tab": "drum_tab.json",
"arrangements": [
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
{"id": "kit", "name": "Live Kit", "type": "drums",
"drum_tab": "./drum_tab.json"},
],
}
pak = _write_pak(tmp_path, manifest, {"drum_tab.json": _tab("Drums")})
loaded = _load(pak, tmp_path)
assert loaded.drum_parts is not None
assert [(p["id"], p["name"]) for p in loaded.drum_parts] == [
("kit", "Live Kit"),
]
assert loaded.drum_parts[0]["drum_tab"] is loaded.drum_tab
def test_legacy_single_drum_pack_gets_a_one_part_list(tmp_path: Path):
pak = _write_pak(tmp_path, {"drum_tab": "drum_tab.json"}, {
"drum_tab.json": _tab("Drums"),
})
loaded = _load(pak, tmp_path)
assert loaded.drum_parts is not None and len(loaded.drum_parts) == 1
assert loaded.drum_parts[0]["id"] == "drums"
assert loaded.drum_parts[0]["drum_tab"] is loaded.drum_tab
def test_no_drums_means_no_parts(tmp_path: Path):
pak = _write_pak(tmp_path, {}, {})
loaded = _load(pak, tmp_path)
assert loaded.drum_parts is None
assert loaded.drum_tab is None
def test_pointer_only_pack_promotes_the_first_part_to_primary(tmp_path: Path):
# A writer that omitted the song-level alias: readers must cope (the
# spec keeps the alias, but a reader never crashes on its absence).
pak = _write_pak(tmp_path, {
"arrangements": [
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
{"id": "kit", "name": "Kit", "type": "drums",
"drum_tab": "drum_tab_kit.json"},
],
}, {"drum_tab_kit.json": _tab("Kit")})
loaded = _load(pak, tmp_path)
assert loaded.drum_parts is not None and len(loaded.drum_parts) == 1
# The part's tab becomes THE drum tab, so has_drum_tab / the default
# stream / the drum-only placeholder all keep working.
assert loaded.drum_tab is loaded.drum_parts[0]["drum_tab"]
assert loaded.drum_parts[0]["id"] == "kit"
# ── Permissive per-part failure ──────────────────────────────────────────────
def test_a_bad_extra_part_disables_that_part_only(tmp_path: Path):
manifest = _two_part_manifest()
manifest["arrangements"].append(
{"id": "drums-3", "name": "Broken", "type": "drums",
"drum_tab": "drum_tab_broken.json"})
pak = _write_pak(tmp_path, manifest, {
"drum_tab.json": _tab("Drums"),
"drum_tab_drums-2.json": _tab("Drums (Live)"),
"drum_tab_broken.json": "not json {{{",
})
loaded = _load(pak, tmp_path)
assert [p["id"] for p in loaded.drum_parts] == ["drums", "drums-2"]
def test_a_traversal_part_path_is_skipped(tmp_path: Path):
manifest = _two_part_manifest()
manifest["arrangements"][2]["drum_tab"] = "../outside.json"
(tmp_path / "outside.json").write_text(json.dumps(_tab("Evil")))
pak = _write_pak(tmp_path, manifest, {"drum_tab.json": _tab("Drums")})
loaded = _load(pak, tmp_path)
assert [p["id"] for p in loaded.drum_parts] == ["drums"]
def test_duplicate_pointer_rels_load_once(tmp_path: Path):
manifest = _two_part_manifest()
manifest["arrangements"].append(
{"id": "drums-dup", "name": "Dup", "type": "drums",
"drum_tab": "drum_tab_drums-2.json"})
pak = _write_pak(tmp_path, manifest, {
"drum_tab.json": _tab("Drums"),
"drum_tab_drums-2.json": _tab("Drums (Live)"),
})
loaded = _load(pak, tmp_path)
assert [p["id"] for p in loaded.drum_parts] == ["drums", "drums-2"]
def test_duplicate_part_ids_are_made_unique(tmp_path: Path):
manifest = _two_part_manifest()
manifest["arrangements"][2]["id"] = "drums"
manifest["arrangements"].append(
{"id": "drums-2", "name": "Aux", "type": "drums",
"drum_tab": "drum_tab_aux.json"})
pak = _write_pak(tmp_path, manifest, {
"drum_tab.json": _tab("Drums"),
"drum_tab_drums-2.json": _tab("Drums (Live)"),
"drum_tab_aux.json": _tab("Aux"),
})
loaded = _load(pak, tmp_path)
assert [p["id"] for p in loaded.drum_parts] == ["drums", "drums-2", "drums-3"]
def test_drum_pointer_with_wrong_type_logs_warning(tmp_path: Path, caplog):
pak = _write_pak(tmp_path, {
"arrangements": [
{"id": "lead", "name": "Lead", "file": "arrangements/lead.json"},
{"id": "typo", "type": "druns", "drum_tab": "drum_tab_typo.json"},
],
}, {"drum_tab_typo.json": _tab("Typo")})
# feedBack sets propagate=False, so pytest's root capture sees nothing from
# it — attach caplog's handler to the feedBack logger and pin WARNING
# regardless of ambient level (a sibling test can leak ERROR onto this tree).
lg = logging.getLogger("feedBack")
orig_level = lg.level
lg.addHandler(caplog.handler)
lg.setLevel(logging.WARNING)
try:
loaded = _load(pak, tmp_path)
finally:
lg.removeHandler(caplog.handler)
lg.setLevel(orig_level)
assert loaded.drum_parts is None
assert "has drum_tab" in caplog.text and "type='druns'" in caplog.text
# ── Drum-only pack with parts ────────────────────────────────────────────────
def test_drum_only_pack_with_pointer_entries_still_synthesizes_placeholder(tmp_path: Path):
# No pitched arrangements at all, drums via pointer entries only: the
# placeholder "Drums" arrangement must still appear so the highway WS
# proceeds and the tab reaches the drum highway.
pak = _write_pak(tmp_path, {
"arrangements": [
{"id": "kit", "name": "Kit", "type": "drums",
"drum_tab": "drum_tab_kit.json"},
],
}, {"drum_tab_kit.json": _tab("Kit", [{"t": 5.0, "p": "kick", "v": 100}])})
# Remove the Lead arrangement _write_pak added to the manifest.
manifest_path = pak / "manifest.yaml"
manifest = yaml.safe_load(manifest_path.read_text())
manifest["arrangements"] = [e for e in manifest["arrangements"] if e.get("id") != "lead"]
manifest.pop("duration", None)
manifest_path.write_text(yaml.safe_dump(manifest, sort_keys=False))
loaded = _load(pak, tmp_path)
assert [a.name for a in loaded.song.arrangements] == ["Drums"]
assert loaded.drum_parts is not None and loaded.drum_parts[0]["id"] == "kit"
# Song length derived from the last hit (the drum-only path's rule).
assert loaded.song.song_length > 5.0
+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
+107
View File
@@ -249,6 +249,34 @@ def test_note_teaching_marks_tolerate_malformed_optional_ints():
assert n.scale_degree == -1
# ── Keys hand assignment ─────────────────────────────────────────────────────
def test_note_hand_round_trips_under_literal_key():
"""The keys hand assignment survives the wire as the literal `hand` key
(spelled out `rh` is taken by right_hand, the bass plucking finger)."""
for hand in ("lh", "rh"):
n = Note(time=0.0, string=2, fret=12, hand=hand)
wire = note_to_wire(n)
assert wire["hand"] == hand
assert note_from_wire(wire) == n
def test_note_hand_omitted_when_unassigned():
wire = note_to_wire(Note(time=0.0, string=0, fret=0))
assert "hand" not in wire
assert note_from_wire(wire).hand is None
def test_note_hand_junk_never_emitted_and_decodes_to_unassigned():
"""Emit side validates ('LH', True, … stay off the wire); decode side is a
strict enum so a hand-edited pack can't poison hand-split logic."""
for junk in ("LH", "left", "", True, 1, ["lh"]):
assert "hand" not in note_to_wire(
Note(time=0.0, string=0, fret=0, hand=junk))
assert note_from_wire(
{"t": 0.0, "s": 0, "f": 0, "hand": junk}).hand is None
# ── Scale-degree derivation helpers (§6.2.2 / §7.7) ──────────────────────────
@pytest.mark.parametrize("key,pc", [
@@ -301,6 +329,31 @@ def test_note_pitch_midi_bass_uses_bass_base():
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
def test_note_pitch_midi_authored_bass_type_uses_bass_base():
"""Editor PR #335: a bass authored via `type` on an arrangement whose NAME
doesn't say "bass". arrangement_string_count now returns 4 for it, so the
open-string base MUST also be the bass base (low E1 = 28), not the guitar
octave (40). Pre-fix note_pitch_midi keyed is_bass off the name only, so
this returned 40 (4 lanes on a guitar octave the exact inconsistency)."""
bass = Arrangement(
name="Low End", type="bass",
tuning=[0, 0, 0, 0],
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
)
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
def test_note_pitch_midi_path_bass_flag_uses_bass_base():
"""Same guarantee via the archive <arrangementProperties> pathBass flag on a
non-"bass"-named arrangement: bass base (28), not guitar (40)."""
bass = Arrangement(
name="Low End", path_bass=True,
tuning=[0, 0, 0, 0],
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
)
assert note_pitch_midi(bass, Note(time=0, string=0, fret=0)) == 28
def test_note_pitch_midi_out_of_range_string_is_none():
arr = Arrangement(name="Lead", tuning=[0, 0, 0, 0, 0, 0])
assert note_pitch_midi(arr, Note(time=0, string=9, fret=0)) is None
@@ -1125,6 +1178,60 @@ def test_string_count_ignores_rs_padded_tuning_for_bass():
assert arrangement_string_count(arr) == 4
def test_string_count_4_for_path_bass_flag_without_bass_in_name():
# Archive/DLC bass whose manifest ArrangementName isn't "Bass" but whose
# <arrangementProperties> pathBass flag is set. Notes on 0..3, tuning
# padded to the arrangement-XML length of 6. Pre-fix, name_based forced
# 6 (name has no "bass"), so this returned 6 despite the authoritative
# instrument flag saying bass.
arr = Arrangement(
name="Low End",
path_bass=True,
tuning=[0, 0, 0, 0, 0, 0],
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
)
assert arrangement_string_count(arr) == 4
def test_string_count_4_for_authored_bass_type_without_bass_in_name():
# Editor PR #335: an instrument `type` authored as bass on an arrangement
# whose NAME does not contain "bass" (the sloppak loader lifts the manifest
# `type` onto Arrangement.type). Notes on 0..3, tuning padded to 6.
# The editor lays out 4 lanes off the type; core must agree.
arr = Arrangement(
name="Low End",
type="bass",
tuning=[0, 0, 0, 0, 0, 0],
notes=[Note(time=float(i), string=i, fret=0) for i in range(4)],
)
assert arrangement_string_count(arr) == 4
def test_string_count_6_for_authored_guitar_type_no_regression():
# A non-bass authored type on a generic name still resolves to the
# canonical 6 — the type signal only pulls DOWN to 4 for bass.
arr = Arrangement(
name="Track 1",
type="guitar",
notes=[Note(time=float(i), string=i, fret=0) for i in range(5)],
)
assert arrangement_string_count(arr) == 6
def test_arrangement_is_bass_signal_safety():
# The manifest `type` is lifted onto arr.type verbatim; the helper must be
# safe against the messy shapes a hand-edited/loose source can produce.
from song import arrangement_is_bass
assert arrangement_is_bass(Arrangement(name="Low End", type="bass"))
assert arrangement_is_bass(Arrangement(name="Low End", type=" BASS ")) # ws/case
assert arrangement_is_bass(Arrangement(name="Low End", path_bass=True))
assert arrangement_is_bass(Arrangement(name="Slap Bass")) # legacy name
# Non-bass / absent signals stay False (back-compat: no bass signal → 6).
assert not arrangement_is_bass(Arrangement(name="Lead", type=""))
assert not arrangement_is_bass(Arrangement(name="Rhythm", type="guitar"))
assert not arrangement_is_bass(Arrangement(name="", type=""))
# ── compute_smart_names ───────────────────────────────────────────────────────
def _sarr(path_lead=False, path_rhythm=False, path_bass=False,
+29 -1
View File
@@ -59,7 +59,8 @@ def _ws_payload(tmp_path, pak):
return {
"stems": [
{"id": s["id"], "url": f"/api/sloppak/{q}/file/{quote(s['file'])}",
"default": s["default"]}
"default": s["default"],
**{k: s[k] for k in ("name", "description") if k in s}}
for s in loaded.stems
],
"full_mix_url": f"/api/sloppak/{q}/file/{quote(loaded.full_mix)}" if loaded.full_mix else None,
@@ -128,6 +129,33 @@ def test_rest_matches_the_ws_for_a_single_full_pack(tmp_path):
assert rest["full_mix_url"] is None
def test_stem_name_and_description_pass_through(tmp_path):
"""feedpak 1.16.0 per-stem `name`/`description` (spec §5.3) reach the payload.
Presentational, so the rule is passthrough-or-omit: a stem that carries the
fields keeps them, a stem that doesn't must NOT grow null keys, and
non-string / blank values are dropped rather than surfaced.
"""
pak = _pak(tmp_path, [
{"id": "guitar", "file": "stems/guitar.ogg", "name": "Rhythm Guitar"},
{"id": "click", "file": "stems/click.ogg", "name": "Click",
"description": "Metronome click with 4-count lead-in.", "default": "off"},
{"id": "bass", "file": "stems/bass.ogg"},
{"id": "junk", "file": "stems/junk.ogg", "name": 7, "description": " "},
], name="Labelled.feedpak")
rest = _payload(tmp_path, pak)
assert rest == _ws_payload(tmp_path, pak)
by_id = {s["id"]: s for s in rest["stems"]}
assert by_id["guitar"]["name"] == "Rhythm Guitar"
assert "description" not in by_id["guitar"]
assert by_id["click"]["name"] == "Click"
assert by_id["click"]["description"] == "Metronome click with 4-count lead-in."
assert by_id["click"]["default"] is False
assert "name" not in by_id["bass"] and "description" not in by_id["bass"]
assert "name" not in by_id["junk"] and "description" not in by_id["junk"]
def test_a_broken_pack_yields_an_empty_list_not_an_error(tmp_path):
# Preloading is an optimisation: an unreadable pack must fall back to the
# normal WS-driven path, never break the song-info request.
+57 -8
View File
@@ -6,16 +6,17 @@ from tones import sloppak_tone_changes
# ── sloppak_tone_changes (highway payload builder) ───────────────────────────
def test_sloppak_tone_changes_sorts_and_returns_base():
base, changes = sloppak_tone_changes({
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 = sloppak_tone_changes({
"changes": [
{"t": "nan", "name": "BadStr"},
{"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():
assert sloppak_tone_changes(None) == ("", [])
base, changes = sloppak_tone_changes({"base": 123, "changes": []})
assert base == "" and changes == []
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") == ("", [])
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})
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 == ""
+2
View File
@@ -60,12 +60,14 @@ def test_the_failure_is_actually_logged(registry, caplog):
# capture_logger() context manager for this, but it is not importable from here:
# pyproject pins pythonpath to [".", "lib"], so `tests` is not a package.)
lg = logging.getLogger("feedBack")
orig_level = lg.level
lg.addHandler(caplog.handler)
lg.setLevel(logging.ERROR)
try:
registry.get_merged()
finally:
lg.removeHandler(caplog.handler)
lg.setLevel(orig_level) # restore, or ERROR leaks onto the feedBack tree
assert any("bad-plugin" in r.getMessage() for r in caplog.records), (
"the raising provider was never named in the logs"
+234
View File
@@ -0,0 +1,234 @@
"""Tests for the session-sync relay WebSocket (/ws/sync/{session_id}).
Behavior tests run against a minimal FastAPI app carrying just the router
(fast no full-server import); one integration test imports the real server
to pin that the route is actually mounted there.
Covers the feedBack#1030 acceptance list: bidirectional fan-out, late join,
sender never echoed, room garbage collection, and the limit closes (invalid
session id, binary frames, frame size, room size, room count, rate cap)
including that one client tripping a limit doesn't disturb the others.
"""
from __future__ import annotations
import asyncio
import importlib
import sys
import time
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from routers import ws_sync
@pytest.fixture(autouse=True)
def _clean_rooms():
ws_sync._rooms.clear()
yield
ws_sync._rooms.clear()
@pytest.fixture()
def client():
app = FastAPI()
app.include_router(ws_sync.router)
with TestClient(app) as c:
yield c
def _expect_close(ws, code):
with pytest.raises(WebSocketDisconnect) as exc:
ws.receive_text()
assert exc.value.code == code
# ── Fan-out semantics ────────────────────────────────────────────────────────
def test_two_clients_relay_both_directions_and_no_echo(client):
with client.websocket_connect("/ws/sync/ROOM01") as a, \
client.websocket_connect("/ws/sync/ROOM01") as b:
a.send_text('{"type":"time","t":1.5}')
assert b.receive_text() == '{"type":"time","t":1.5}'
b.send_text('{"type":"hello"}')
# A's first inbound frame is B's hello — NOT an echo of its own send.
assert a.receive_text() == '{"type":"hello"}'
def test_late_joiner_receives_subsequent_frames(client):
with client.websocket_connect("/ws/sync/ROOM02") as a, \
client.websocket_connect("/ws/sync/ROOM02") as b:
a.send_text("f1")
assert b.receive_text() == "f1"
with client.websocket_connect("/ws/sync/ROOM02") as c:
a.send_text("f2")
assert b.receive_text() == "f2"
assert c.receive_text() == "f2"
def test_rooms_are_isolated(client):
with client.websocket_connect("/ws/sync/ROOMA1") as a, \
client.websocket_connect("/ws/sync/ROOMB1") as b, \
client.websocket_connect("/ws/sync/ROOMA1") as a2:
a.send_text("for-room-a")
assert a2.receive_text() == "for-room-a"
# B (other room) got nothing: prove it by relaying within B's room.
with client.websocket_connect("/ws/sync/ROOMB1") as b2:
b2.send_text("for-room-b")
assert b.receive_text() == "for-room-b"
def test_client_disconnect_does_not_disrupt_remaining(client):
with client.websocket_connect("/ws/sync/ROOM03") as a, \
client.websocket_connect("/ws/sync/ROOM03") as b:
with client.websocket_connect("/ws/sync/ROOM03") as c:
a.send_text("before")
assert b.receive_text() == "before"
assert c.receive_text() == "before"
# C is gone; relay between A and B continues.
a.send_text("after")
assert b.receive_text() == "after"
def test_room_garbage_collected_when_last_client_leaves(client):
with client.websocket_connect("/ws/sync/ROOM04") as a:
with client.websocket_connect("/ws/sync/ROOM04") as b:
a.send_text("x")
assert b.receive_text() == "x"
assert "ROOM04" in ws_sync._rooms
assert "ROOM04" not in ws_sync._rooms
assert ws_sync._rooms == {}
# ── Limit enforcement ────────────────────────────────────────────────────────
@pytest.mark.parametrize("bad_id", ["abc", "x" * 65, "has space", "bad$id", "nope!"])
def test_invalid_session_id_closed_with_policy_code(client, bad_id):
with client.websocket_connect(f"/ws/sync/{bad_id}") as ws:
_expect_close(ws, 1008)
assert ws_sync._rooms == {}
def test_binary_frame_closes_with_unsupported_data(client):
with client.websocket_connect("/ws/sync/ROOM05") as ws:
ws.send_bytes(b"\x00\x01")
_expect_close(ws, 1003)
def test_oversized_frame_closes_sender_only(client):
with client.websocket_connect("/ws/sync/ROOM06") as a, \
client.websocket_connect("/ws/sync/ROOM06") as b, \
client.websocket_connect("/ws/sync/ROOM06") as c:
a.send_text("x" * (ws_sync.MAX_FRAME_BYTES + 1))
_expect_close(a, 1009)
# The room carries on without A.
b.send_text("still-alive")
assert c.receive_text() == "still-alive"
def test_room_client_cap(client, monkeypatch):
monkeypatch.setattr(ws_sync, "MAX_CLIENTS_PER_ROOM", 2)
with client.websocket_connect("/ws/sync/ROOM07") as a, \
client.websocket_connect("/ws/sync/ROOM07") as b, \
client.websocket_connect("/ws/sync/ROOM07") as c:
_expect_close(c, 1013)
a.send_text("two-is-fine")
assert b.receive_text() == "two-is-fine"
def test_total_room_cap(client, monkeypatch):
monkeypatch.setattr(ws_sync, "MAX_ROOMS", 1)
with client.websocket_connect("/ws/sync/ROOM08"):
with client.websocket_connect("/ws/sync/ROOM09") as overflow:
_expect_close(overflow, 1013)
# Joining the EXISTING room is still fine at the room cap.
with client.websocket_connect("/ws/sync/ROOM08"):
pass
def test_rate_cap_closes_flooding_sender(client, monkeypatch):
monkeypatch.setattr(ws_sync, "RATE_BURST", 3.0)
monkeypatch.setattr(ws_sync, "RATE_MSGS_PER_SEC", 0.0)
with client.websocket_connect("/ws/sync/ROOM10") as a, \
client.websocket_connect("/ws/sync/ROOM10") as b:
for i in range(3):
a.send_text(f"burst-{i}")
for i in range(3):
assert b.receive_text() == f"burst-{i}"
a.send_text("one-too-many")
_expect_close(a, 1008)
# The over-limit frame was dropped, not relayed, and B lives on.
with client.websocket_connect("/ws/sync/ROOM10") as c:
c.send_text("fresh-socket")
assert b.receive_text() == "fresh-socket"
class _StalledPeer:
"""A fake room member whose send never completes (peer stopped draining)."""
async def send_text(self, text):
await asyncio.Event().wait()
def test_stalled_peer_is_evicted_and_healthy_peers_still_receive(client, monkeypatch):
monkeypatch.setattr(ws_sync, "SEND_TIMEOUT_SECONDS", 0.2)
with client.websocket_connect("/ws/sync/ROOM11") as a, \
client.websocket_connect("/ws/sync/ROOM11") as b:
# Wait for both handlers to have registered in the room, then inject
# the stalled peer directly (a real stalled TCP peer isn't
# constructible under TestClient).
deadline = time.monotonic() + 2.0
while len(ws_sync._rooms.get("ROOM11", {})) < 2:
assert time.monotonic() < deadline, "room never filled"
time.sleep(0.01)
stalled = _StalledPeer()
ws_sync._rooms["ROOM11"][stalled] = asyncio.Lock()
# Healthy delivery is not blocked behind the stalled peer, and by the
# time a second frame has round-tripped, the first fan-out's timeout
# has fired and evicted it.
a.send_text("f1")
assert b.receive_text() == "f1"
a.send_text("f2")
assert b.receive_text() == "f2"
assert stalled not in ws_sync._rooms["ROOM11"]
def test_main_run_caps_uvicorn_ws_max_size():
"""main.py must bound inbound WS frames at the transport (uvicorn defaults
to 16 MB, which would let a client materialize frames far past the relay's
16 KB application cap before the handler ever sees them)."""
import unittest.mock
import main
with (
unittest.mock.patch("logging_setup.configure_logging"),
unittest.mock.patch("uvicorn.run") as mock_run,
):
main.run()
kwargs = mock_run.call_args.kwargs
assert kwargs.get("ws_max_size") == 64 * 1024
assert kwargs["ws_max_size"] >= ws_sync.MAX_FRAME_BYTES
# ── Real-app integration ─────────────────────────────────────────────────────
def test_route_mounted_on_real_server(tmp_path, monkeypatch):
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "config"))
monkeypatch.setenv("DLC_DIR", str(tmp_path / "dlc"))
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
sys.modules.pop("server", None)
server = importlib.import_module("server")
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
monkeypatch.setattr(server, "startup_scan", lambda: None)
with TestClient(server.app) as client:
with client.websocket_connect("/ws/sync/REALAPP") as a, \
client.websocket_connect("/ws/sync/REALAPP") as b:
a.send_text('{"type":"time","t":0}')
assert b.receive_text() == '{"type":"time","t":0}'
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""Build & publish opt-in content packs (career venue media, rig VST slices).
Flat-zips a pack directory, sha256s it, and emits the ``{url, sha256, bytes}``
block that the career and rig_builder download paths consume
(``plugins/career/routes.py`` ``_download_pack``). Two modes:
--local <dir> write zips + a file:// manifest (dev/CI/tests; no network)
--publish create/upload each pack's per-pack release; emit release URLs
The zip is flat (files at the archive root) to satisfy career's zip-slip guard
(``PACK_FILENAME_RE``) and ``_validate_pack_dir``. This module is the reusable
core the content-packs CI workflow calls, so building packs is automation
never a person's manual job.
Run ``python tools/content_packs.py --selfcheck`` for the built-in round-trip.
"""
import argparse
import hashlib
import json
import re
import subprocess
import sys
import zipfile
from pathlib import Path
REPO = "got-feedBack/feedBack" # where the content-packs release lives (public)
# Must mirror career's download-time whitelist (plugins/career/routes.py
# PACK_FILENAME_RE). If the builder packs a name the downloader rejects (e.g. a
# stray .DS_Store), the published pack fails _validate_pack_dir for every client.
PACK_FILENAME_RE = re.compile(r"^[a-z0-9_-]{1,64}\.(mp4|webm|mp3|json)$")
def build_pack(src_dir: Path, out_zip: Path) -> dict:
"""Flat-zip every file directly under src_dir; return {sha256, bytes}.
Only regular files at the top level are included (venue packs are flat).
Subdirectories are skipped a nested tree would trip career's zip-slip
guard on download anyway.
The build is REPRODUCIBLE: identical file contents always yield a
byte-identical zip (fixed name order, fixed mtime, fixed permissions,
ZIP_STORED). So a sha256 computed on any machine matches the zip the CI
workflow or another contributor produces anyone can precompute the
manifest values without having to be the one who uploads the asset.
"""
files = sorted((p for p in src_dir.iterdir() if p.is_file()),
key=lambda p: p.name)
if not files:
raise ValueError(f"no files to pack in {src_dir}")
bad = [p.name for p in files if not PACK_FILENAME_RE.fullmatch(p.name)]
if bad:
raise ValueError(
f"{src_dir}: files the downloader will reject: {bad} "
f"(allowed: {PACK_FILENAME_RE.pattern})")
out_zip.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_STORED) as zf:
# ZIP_STORED: the media (mp4/mp3) and .vst3 binaries are already
# compressed; deflating just burns CPU for ~0 gain.
for p in files:
# Fixed mtime (the zip epoch, 1980-01-01) + fixed perms so the
# bytes don't depend on the checkout's file timestamps.
info = zipfile.ZipInfo(p.name, date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_STORED
# Pin create_system: ZipInfo defaults it from the host OS (0 on
# Windows, 3 on Unix), which would otherwise make the same pack
# hash differently across runners. 3 = Unix.
info.create_system = 3
info.external_attr = 0o644 << 16
zf.writestr(info, p.read_bytes())
data = out_zip.read_bytes()
return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
# rig_builder ships "fat" .vst3 bundles carrying all three platforms inside
# Contents/. A pack for one platform keeps that platform's binary dir + the
# shared bundle files, and drops the other two.
VST_PLATFORM_DIRS = {"mac": "MacOS", "win": "x86_64-win", "linux": "x86_64-linux"}
def build_vst_pack(vst_root: Path, out_zip: Path, platform: str) -> dict:
"""Reproducibly zip the VST tree keeping only `platform`'s binaries.
Slices each fat .vst3: everything is kept except the two foreign platform
dirs (MacOS / x86_64-win / x86_64-linux) and the vst/src build trees. Arc
names are relative to vst_root so the download endpoint extracts straight
into <plugin>/vst/. Same reproducible-build guarantees as build_pack.
"""
if platform not in VST_PLATFORM_DIRS:
raise ValueError(f"unknown platform {platform!r} (want mac/win/linux)")
foreign = set(VST_PLATFORM_DIRS.values()) - {VST_PLATFORM_DIRS[platform]}
files = []
for p in sorted(vst_root.rglob("*"), key=lambda q: q.as_posix()):
if not p.is_file():
continue
rel = p.relative_to(vst_root)
if rel.parts and rel.parts[0] == "src": # skip C++/JUCE build trees
continue
if set(rel.parts) & foreign: # drop foreign-platform binaries
continue
files.append((p, rel))
if not files:
raise ValueError(f"no VST files to pack for {platform} in {vst_root}")
out_zip.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_STORED) as zf:
for p, rel in files:
info = zipfile.ZipInfo(rel.as_posix(), date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_STORED
# Pin create_system like build_pack: ZipInfo defaults it from the
# host OS (0 on Windows, 3 on Unix), which would otherwise make the
# same pack hash differently across runners. VST packs are the most
# likely to be built on Windows (native .vst3), so without this pin
# the precomputable-hash guarantee breaks exactly where it's needed.
info.create_system = 3
info.external_attr = 0o644 << 16
zf.writestr(info, p.read_bytes())
data = out_zip.read_bytes()
return {"sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)}
def manifest_entry(out_zip: Path, url: str) -> dict:
"""Pack info as the download-path expects it: {url, sha256, bytes}."""
return {"url": url,
"sha256": hashlib.sha256(out_zip.read_bytes()).hexdigest(),
"bytes": out_zip.stat().st_size}
# Per-pack, versioned, immutable release convention (matches what the team
# already published, e.g. tag `venue-arena-v1` / asset `arena-pack-v1.zip`).
def pack_tag(pack_id: str, version: int) -> str:
return f"venue-{pack_id}-v{version}"
def pack_asset(pack_id: str, version: int) -> str:
return f"{pack_id}-pack-v{version}.zip"
def pack_url(pack_id: str, version: int, repo: str = REPO) -> str:
return (f"https://github.com/{repo}/releases/download/"
f"{pack_tag(pack_id, version)}/{pack_asset(pack_id, version)}")
# VST packs use the same immutable per-pack convention, keyed by platform:
# tag `vst-<plat>-v<N>`, asset `vst-<plat>-pack-v<N>.zip`. The manifest they
# emit is keyed by platform (mac/win/linux) — the shape the rig_builder plugin's
# data/vst_packs.json consumes.
def vst_tag(platform: str, version: int) -> str:
return f"vst-{platform}-v{version}"
def vst_asset(platform: str, version: int) -> str:
return f"vst-{platform}-pack-v{version}.zip"
def vst_url(platform: str, version: int, repo: str = REPO) -> str:
return (f"https://github.com/{repo}/releases/download/"
f"{vst_tag(platform, version)}/{vst_asset(platform, version)}")
def _publish_release(tag: str, zip_path: Path, title: str, notes: str,
repo: str = REPO) -> None:
"""Create the per-pack release if missing, then upload the versioned zip.
Tags are immutable: a media change means a new version (v1 v2), never a
re-upload so no --clobber. gh errors if the asset already exists, which is
the right guard against overwriting a published, referenced pack.
"""
if subprocess.run(["gh", "release", "view", tag, "--repo", repo],
capture_output=True).returncode != 0:
subprocess.run(
["gh", "release", "create", tag, "--repo", repo, "--latest=false",
"--title", title, "--notes", notes],
check=True)
subprocess.run(
["gh", "release", "upload", tag, str(zip_path), "--repo", repo], check=True)
def publish(pack_id: str, version: int, zip_path: Path, repo: str = REPO) -> None:
_publish_release(pack_tag(pack_id, version), zip_path,
f"{pack_id.capitalize()} venue pack v{version}",
"Opt-in career venue pack. Not a code release.", repo)
def _pack_id(src_dir: Path) -> str:
return src_dir.name
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("src", nargs="*", type=Path,
help="pack source dirs (e.g. plugins/career/venue-packs/club)")
ap.add_argument("--version", type=int, default=1,
help="pack version (tag venue-<id>-v<N>); default 1")
ap.add_argument("--local", type=Path, metavar="DIR",
help="write zips here + a file:// manifest.json; no upload")
ap.add_argument("--publish", action="store_true",
help="create/upload the per-pack release; emit release URLs")
ap.add_argument("--vst", action="store_true",
help="slice one rig VST root (src[0]) into per-platform "
"vst-<plat>-v<N> packs; manifest keyed by platform "
"(the shape rig_builder's data/vst_packs.json wants)")
ap.add_argument("--manifest", type=Path,
help="write the {id: {url,sha256,bytes}} map here (default: stdout)")
ap.add_argument("--selfcheck", action="store_true", help="run the round-trip demo and exit")
args = ap.parse_args(argv)
if args.selfcheck:
return _selfcheck()
if not args.src or (not args.local and not args.publish):
ap.error("need one or more src dirs and either --local or --publish")
out_dir = args.local if args.local else Path(args.src[0]).parent / "_packs"
manifest = {}
if args.vst:
vst_root = args.src[0]
for plat in VST_PLATFORM_DIRS:
zip_path = out_dir / vst_asset(plat, args.version)
build_vst_pack(vst_root, zip_path, plat)
if args.publish:
_publish_release(vst_tag(plat, args.version), zip_path,
f"Rig VST pack ({plat}) v{args.version}",
"Opt-in per-platform rig VST pack. Not a code release.")
url = vst_url(plat, args.version)
else:
url = (out_dir.resolve() / zip_path.name).as_uri()
manifest[plat] = manifest_entry(zip_path, url)
else:
for src in args.src:
pid = _pack_id(src)
zip_path = out_dir / pack_asset(pid, args.version)
build_pack(src, zip_path)
if args.publish:
publish(pid, args.version, zip_path)
url = pack_url(pid, args.version)
else:
url = (out_dir.resolve() / zip_path.name).as_uri()
manifest[pid] = manifest_entry(zip_path, url)
out = json.dumps(manifest, indent=2)
if args.manifest:
args.manifest.write_text(out + "\n", encoding="utf-8")
else:
print(out)
return 0
def _selfcheck() -> int:
"""Build a pack and confirm build_pack/manifest_entry agree on the digest."""
import tempfile
with tempfile.TemporaryDirectory() as td:
td = Path(td)
src = td / "bar"
src.mkdir()
(src / "manifest.json").write_text('{"venue":"bar"}')
(src / "bored.mp4").write_bytes(b"\x00fake-video")
zip_path = td / pack_asset("bar", 1)
info = build_pack(src, zip_path)
# Reproducible: a second build (into a different path) is byte-identical.
info2 = build_pack(src, td / "again.zip")
assert info2["sha256"] == info["sha256"], "build is not reproducible"
entry = manifest_entry(zip_path, pack_url("bar", 1))
assert entry["sha256"] == info["sha256"], "digest mismatch"
assert entry["bytes"] == info["bytes"]
assert entry["url"] == (
f"https://github.com/{REPO}/releases/download/venue-bar-v1/bar-pack-v1.zip")
# Round-trip: the zip must be flat (names == basenames).
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
assert set(names) == {"manifest.json", "bored.mp4"}, names
# VST slice: keep target platform + shared, drop foreign, reproducible.
c = td / "vst" / "Foo.vst3" / "Contents"
for d in ("MacOS", "x86_64-win", "x86_64-linux", "Resources"):
(c / d).mkdir(parents=True)
(c / "MacOS" / "Foo").write_bytes(b"mac")
(c / "x86_64-linux" / "Foo.so").write_bytes(b"linux")
(c / "Info.plist").write_bytes(b"<plist/>")
vzip = td / vst_asset("linux", 1)
vinfo = build_vst_pack(td / "vst", vzip, "linux")
assert vinfo == build_vst_pack(td / "vst", td / "v2.zip", "linux"), \
"vst slice is not reproducible"
with zipfile.ZipFile(vzip) as zf:
vnames = set(zf.namelist())
assert "Foo.vst3/Contents/x86_64-linux/Foo.so" in vnames
assert "Foo.vst3/Contents/Info.plist" in vnames
assert not any("MacOS" in n for n in vnames), vnames
print("content_packs selfcheck: ok")
return 0
if __name__ == "__main__":
sys.exit(main())