mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 17:54:30 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef58c88c3 | ||
|
|
1a7e2bf084 | ||
|
|
32c00cdd78 | ||
|
|
8297afc449 | ||
|
|
59bcf338a3 | ||
|
|
03e1c1d57e | ||
|
|
0e3522ccc3 | ||
|
|
e0270e5c30 | ||
|
|
605dbdfd25 | ||
|
|
a9be210f77 | ||
|
|
35c0d0ea0d | ||
|
|
270cb39f41 | ||
|
|
23c509322b | ||
|
|
fcdb4867d6 | ||
|
|
be49465540 | ||
|
|
05be9ebdbe | ||
|
|
f7942f3689 | ||
|
|
1cd6f2dd65 | ||
|
|
39d1a8cb9b | ||
|
|
32ed564006 | ||
|
|
1712803dc7 | ||
|
|
00fce2772d | ||
|
|
1745b13ba7 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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". |
|
||||
|
||||
@@ -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
|
||||
```
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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/<hash>.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
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Generated
+964
-1
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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 anchor’s
|
||||
// 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, '');
|
||||
});
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
@@ -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 A–B
|
||||
// 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,
|
||||
|
||||
@@ -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 (_) {}
|
||||
})();
|
||||
})();
|
||||
|
||||
@@ -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
@@ -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
|
||||
// instrument’s 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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
});
|
||||
})();
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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 · /);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 == ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}'
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user