Add new chart-transform plugin capability (#1000)

* Chart-transform plugin capability

* PR comments

* Cleanup

* Fix markdown

* CodeRabbit feedback

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

---------

Signed-off-by: Joe <jphinspace@gmail.com>
Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
This commit is contained in:
Joe Hallahan
2026-07-19 11:27:52 +02:00
committed by GitHub
co-authored by Byron Gamatos
parent f7942f3689
commit 05be9ebdbe
18 changed files with 1345 additions and 54 deletions
+8
View File
@@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- **`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 - **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" 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 in one tap — the same query Stem Splitter's missing-stems view runs — instead of
+8
View File
@@ -400,6 +400,14 @@ highway.setNoteStateProvider((note, chartTime) => {
- The built-in 2D highway consults it in `drawNote` / `drawSustains` / the chord-frame path: 'hit'/'active' → bright string colour + additive halo + a contained "sizzle" (crackling sparks, throbbing core, a shockwave ring on a fresh strike) on the gem and a bright (vs dim) sustain trail; 'miss' → faint red wash. The bundled **3D highway** reads the same data via `bundle.getNoteState` (bright string-tinted outline + bright body + glowing sustain + a contained sparkle hugging the note rect on hit/active; red outline + suppressed body on miss). Custom renderers that want it call `bundle.getNoteState(note, chartTime)` — it null-guards and returns the normalized `{ state, alpha, color }` (or null). - 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. - 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) ### 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. 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.
+12 -3
View File
@@ -153,6 +153,14 @@ The legacy chart-coupled surface — `highway.setNoteStateProvider(fn)`, the sin
Diagnostics live under `feedBack.note_detection_capability.v1` and contain provider ids/labels/kinds, binding summaries (requester, provider, redacted context, target size), availability, and the last bounded outcome (event, binding, provider, MIDI number, hit flag) — never raw audio buffers, sample data, device labels, or song identity. 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 ## 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. 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. - `active`: wired to current FeedBack behavior and expected to work as an integration point.
- `diagnostic`: support/inspection-only runtime surfaces. - `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. 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 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 ## First-Party Management Plugins
@@ -295,8 +303,9 @@ From the `feedBack/` directory:
```bash ```bash
node --check static/app.js node --check static/app.js
node --check static/capabilities.js node --check static/capabilities.js
node --check static/capabilities/chart-transform.js
node --check static/diagnostics.js node --check static/diagnostics.js
node --check plugins/capability_inspector/screen.js node --check plugins/capability_inspector/screen.js
node --test tests/js/*.test.js node --test tests/js/*.test.js
pytest tests/test_plugin_runtime_idempotence.py tests/test_plugins.py tests/test_diagnostics_bundle.py -q pytest tests/test_plugin_runtime_idempotence.py tests/test_plugins.py tests/test_diagnostics_bundle.py -q
``` ```
+51
View File
@@ -499,6 +499,57 @@ window.feedBack.on('progression:quest-completed', (e) => {
}); });
``` ```
## Chart-Transform Provider
Plugins that transpose, simplify, annotate, or otherwise rewrite chart data register as `chart-transform` providers (#952). The effective chart reaches the built-in highway, custom renderers, and highway getters on primary and splitscreen instances.
```json
{
"id": "my_transform",
"name": "My Transform",
"standards": ["capability-pipelines.v1"],
"capabilities": {
"chart-transform": {
"roles": ["provider"],
"operations": ["chart.transform"],
"mode": "active",
"compatibility": "none",
"ownership": "multi-provider",
"safety": "safe",
"version": 1
}
}
}
```
```js
const api = window.feedBack.capabilities;
await api.dispatch({
capability: 'chart-transform',
command: 'register-provider',
source: 'my_transform',
payload: {
providerId: 'my_transform',
label: 'My Transform',
transform(input) {
const notes = rewriteNotes(input.notes);
const allNotes = input.allNotes === input.notes ? notes : rewriteNotes(input.allNotes);
return { notes, allNotes };
},
},
});
await api.dispatch({ capability: 'chart-transform', command: 'select-provider',
source: 'my_transform', payload: { providerId: 'my_transform' } });
await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: 'my_transform' });
```
`transform(input)` receives filtered `notes`, `chords`, `anchors`, and `handShapes`, plus full-difficulty `allNotes`/`allChords`, `chordTemplates`, `stringCount`, and `songInfo`. It may synchronously return any subset of those arrays plus `tuning`, `capo`, or `centOffset`; null leaves the chart unchanged. The host isolates provider inputs and outputs, time-sorts accepted timelines, and falls back to the original chart on failure.
Transforms run at chart ready, mastery recompute, and explicit `refresh`, never per frame. Selection persists by provider id. `getSongInfo()` retains original metadata; effective metadata is available through the renderer bundle and `getStringCount()`, `getTuning()`, `getCapo()`, and `getCentOffset()`.
## Future Expansion Domains ## 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. Some domain names are reserved for expected future contracts, but they are not registered in the runtime graph yet. For example, `ui.player-panels` is documented as a likely panel-host surface, but FeedBack does not currently expose a capability command for panel contributions. See [capability-roadmap.md](capability-roadmap.md) for the PR1 domain set and deferred-domain checklist.
+4
View File
@@ -60,6 +60,10 @@ The progression slice (spec 010) promotes `progression` as an active exclusive-o
Deferred follow-up slices: a `contributor` role so plugins ship their own challenge/quest content (drums challenges from a drums-scoring plugin, quest-pool entries from minigame plugins), and drums scoring wiring so `song_completed {instrument: "drums"}` goals become satisfiable. 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 ## Recommended Next Slices
The plugin inventory suggests this migration order after the audio graph/session and playback slices: The plugin inventory suggests this migration order after the audio graph/session and playback slices:
+1 -1
View File
@@ -20,7 +20,7 @@ Core domains also have a review scope. **Active contract** domains are wired to
| visualization | provider-coordinator | safe | inspect, list-providers, select-renderer, clear-renderer | renderer.create, renderer.destroy | Highway renderer provider registry, picker-delegated selection, auto-match attribution, and failure fallback. `renderer.create` maps to the legacy `window.feedBackViz_*` factory `init(canvas, ctx)` call; `renderer.destroy` maps to the factory `destroy()` teardown. Legacy `type: "visualization"` manifests and `window.feedBackViz_*` globals are accounted compatibility shims. Diagnostics carry provider ids/labels, selection source, last auto-match outcome, and last failure — no song filenames, titles, or arrangement names. | | 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. | | 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. | | 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. 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.
+3 -2
View File
@@ -25,8 +25,8 @@
Object.freeze({ Object.freeze({
id: 'player-audio', id: 'player-audio',
label: 'Player and Audio Runtime', label: 'Player and Audio Runtime',
summary: 'Playback, renderer, mixer, monitoring, effects, and note-detection surfaces.', summary: 'Playback, renderer, chart-transform, mixer, monitoring, effects, and note-detection surfaces.',
domains: Object.freeze(['playback', 'visualization', 'audio-mix', 'audio-input', 'audio-monitoring', 'audio-effects', 'stems', 'note-detection']), domains: Object.freeze(['playback', 'visualization', 'chart-transform', 'audio-mix', 'audio-input', 'audio-monitoring', 'audio-effects', 'stems', 'note-detection']),
}), }),
Object.freeze({ Object.freeze({
id: 'plugin-defined', id: 'plugin-defined',
@@ -50,6 +50,7 @@
'audio-monitoring': 'headphones', 'audio-monitoring': 'headphones',
stems: 'sliders', stems: 'sliders',
'note-detection': 'activity', 'note-detection': 'activity',
'chart-transform': 'box',
diagnostics: 'fileSearch', diagnostics: 'fileSearch',
pipeline: 'activity', pipeline: 'activity',
'ui.navigation': 'list', 'ui.navigation': 'list',
+1 -1
View File
@@ -155,7 +155,7 @@ Every per-frame renderer call receives a `bundle` from feedBack core. Fields use
- `lefty` — display flag consumed by this renderer from `bundle.lefty`. Captured into `_leftyCached` before each frame so `xFret()`, `xFretMid()`, `boardSpanX()`, board geometry, note placement, and the camera shoulder offset mirror the fret axis for left-handed mode. A runtime lefty flip rebuilds board state and mirrors `curX`/`tgtX` plus the lookahead camera X cache so the camera does not drift across the neck. - `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`. - `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. Core reuses the bundle OBJECT across frames (mutated in place); never cache it or compare its identity between frames — field values are only valid for the current draw call. Field-identity caches on ARRAY fields (this plugin's `_mergeCacheChordsRef === bundle.chords` etc.) remain valid: arrays still swap reference when chart data changes. Core also exposes `bundle.lowerBoundT(arr, time)` (lower-bound on `.t`, notes/chords) and `bundle.lowerBoundTime(arr, time)` (on `.time`, beats/anchors/sections) — prefer these over the local `lowerBoundT` helper when a downlevel-host fallback isn't needed.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"id": "highway_3d", "id": "highway_3d",
"name": "3D Highway", "name": "3D Highway",
"version": "3.32.0", "version": "3.33.0",
"type": "visualization", "type": "visualization",
"bundled": true, "bundled": true,
"script": "screen.js", "script": "screen.js",
+16 -10
View File
@@ -858,9 +858,13 @@
*/ */
function _openStringPitchLabelsForTuning(bundle, songInfo, nEffective) { function _openStringPitchLabelsForTuning(bundle, songInfo, nEffective) {
const n = Number.isFinite(nEffective) ? Math.min(Math.max(1, Math.trunc(nEffective)), MAX_RENDER_STRINGS) : resolveStringCount(bundle); 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; // bundle first: chart-transform substitutes tuning/capo there, while
let cap = songInfo && songInfo.capo; // songInfo keeps the chart's originals by contract. A malformed
cap = Number.isFinite(cap) ? cap : (Number.isFinite(bundle.capo) ? bundle.capo : 0); // (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 = []; if (!Array.isArray(tuning)) tuning = [];
const base = _baseOpenStringMidis(n, songInfo?.arrangement); const base = _baseOpenStringMidis(n, songInfo?.arrangement);
@@ -5604,13 +5608,15 @@
function _openStringLabelSignature(bundle, labels) { function _openStringLabelSignature(bundle, labels) {
const si = bundle && bundle.songInfo; const si = bundle && bundle.songInfo;
const tun = si && si.tuning; // Same bundle-first preference as _openStringPitchLabelsForTuning.
let tStr = ''; let tStr = '';
if (Array.isArray(tun)) tStr = tun.slice(0, labels.length).join(','); if (bundle && Array.isArray(bundle.tuning)) tStr = bundle.tuning.slice(0, labels.length).join(',');
else 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 = 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 : ''; const arrIdx = si && si.arrangement_index != null ? si.arrangement_index : '';
let palSig = ''; let palSig = '';
const nLab = labels.length; const nLab = labels.length;
@@ -5645,8 +5651,8 @@
const tunRef = (si && Array.isArray(si.tuning)) ? si.tuning : null; const tunRef = (si && Array.isArray(si.tuning)) ? si.tuning : null;
const bundleTunRef = Array.isArray(bundle.tuning) ? bundle.tuning : null; const bundleTunRef = Array.isArray(bundle.tuning) ? bundle.tuning : null;
const capo = const capo =
si && Number.isFinite(si.capo) ? si.capo Number.isFinite(bundle.capo) ? bundle.capo
: (Number.isFinite(bundle.capo) ? bundle.capo : NaN); : (si && Number.isFinite(si.capo) ? si.capo : 0);
const arrIdx = si && si.arrangement_index != null ? si.arrangement_index : undefined; const arrIdx = si && si.arrangement_index != null ? si.arrangement_index : undefined;
if ( if (
_tuningLabelSprites.length === nStr && _tuningLabelSprites.length === nStr &&
+2 -1
View File
@@ -128,6 +128,7 @@
stems: Object.freeze({ lifecycle: 'active', label: 'Active contract', tone: 'clean', summary: 'Core-coordinated stem automation, restore, manual override, and compatibility bridge surface backed by the active Stems provider.' }), 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.' }), 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.' }), '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({}); const EXPECTED_COMPATIBILITY_SHIMS = Object.freeze({});
@@ -1536,4 +1537,4 @@
window.dispatchEvent(new CustomEvent('feedBack:capabilities:ready', { detail: api })); window.dispatchEvent(new CustomEvent('feedBack:capabilities:ready', { detail: api }));
_notifySubscribers('registered', { capability: '*', pluginId: 'core', timestamp: _now() }); _notifySubscribers('registered', { capability: '*', pluginId: 'core', timestamp: _now() });
} catch (_) {} } catch (_) {}
})(); })();
+336
View File
@@ -0,0 +1,336 @@
// Chart-transform provider registration, selection, and diagnostics.
// Transformation stays on the synchronous highway data plane and runs after
// difficulty filtering; the selected provider is shared by highway instances.
(function () {
'use strict';
window.feedBack = window.feedBack || {};
const capabilities = window.feedBack.capabilities;
if (!capabilities || capabilities.version !== 1) return;
if (window.feedBack.chartTransformDomain && window.feedBack.chartTransformDomain.version === 1) return;
const STORAGE_KEY = 'feedBack.chartTransform.selectedProviderId';
const PUBLIC_FAILURE_REASON = 'Chart transform provider failed';
// providerId → { id, label, pluginId, transform }
const providers = new Map();
let activeProviderId = null;
let activeSource = 'startup';
let lastFailure = null;
// Count of highway instances the active provider is installed on
// (the primary window.highway plus any announced via highway:created —
// e.g. splitscreen panels). 0 = nothing capable exists yet.
let installedCount = 0;
// Known highway surfaces beyond window.highway, held weakly so closed
// splitscreen panels can be collected. WeakRef is guarded for minimal
// test environments; the strong-ref fallback only over-retains there.
const _HasWeakRef = typeof WeakRef === 'function';
let _surfaces = [];
function _handled(payload = {}) { return { outcome: 'handled', payload }; }
function _degraded(reason, payload = {}) { return { outcome: 'degraded', reason, payload }; }
function _snapshot(extra = {}) {
return {
available: true,
active: activeProviderId,
activeSource,
installed: installedCount > 0,
surfaces: installedCount,
providers: [...providers.values()].map(p => ({
id: p.id,
label: p.label,
pluginId: p.pluginId,
})),
lastFailure: lastFailure ? { ...lastFailure } : null,
...extra,
};
}
function _emit(name, detail) {
try { capabilities.emitEvent('chart-transform', name, detail || {}); }
catch (_) { /* eventing must not break rendering */ }
}
function _contributeDiagnostics() {
const diagnostics = window.feedBack && window.feedBack.diagnostics;
if (diagnostics && typeof diagnostics.contribute === 'function') {
try {
diagnostics.contribute('chart-transform-capability', {
schema: 'feedBack.chart_transform.diagnostics.v1',
..._snapshot(),
});
} catch (_) { /* diagnostics must not break rendering */ }
}
}
function _persistSelection(providerId) {
try {
if (providerId) window.localStorage.setItem(STORAGE_KEY, providerId);
else window.localStorage.removeItem(STORAGE_KEY);
} catch (_) { /* storage unavailable → in-memory selection only */ }
}
function _persistedSelection() {
try { return window.localStorage.getItem(STORAGE_KEY) || null; }
catch (_) { return null; }
}
function _capable(hw) {
return !!(hw && typeof hw.setChartTransform === 'function');
}
// Every capable highway surface: window.highway plus live announced
// instances (splitscreen panels), deduped, dead refs pruned in place.
function _eachSurface(fn) {
const seen = new Set();
const primary = window.highway;
if (_capable(primary)) { seen.add(primary); fn(primary); }
const live = [];
for (const ref of _surfaces) {
const hw = _HasWeakRef ? ref.deref() : ref;
if (!hw) continue;
live.push(ref);
if (seen.has(hw) || !_capable(hw)) continue;
seen.add(hw);
fn(hw);
}
_surfaces = live;
return seen.size;
}
function _rememberSurface(hw) {
if (!_capable(hw) || hw === window.highway) return;
let known = false;
_eachSurface(() => {});
for (const ref of _surfaces) {
if ((_HasWeakRef ? ref.deref() : ref) === hw) { known = true; break; }
}
if (!known) _surfaces.push(_HasWeakRef ? new WeakRef(hw) : hw);
}
// Hand the current selection to every highway surface (or clear it).
// Selection survives with zero surfaces — it re-applies as instances
// appear (song:ready for the primary, highway:created for panels).
function _install() {
const provider = activeProviderId ? providers.get(activeProviderId) : null;
const payload = provider ? { id: provider.id, transform: provider.transform } : null;
installedCount = 0;
_eachSurface((hw) => {
try {
hw.setChartTransform(payload);
if (payload) installedCount += 1;
} catch (_) { /* one broken surface must not block the rest */ }
});
return installedCount > 0 || payload === null;
}
function _setActive(providerId, source) {
const from = activeProviderId;
activeProviderId = providerId;
activeSource = String(source || 'unknown');
_persistSelection(providerId);
_install();
if (from !== providerId) {
_emit('transform-changed', { from, to: providerId, source: activeSource });
}
_contributeDiagnostics();
}
function _payload(ctx = {}) {
return ctx.payload && typeof ctx.payload === 'object' ? ctx.payload : {};
}
function _providersForParticipant(participantId) {
return [...providers.values()].filter(provider => provider.pluginId === participantId);
}
function _registerProviderParticipant(participantId) {
const owned = _providersForParticipant(participantId);
if (!owned.length) return;
capabilities.registerParticipant(participantId, {
'chart-transform': {
roles: ['provider'],
operations: ['chart.transform'],
events: [],
mode: 'active',
compatibility: 'none',
safety: 'safe',
runtime: true,
description: `${owned.length} registered chart transform provider${owned.length === 1 ? '' : 's'}.`,
provider_policy: {
providerIds: owned.map(provider => provider.id),
providers: owned.map(provider => ({ id: provider.id, label: provider.label })),
},
},
});
}
function _registerProvider(ctx = {}) {
const payload = _payload(ctx);
const providerId = String(payload.providerId || payload.id || '').trim();
if (!providerId) return _degraded('Provider registration requires a providerId', _snapshot());
if (typeof payload.transform !== 'function') {
return _degraded('Provider registration requires a transform(input) function', _snapshot());
}
const participantId = String(ctx.source || ctx.requester || providerId);
const existing = providers.get(providerId);
if (existing && existing.pluginId !== participantId) {
return _degraded(
`Provider ${providerId} is already registered by a different participant`,
_snapshot(),
);
}
providers.set(providerId, {
id: providerId,
label: String(payload.label || providerId),
pluginId: participantId,
transform: payload.transform,
});
_registerProviderParticipant(participantId);
_emit('provider-registered', { providerId });
// Restore a persisted selection the moment its provider appears.
if (!activeProviderId && _persistedSelection() === providerId) {
_setActive(providerId, 'restore-selection');
} else if (activeProviderId === providerId) {
// Re-registration after script rehydration: reinstall the fresh
// transform closure so the highway isn't holding a stale one.
_install();
}
_contributeDiagnostics();
return _handled(_snapshot({ registered: providerId }));
}
function _unregisterProvider(ctx = {}) {
const payload = _payload(ctx);
const providerId = String(payload.providerId || payload.id || '').trim();
const provider = providers.get(providerId);
if (!provider) return _degraded(`Unknown chart-transform provider: ${providerId || '(none)'}`, _snapshot());
const callerId = String(ctx.source || ctx.requester || providerId);
if (provider.pluginId !== callerId) {
return _degraded(
`Provider ${providerId} can only be unregistered by its original registrant`,
_snapshot(),
);
}
providers.delete(providerId);
if (activeProviderId === providerId) {
// Keep the persisted selection so the provider re-activates on
// its next registration; just detach it from the highway.
activeProviderId = null;
_install();
_emit('transform-changed', { from: providerId, to: null, source: 'provider-unregistered' });
}
const remainingProviders = _providersForParticipant(provider.pluginId);
if (remainingProviders.length) {
_registerProviderParticipant(provider.pluginId);
} else if (typeof capabilities.unregisterParticipant === 'function') {
const live = typeof capabilities.inspect === 'function' ? capabilities.inspect('chart-transform') : null;
const participant = ((live && live.participants) || []).find(p => p.pluginId === provider.pluginId);
const roles = participant && Array.isArray(participant.roles) ? participant.roles : [];
const providerOnly = roles.length === 1 && roles[0] === 'provider';
if (!participant || providerOnly) {
try { capabilities.unregisterParticipant(provider.pluginId, 'chart-transform'); }
catch (_) { /* participant cleanup is best-effort */ }
}
}
_emit('provider-unregistered', { providerId });
_contributeDiagnostics();
return _handled(_snapshot({ unregistered: providerId }));
}
function _targetProviderId(ctx = {}) {
const payload = _payload(ctx);
const target = ctx.target && typeof ctx.target === 'object' ? ctx.target : {};
return String(
target.providerId || target.provider_id || target.id
|| payload.providerId || payload.provider_id || payload.id
|| (typeof ctx.target === 'string' ? ctx.target : '') || ''
).trim();
}
function _selectProvider(ctx = {}) {
const providerId = _targetProviderId(ctx);
if (!providerId) return _degraded('Transform selection requires a provider id', _snapshot());
if (!providers.has(providerId)) {
return _degraded(`Unknown chart-transform provider: ${providerId}`, _snapshot());
}
_setActive(providerId, ctx.requester ? `command:${ctx.requester}` : 'command');
return _handled(_snapshot({ selected: providerId }));
}
function _clearProvider(ctx = {}) {
_setActive(null, ctx.requester ? `command:${ctx.requester}` : 'command');
return _handled(_snapshot({ cleared: true }));
}
function _refresh() {
if (!activeProviderId || installedCount === 0) return _handled(_snapshot({ refreshed: false }));
let refreshed = 0;
_eachSurface((hw) => {
if (typeof hw.refreshChartTransform !== 'function') return;
try { hw.refreshChartTransform(); refreshed += 1; }
catch (_) { /* one broken surface must not block the rest */ }
});
return _handled(_snapshot({ refreshed: refreshed > 0 }));
}
capabilities.registerOwner('chart-transform', {
pluginId: 'core.chart-transform',
kind: 'provider-coordinator',
safety: 'safe',
commands: ['inspect', 'list-providers', 'register-provider', 'unregister-provider', 'select-provider', 'clear-provider', 'refresh'],
operations: ['chart.transform'],
events: ['provider-registered', 'provider-unregistered', 'transform-changed', 'transform-failed'],
description: 'Owns chart-transform providers: pre-render/pre-scoring chart substitution applied after difficulty filtering, with selection, refresh, and failure attribution.',
handlers: {
inspect: () => _handled(_snapshot()),
'list-providers': () => _handled(_snapshot()),
'register-provider': (ctx) => _registerProvider(ctx),
'unregister-provider': (ctx) => _unregisterProvider(ctx),
'select-provider': (ctx) => _selectProvider(ctx),
'clear-provider': (ctx) => _clearProvider(ctx),
refresh: () => _refresh(),
},
});
// Bus mirroring (guarded: the bus may not exist in minimal/test envs).
const sm = window.feedBack;
if (typeof sm.on === 'function') {
try {
sm.on('highway:chart-transform-failed', (e) => {
const detail = (e && e.detail) || e || {};
lastFailure = {
providerId: String(detail.id || activeProviderId || 'unknown'),
reason: PUBLIC_FAILURE_REASON,
};
_emit('transform-failed', { ...lastFailure });
_contributeDiagnostics();
});
// The primary highway is created after this module evaluates —
// install a pending selection once a song is loading/ready.
sm.on('song:ready', () => {
if (activeProviderId && installedCount === 0 && _install()) {
// setChartTransform restages immediately, so the chart
// that just became ready picks the transform up now.
_contributeDiagnostics();
}
});
// Additional instances restage the active provider against their
// own chart state.
sm.on('highway:created', (e) => {
const detail = (e && e.detail) || e || {};
if (!_capable(detail.highway)) return;
_rememberSurface(detail.highway);
if (activeProviderId) _install();
_contributeDiagnostics();
});
} catch (_) { /* bus mirroring is best-effort */ }
}
window.feedBack.chartTransformDomain = {
version: 1,
snapshot: _snapshot,
};
_contributeDiagnostics();
})();
+186 -23
View File
@@ -267,6 +267,19 @@ function createHighway() {
hwState._filteredChords = null; hwState._filteredChords = null;
hwState._filteredAnchors = null; hwState._filteredAnchors = null;
hwState._filteredHandShapes = 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 // Tracks whether ANY phrase level carries handshape data. Lets us
// distinguish "this difficulty has none" (respect strictly — even // distinguish "this difficulty has none" (respect strictly — even
// when empty) from "the chart's phrase data never authored any // when empty) from "the chart's phrase data never authored any
@@ -397,7 +410,8 @@ function createHighway() {
function getAnchorAt(t) { function getAnchorAt(t) {
// Same master-difficulty fallback as the render loops — the // Same master-difficulty fallback as the render loops — the
// anchor ladder pairs with the note ladder. // 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 }; let a = src[0] || { fret: 1, width: 4 };
for (const anc of src) { for (const anc of src) {
if (anc.time > t) break; if (anc.time > t) break;
@@ -408,7 +422,8 @@ function createHighway() {
function getMaxFretInWindow(t) { function getMaxFretInWindow(t) {
// Find the highest fret needed across all anchors visible on screen // 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; let maxFret = 0;
for (const anc of src) { 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) 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 // Chart content (filter-aware — difficulty-filtered arrays
// preferred; raw arrays are the fallback when no ladder data). // preferred; raw arrays are the fallback when no ladder data).
b.notes = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes; b.notes = hwState._xfNotes !== null ? hwState._xfNotes
b.chords = hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords; : hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
b.anchors = hwState._filteredAnchors !== null ? hwState._filteredAnchors : hwState.anchors; 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.beats = hwState.beats;
b.sections = hwState.sections; b.sections = hwState.sections;
b.chordTemplates = hwState.chordTemplates; b.chordTemplates = hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates;
b.stringCount = hwState.stringCount; b.stringCount = hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount;
// Mirrors song_info tuning capo offsets (±semitones from the // Effective tuning metadata; live references like the chart arrays.
// instruments standard open-string layout). Live reference. b.tuning = hwState._xfTuning !== null ? hwState._xfTuning : hwState.songInfo?.tuning;
b.tuning = hwState.songInfo?.tuning; b.capo = hwState._xfCapo !== null ? hwState._xfCapo : hwState.songInfo?.capo;
b.capo = hwState.songInfo?.capo; b.centOffset = hwState._xfCentOffset !== null ? hwState._xfCentOffset : hwState.songInfo?.centOffset;
b.lyrics = hwState.lyrics; b.lyrics = hwState.lyrics;
b.lyricsSource = hwState.lyricsSource; b.lyricsSource = hwState.lyricsSource;
b.toneChanges = hwState.toneChanges; b.toneChanges = hwState.toneChanges;
@@ -572,9 +590,10 @@ function createHighway() {
// don't belong. Only fall back to the flat list when the // don't belong. Only fall back to the flat list when the
// phrase data carries no handshapes at all (common on DLC // phrase data carries no handshapes at all (common on DLC
// where handshapes ship on the arrangement root). // where handshapes ship on the arrangement root).
b.handShapes = (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes) b.handShapes = hwState._xfHandShapes !== null ? hwState._xfHandShapes
? hwState._filteredHandShapes : (hwState._filteredHandShapes !== null && hwState._phrasesHaveHandShapes)
: hwState.handShapes; ? hwState._filteredHandShapes
: hwState.handShapes;
// Display flags // Display flags
b.inverted = hwState._inverted; b.inverted = hwState._inverted;
@@ -1372,9 +1391,10 @@ function createHighway() {
// slots, so 4 strings spread across the full band rather than // slots, so 4 strings spread across the full band rather than
// using the upper 4/6ths of the 6-string layout. The Math.max // using the upper 4/6ths of the 6-string layout. The Math.max
// guards against a hypothetical 1-string instrument (denom=0). // guards against a hypothetical 1-string instrument (denom=0).
const span = Math.max(1, hwState.stringCount - 1); const sc = hwState._xfStringCount !== null ? hwState._xfStringCount : hwState.stringCount;
for (let i = 0; i < hwState.stringCount; i++) { const span = Math.max(1, sc - 1);
const yi = hwState._inverted ? (hwState.stringCount - 1 - i) : i; for (let i = 0; i < sc; i++) {
const yi = hwState._inverted ? (sc - 1 - i) : i;
const y = strTop + (yi / span) * (strBot - strTop); const y = strTop + (yi / span) * (strBot - strTop);
hwState.ctx.strokeStyle = hwState.STRING_COLORS[i] || '#888'; hwState.ctx.strokeStyle = hwState.STRING_COLORS[i] || '#888';
hwState.ctx.lineWidth = 3; hwState.ctx.lineWidth = 3;
@@ -1477,6 +1497,7 @@ function createHighway() {
hwState._filteredAnchors = null; hwState._filteredAnchors = null;
hwState._filteredHandShapes = null; hwState._filteredHandShapes = null;
hwState._phrasesHaveHandShapes = false; hwState._phrasesHaveHandShapes = false;
_restageChartTransform();
return; return;
} }
const outNotes = []; const outNotes = [];
@@ -1524,6 +1545,116 @@ function createHighway() {
} }
hwState._filteredHandShapes = outHandShapes; hwState._filteredHandShapes = outHandShapes;
hwState._phrasesHaveHandShapes = anyHandShapeInPhrases; 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 ─────────────────────────────────────────────────────── // ── Public API ───────────────────────────────────────────────────────
@@ -1568,6 +1699,8 @@ function createHighway() {
hwState._filteredAnchors = null; hwState._filteredAnchors = null;
hwState._filteredHandShapes = null; hwState._filteredHandShapes = null;
hwState._phrasesHaveHandShapes = false; hwState._phrasesHaveHandShapes = false;
// Keep _xfProvider (persists across songs); drop staged output.
_clearChartTransformStage();
_resetChordRenderState(); _resetChordRenderState();
}, },
@@ -2454,8 +2587,11 @@ function createHighway() {
hwState._domVisSampledFrame = NaN; hwState._domVisSampledFrame = NaN;
return _isHighwayVisible(); return _isHighwayVisible();
}, },
getNotes() { return hwState.notes; }, // When a chart transform is active these return its full-difficulty
getChords() { return hwState.chords; }, // 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 // Difficulty-filtered variants of getNotes()/getChords(). Returns the
// master-difficulty-filtered arrays when the current song has phrase-level // master-difficulty-filtered arrays when the current song has phrase-level
// data (i.e. the mastery slider is active). For songs with a single // data (i.e. the mastery slider is active). For songs with a single
@@ -2463,8 +2599,14 @@ function createHighway() {
// these fall through to the raw arrays, the same as getNotes()/getChords(). // these fall through to the raw arrays, the same as getNotes()/getChords().
// Plugins that score or analyse only the notes the player is currently // Plugins that score or analyse only the notes the player is currently
// expected to play should prefer these over getNotes()/getChords(). Read-only. // expected to play should prefer these over getNotes()/getChords(). Read-only.
getFilteredNotes() { return hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes; }, getFilteredNotes() {
getFilteredChords() { return hwState._filteredChords !== null ? hwState._filteredChords : hwState.chords; }, 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 — // Live reference to the chord-template lookup table —
// `getChords()[i].id` is an index into this array. Each // `getChords()[i].id` is an index into this array. Each
// template carries `{ name, fingers, frets }`: // template carries `{ name, fingers, frets }`:
@@ -2479,7 +2621,7 @@ function createHighway() {
// its entries. Not difficulty-filter-aware (templates are // its entries. Not difficulty-filter-aware (templates are
// static metadata; every chord_id referenced by `getChords()` // static metadata; every chord_id referenced by `getChords()`
// is guaranteed valid). // is guaranteed valid).
getChordTemplates() { return hwState.chordTemplates; }, getChordTemplates() { return hwState._xfChordTemplates !== null ? hwState._xfChordTemplates : hwState.chordTemplates; },
getToneChanges() { return hwState.toneChanges; }, getToneChanges() { return hwState.toneChanges; },
getToneBase() { return hwState.toneBase; }, getToneBase() { return hwState.toneBase; },
getSections() { return hwState.sections; }, getSections() { return hwState.sections; },
@@ -2507,7 +2649,10 @@ function createHighway() {
// string-indexed UI / geometry against THIS rather than // string-indexed UI / geometry against THIS rather than
// assuming 6. Defaults to 6 between songs (until the next // assuming 6. Defaults to 6 between songs (until the next
// song_info message arrives). // 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) { addDrawHook(fn) {
hwState._drawHooks.push(fn); hwState._drawHooks.push(fn);
}, },
@@ -2531,6 +2676,17 @@ function createHighway() {
*/ */
setNoteStateProvider(fn) { hwState._noteStateProvider = (typeof fn === 'function') ? fn : null; }, setNoteStateProvider(fn) { hwState._noteStateProvider = (typeof fn === 'function') ? fn : null; },
getNoteStateProvider() { return hwState._noteStateProvider; }, 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. */ /** Current per-string base colors (copy). Index 0..7. */
getStringColors() { return hwState.STRING_COLORS.slice(); }, getStringColors() { return hwState.STRING_COLORS.slice(); },
/** /**
@@ -2638,6 +2794,8 @@ function createHighway() {
hwState._filteredAnchors = null; hwState._filteredAnchors = null;
hwState._filteredHandShapes = null; hwState._filteredHandShapes = null;
hwState._phrasesHaveHandShapes = false; hwState._phrasesHaveHandShapes = false;
// Keep _xfProvider (persists across songs); drop staged output.
_clearChartTransformStage();
_resetChordRenderState(); _resetChordRenderState();
const wsParams = new URLSearchParams(); const wsParams = new URLSearchParams();
if (arrangement !== undefined) wsParams.set('arrangement', arrangement); if (arrangement !== undefined) wsParams.set('arrangement', arrangement);
@@ -2735,6 +2893,11 @@ function createHighway() {
*/ */
isDefaultRenderer() { return hwState._renderer === _defaultRenderer || hwState._renderer == null; }, 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; return api;
} }
const highway = createHighway(); const highway = createHighway();
+21 -10
View File
@@ -400,8 +400,9 @@ export function drawSustains(hwState, W, H) {
// Same master-difficulty fallback as drawNotes/drawChords — // Same master-difficulty fallback as drawNotes/drawChords —
// without this, sustain bars for filtered-out notes would // without this, sustain bars for filtered-out notes would
// still render, leaving orphan rectangles where no note head // still render, leaving orphan rectangles where no note head
// is drawn. // is drawn. An active chart transform substitutes its staged view.
const src = hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes; const src = hwState._xfNotes !== null ? hwState._xfNotes
: hwState._filteredNotes !== null ? hwState._filteredNotes : hwState.notes;
for (const n of src) { for (const n of src) {
if (n.sus <= 0.01) continue; if (n.sus <= 0.01) continue;
const end = n.t + n.sus; 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 // phrase-level ladder data, render from the mastery-filtered
// array. _filteredNotes stays null for slider-disabled sources // array. _filteredNotes stays null for slider-disabled sources
// so rendering falls through to the flat notes array unchanged. // 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 // Binary search for visible range
const tMin = hwState.currentTime - 0.25; const tMin = hwState.currentTime - 0.25;
const tMax = hwState.currentTime + VISIBLE_SECONDS; const tMax = hwState.currentTime + VISIBLE_SECONDS;
@@ -649,7 +652,8 @@ export function drawUnisonBends(hwState, W, H, drawnNotes) {
export function drawChords(hwState, W, H) { export function drawChords(hwState, W, H) {
// See drawNotes — _filteredChords is null for slider-disabled // See drawNotes — _filteredChords is null for slider-disabled
// sources so we fall through to the flat chords array. // 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); _ensureChordRenderCache(hwState, src);
const tMin = hwState.currentTime - 0.25; const tMin = hwState.currentTime - 0.25;
@@ -674,7 +678,7 @@ export function drawChords(hwState, W, H) {
const actualSpread = Math.max(spread, minSpread); const actualSpread = Math.max(spread, minSpread);
const actualTotalH = actualSpread * Math.max(0, sorted.length - 1); 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 hasNonZero = nonZeroNotes.length >= 1;
const frameLeftFret = baseFret; const frameLeftFret = baseFret;
@@ -1124,15 +1128,22 @@ export function getChordTemplateInfo(chordId, chordTemplates) {
return { tmpl, tmplFrets, getTemplateFret, isOpen }; 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. // Build _chordRenderInfo for every chord in `src` if the cache is stale.
// Two passes over the array: chain bounds, then base-fret resolution // Two passes over the array: chain bounds, then base-fret resolution
// (which can read previous chord's cached baseFret). // (which can read previous chord's cached baseFret).
export function _ensureChordRenderCache(hwState, src) { 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; if (hwState._chordRenderCacheSrc === src && hwState._chordRenderCacheInverted === hwState._inverted && !templatesChanged) return;
hwState._chordRenderCacheSrc = src; hwState._chordRenderCacheSrc = src;
hwState._chordRenderCacheInverted = hwState._inverted; hwState._chordRenderCacheInverted = hwState._inverted;
hwState._chordRenderCacheTemplates = hwState.chordTemplates; hwState._chordRenderCacheTemplates = effTemplates;
// Templates feed isOpen() — when they land after `chords`, // Templates feed isOpen() — when they land after `chords`,
// _updateFretLinePreview's stashed open/non-open classification // _updateFretLinePreview's stashed open/non-open classification
// for the currently-active chord is also stale. It only refreshes // 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++) { for (let i = 0; i < src.length; i++) {
const ch = src[i]; const ch = src[i];
const info = hwState._chordRenderInfo.get(ch); 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 sortedNotes = [...ch.notes].sort((a, b) => hwState._inverted ? b.s - a.s : a.s - b.s);
const nonZero = sortedNotes.filter(cn => !isOpen(cn)); const nonZero = sortedNotes.filter(cn => !isOpen(cn));
const nonZeroFrets = nonZero.map(cn => cn.f); const nonZeroFrets = nonZero.map(cn => cn.f);
@@ -1248,7 +1259,7 @@ export function _updateFretLinePreview(hwState, src, lo, hi) {
ch.t > bestChordTime) { ch.t > bestChordTime) {
bestChordTime = ch.t; bestChordTime = ch.t;
activeChord = ch; activeChord = ch;
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates); const { isOpen } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
const nonZero = ch.notes.filter(cn => !isOpen(cn)); const nonZero = ch.notes.filter(cn => !isOpen(cn));
activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : []; 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); const p = project(ch.t - hwState.currentTime);
if (!p) continue; if (!p) continue;
activeChord = ch; activeChord = ch;
const { isOpen } = getChordTemplateInfo(ch.id, hwState.chordTemplates); const { isOpen } = getChordTemplateInfo(ch.id, _effChordTemplates(hwState));
const nonZero = ch.notes.filter(cn => !isOpen(cn)); const nonZero = ch.notes.filter(cn => !isOpen(cn));
activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : []; activeNotesOnFret = nonZero.length >= 1 ? nonZero.map(cn => ({ s: cn.s, f: cn.f })) : [];
break; break;
+1
View File
@@ -133,6 +133,7 @@
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). --> <!-- 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/library-card-actions.js"></script>
<script type="module" src="/static/capabilities/visualization.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/note-detection.js"></script>
<script type="module" src="/static/capabilities/midi-input.js"></script> <script type="module" src="/static/capabilities/midi-input.js"></script>
<script type="module" src="/static/capabilities/interface-scale.js"></script> <script type="module" src="/static/capabilities/interface-scale.js"></script>
+335
View File
@@ -0,0 +1,335 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { createWindow, ROOT } = require('./capabilities_test_harness');
const CAPABILITIES_JS = path.join(ROOT, 'static', 'capabilities.js');
const CHART_TRANSFORM_JS = path.join(ROOT, 'static', 'capabilities', 'chart-transform.js');
const STORAGE_KEY = 'feedBack.chartTransform.selectedProviderId';
const PLUGIN_ID = 'example_plugin';
const PROVIDER_ID = 'example-transform';
const PROVIDER_LABEL = 'Example Transform';
function makeFakeHighway() {
const calls = { set: [], refresh: 0 };
return {
calls,
setChartTransform(p) { calls.set.push(p); },
refreshChartTransform() { calls.refresh += 1; },
getChartTransform() { return calls.set.length ? calls.set[calls.set.length - 1] : null; },
};
}
function loadChartTransform(options = {}) {
const window = createWindow(options);
// The real bus provides feedBack.on; the harness only has emit →
// dispatchEvent. Shim `on` the same way app.js implements it so the
// module's bus mirroring (song:ready, chart-transform-failed) is live.
window.feedBack.on = (type, handler) => window.addEventListener(type, handler);
if (options.highway) window.highway = options.highway;
if (options.persistedSelection) window.localStorage.setItem(STORAGE_KEY, options.persistedSelection);
const context = vm.createContext(window);
vm.runInContext(fs.readFileSync(CAPABILITIES_JS, 'utf8'), context, { filename: CAPABILITIES_JS });
vm.runInContext(fs.readFileSync(CHART_TRANSFORM_JS, 'utf8'), context, { filename: CHART_TRANSFORM_JS });
return window;
}
function captureEvents(api, eventNames) {
const events = [];
for (const name of eventNames) {
api.subscribe(name, (detail) => events.push(detail));
}
return events;
}
async function registerProvider(api, overrides = {}) {
return api.dispatch({
capability: 'chart-transform', command: 'register-provider',
source: overrides.source || PLUGIN_ID,
payload: {
providerId: overrides.providerId || PROVIDER_ID,
label: overrides.label || PROVIDER_LABEL,
transform: overrides.transform || ((input) => ({ notes: input.notes })),
},
});
}
test('chart-transform domain registers a safe provider-coordinator owner', () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
const pipeline = api.inspect('chart-transform');
assert.ok(pipeline, 'chart-transform pipeline exists');
const owner = (pipeline.participants || []).find(p => p.pluginId === 'core.chart-transform');
assert.ok(owner, 'core.chart-transform owner registered');
assert.equal(owner.safety, 'safe');
assert.ok(owner.commands.includes('select-provider'));
assert.ok(owner.commands.includes('refresh'));
assert.equal(window.feedBack.chartTransformDomain.version, 1);
});
test('register-provider requires a transform function', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
const result = await api.dispatch({
capability: 'chart-transform', command: 'register-provider',
source: PLUGIN_ID, payload: { providerId: PROVIDER_ID },
});
assert.equal(result.outcome, 'degraded');
assert.match(result.reason, /transform\(input\) function/);
});
test('register + select installs the provider on the highway and persists', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
const events = captureEvents(api, [
'chart-transform:provider-registered',
'chart-transform:transform-changed',
]);
const reg = await registerProvider(api);
assert.equal(reg.outcome, 'handled');
assert.ok(api.inspect('chart-transform').participants.some(p => p.pluginId === PLUGIN_ID));
const sel = await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
assert.equal(sel.outcome, 'handled');
assert.equal(sel.payload.active, PROVIDER_ID);
assert.equal(sel.payload.installed, true);
assert.equal(highway.calls.set.length, 1);
assert.equal(highway.calls.set[0].id, PROVIDER_ID);
assert.equal(typeof highway.calls.set[0].transform, 'function');
assert.equal(window.localStorage.getItem(STORAGE_KEY), PROVIDER_ID);
const names = events.map(e => e.event);
assert.ok(names.includes('provider-registered'));
assert.ok(names.includes('transform-changed'));
});
test('select-provider with an unknown id degrades', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
const result = await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: 'nope' },
});
assert.equal(result.outcome, 'degraded');
assert.match(result.reason, /Unknown chart-transform provider/);
});
test('selection without a highway is kept and installed on song:ready', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
await registerProvider(api);
const sel = await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
assert.equal(sel.outcome, 'handled');
assert.equal(sel.payload.installed, false, 'no highway yet');
const highway = makeFakeHighway();
window.highway = highway;
window.feedBack.emit('song:ready', {});
assert.equal(highway.calls.set.length, 1);
assert.equal(highway.calls.set[0].id, PROVIDER_ID);
assert.equal(window.feedBack.chartTransformDomain.snapshot().installed, true);
});
test('a persisted selection restores when its provider registers', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway, persistedSelection: PROVIDER_ID });
const api = window.feedBack.capabilities;
await registerProvider(api);
const snapshot = window.feedBack.chartTransformDomain.snapshot();
assert.equal(snapshot.active, PROVIDER_ID);
assert.equal(snapshot.activeSource, 'restore-selection');
assert.equal(highway.calls.set.length, 1);
});
test('unregister is registrant-only and detaches the active provider', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
const denied = await api.dispatch({
capability: 'chart-transform', command: 'unregister-provider',
source: 'someone_else', payload: { providerId: PROVIDER_ID },
});
assert.equal(denied.outcome, 'degraded');
assert.match(denied.reason, /original registrant/);
const ok = await api.dispatch({
capability: 'chart-transform', command: 'unregister-provider',
source: PLUGIN_ID, payload: { providerId: PROVIDER_ID },
});
assert.equal(ok.outcome, 'handled');
const snapshot = window.feedBack.chartTransformDomain.snapshot();
assert.equal(snapshot.active, null);
assert.equal(snapshot.providers.length, 0);
// Detach = a trailing setChartTransform(null) on the highway.
assert.equal(highway.calls.set[highway.calls.set.length - 1], null);
// Persisted selection survives so re-registration re-activates.
assert.equal(window.localStorage.getItem(STORAGE_KEY), PROVIDER_ID);
});
test('unregister keeps a participant while another provider still references it', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
await registerProvider(api, { providerId: 'provider-a', label: 'Provider A' });
await registerProvider(api, { providerId: 'provider-b', label: 'Provider B' });
let participant = api.inspect('chart-transform').participants
.find(p => p.pluginId === PLUGIN_ID);
assert.deepEqual(Array.from(participant.providerPolicy.providerIds), ['provider-a', 'provider-b']);
assert.deepEqual(
Array.from(participant.providerPolicy.providers, p => ({ id: p.id, label: p.label })),
[{ id: 'provider-a', label: 'Provider A' }, { id: 'provider-b', label: 'Provider B' }],
);
await api.dispatch({
capability: 'chart-transform', command: 'unregister-provider',
source: PLUGIN_ID, payload: { providerId: 'provider-b' },
});
participant = api.inspect('chart-transform').participants
.find(p => p.pluginId === PLUGIN_ID);
assert.ok(participant, 'the shared participant remains registered');
assert.deepEqual(Array.from(participant.providerPolicy.providerIds), ['provider-a']);
assert.deepEqual(
Array.from(participant.providerPolicy.providers, p => ({ id: p.id, label: p.label })),
[{ id: 'provider-a', label: 'Provider A' }],
);
assert.deepEqual(
Array.from(window.feedBack.chartTransformDomain.snapshot().providers, p => p.id),
['provider-a'],
);
await api.dispatch({
capability: 'chart-transform', command: 'unregister-provider',
source: PLUGIN_ID, payload: { providerId: 'provider-a' },
});
assert.ok(!api.inspect('chart-transform').participants
.some(p => p.pluginId === PLUGIN_ID), 'the final removal unregisters the participant');
});
test('clear-provider clears the highway hook and the persisted selection', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
const result = await api.dispatch({
capability: 'chart-transform', command: 'clear-provider', source: 'settings_ui',
});
assert.equal(result.outcome, 'handled');
assert.equal(highway.calls.set[highway.calls.set.length - 1], null);
assert.equal(window.localStorage.getItem(STORAGE_KEY), null);
});
test('refresh re-runs the installed transform', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
const result = await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: PLUGIN_ID });
assert.equal(result.outcome, 'handled');
assert.equal(result.payload.refreshed, true);
assert.equal(highway.calls.refresh, 1);
});
test('announced highway instances (splitscreen panels) get the active transform', async () => {
const primary = makeFakeHighway();
const window = loadChartTransform({ highway: primary });
const api = window.feedBack.capabilities;
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
assert.equal(window.feedBack.chartTransformDomain.snapshot().surfaces, 1);
// A splitscreen panel announces its own createHighway() instance.
const panel = makeFakeHighway();
window.feedBack.emit('highway:created', { highway: panel });
assert.equal(panel.calls.set.length, 1, 'panel receives the active transform');
assert.equal(panel.calls.set[0].id, PROVIDER_ID);
assert.equal(window.feedBack.chartTransformDomain.snapshot().surfaces, 2);
// Refresh reaches every surface.
await api.dispatch({ capability: 'chart-transform', command: 'refresh', source: PLUGIN_ID });
assert.equal(primary.calls.refresh, 1);
assert.equal(panel.calls.refresh, 1);
// Clearing detaches every surface.
await api.dispatch({ capability: 'chart-transform', command: 'clear-provider', source: 'settings_ui' });
assert.equal(primary.calls.set[primary.calls.set.length - 1], null);
assert.equal(panel.calls.set[panel.calls.set.length - 1], null);
});
test('a panel announced before any selection installs on later select', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
const panel = makeFakeHighway();
window.feedBack.emit('highway:created', { highway: panel });
await registerProvider(api);
const sel = await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
assert.equal(sel.outcome, 'handled');
assert.equal(panel.calls.set.length, 1);
assert.equal(panel.calls.set[0].id, PROVIDER_ID);
});
test('highway failure events expose a fixed public reason', async () => {
const highway = makeFakeHighway();
const window = loadChartTransform({ highway });
const api = window.feedBack.capabilities;
const events = captureEvents(api, ['chart-transform:transform-failed']);
await registerProvider(api);
await api.dispatch({
capability: 'chart-transform', command: 'select-provider',
source: 'settings_ui', payload: { providerId: PROVIDER_ID },
});
window.feedBack.emit('highway:chart-transform-failed', {
id: PROVIDER_ID,
reason: 'token=secret https://example.test/private chart={notes:[...]}',
});
const snapshot = window.feedBack.chartTransformDomain.snapshot();
assert.equal(snapshot.lastFailure.providerId, PROVIDER_ID);
assert.equal(snapshot.lastFailure.reason, 'Chart transform provider failed');
assert.equal(events.length, 1);
assert.equal(events[0].payload.reason, 'Chart transform provider failed');
});
test('diagnostics contribution carries the schema and no song identity fields', async () => {
const window = loadChartTransform();
const api = window.feedBack.capabilities;
await registerProvider(api);
const contributions = window.feedBack.diagnostics.snapshotContributions();
const diag = contributions['chart-transform-capability'];
assert.ok(diag, 'diagnostics contributed');
assert.equal(diag.schema, 'feedBack.chart_transform.diagnostics.v1');
const flat = JSON.stringify(diag);
assert.ok(!/filename|title|artist|arrangement/.test(flat), 'no song identity in diagnostics');
});
+355
View File
@@ -0,0 +1,355 @@
// Source-level coverage is used because createHighway's browser closure is too
// large for the Node harness. Critical staging helpers are exercised directly.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
const highwayDrawJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-draw.js');
function extractBlock(src, marker) {
const start = src.indexOf(marker);
assert.ok(start >= 0, `${marker} present`);
const open = src.indexOf('{', start);
assert.ok(open >= 0, `${marker} has a body`);
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth += 1;
else if (src[i] === '}') {
depth -= 1;
if (depth === 0) return src.slice(start, i + 1);
}
}
assert.fail(`${marker} body is balanced`);
}
test('highway public API exposes the chart-transform hook', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /setChartTransform\s*\(\s*p\s*\)\s*\{/, 'setChartTransform exists');
assert.match(src, /getChartTransform\s*\(\s*\)\s*\{[^}]*_xfProvider/, 'getChartTransform returns the provider');
assert.match(src, /refreshChartTransform\s*\(\s*\)\s*\{[^}]*_restageChartTransform/, 'refreshChartTransform restages');
});
test('restage runs at BOTH exits of _rebuildMasteryFilter (transform after difficulty)', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fnStart = src.indexOf('function _rebuildMasteryFilter()');
const fnEnd = src.indexOf('function _clearChartTransformStage');
assert.ok(fnStart > -1 && fnEnd > fnStart, 'both functions present in order');
const body = src.slice(fnStart, fnEnd);
const calls = body.match(/_restageChartTransform\(\);/g) || [];
assert.equal(calls.length, 2, 'restage at the early return and the normal exit');
});
test('restage consumes the difficulty-filtered arrays, not the raw chart', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
assert.match(fn, /notes:\s*filterActive\s*\?\s*hwState\._filteredNotes\s*:\s*hwState\.notes/);
assert.match(fn, /allNotes:\s*hwState\.notes/, 'full-difficulty views passed alongside');
});
test('a throwing provider clears the stage and emits highway:chart-transform-failed', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
const report = extractBlock(src, 'function _reportChartTransformFailure(provider, error)');
assert.match(fn, /catch\s*\(e\)\s*\{[\s\S]*_reportChartTransformFailure\(p, e\)[\s\S]*return;/);
assert.match(fn, /^\s*_clearChartTransformStage\(\);/m, 'stage cleared before the provider runs');
// Execute the extracted reporter with a sentinel error: the emitted
// payload must contain ONLY the approved field (id) — nothing derived
// from the exception — while the raw error stays on the local console.
const sandbox = { cleared: 0, emitted: [], logged: [] };
vm.runInNewContext(`
const _clearChartTransformStage = () => { cleared += 1; };
const console = { error: (...args) => logged.push(args) };
const window = { feedBack: { emit: (type, detail) => emitted.push({ type, detail }) } };
${report}
_reportChartTransformFailure({ id: 'prov-1' }, new Error('sentinel: /Users/someone/secret.sloppak'));
`, sandbox);
assert.equal(sandbox.cleared, 1, 'failure clears the stage');
assert.equal(sandbox.emitted.length, 1, 'exactly one failure event');
assert.equal(sandbox.emitted[0].type, 'highway:chart-transform-failed');
assert.deepEqual(Object.keys(sandbox.emitted[0].detail), ['id'],
'payload carries only the approved field — no exception-derived fields');
assert.equal(sandbox.emitted[0].detail.id, 'prov-1');
assert.ok(!JSON.stringify(sandbox.emitted[0].detail).includes('sentinel'),
'nothing exception-derived leaks into the event');
assert.equal(sandbox.logged.length, 1, 'raw exception stays on the local console');
assert.ok(sandbox.logged[0].some((arg) => String(arg).includes('sentinel')),
'the local console received the actual error');
});
test('restage is a pre-ready no-op: provider stays attached, ready path runs the first staging', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
const guardAt = fn.indexOf('if (!hwState.ready) return;');
const invokeAt = fn.indexOf('p.transform(');
assert.ok(guardAt > -1, 'ready guard present');
assert.ok(invokeAt > guardAt, 'guard sits before the provider is invoked');
// The ready handler must flip hwState.ready BEFORE rebuilding the
// filter, or the guard would skip the first real staging.
const readyCase = src.indexOf("case 'ready':");
const readyFlip = src.indexOf('hwState.ready = true;', readyCase);
const readyRebuild = src.indexOf('_rebuildMasteryFilter();', readyCase);
assert.ok(readyCase > -1 && readyFlip > -1 && readyRebuild > readyFlip,
'ready handler sets hwState.ready before the rebuild that restages');
});
test('bundle assembly prefers the staged transform views', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /b\.notes = hwState\._xfNotes !== null \? hwState\._xfNotes/);
assert.match(src, /b\.chords = hwState\._xfChords !== null \? hwState\._xfChords/);
assert.match(src, /b\.anchors = hwState\._xfAnchors !== null \? hwState\._xfAnchors/);
assert.match(src, /b\.chordTemplates = hwState\._xfChordTemplates !== null/);
assert.match(src, /b\.stringCount = hwState\._xfStringCount !== null/);
assert.match(src, /b\.tuning = hwState\._xfTuning !== null/);
assert.match(src, /b\.capo = hwState\._xfCapo !== null/);
assert.match(src, /b\.handShapes = hwState\._xfHandShapes !== null \? hwState\._xfHandShapes/);
assert.match(src, /b\.centOffset = hwState\._xfCentOffset !== null/);
});
test('transform input carries the effective handShapes; output stages handShapes/centOffset', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const fn = src.slice(src.indexOf('function _restageChartTransform'), src.indexOf('// ── Public API'));
assert.match(fn, /handShapes: \(hwState\._filteredHandShapes !== null && hwState\._phrasesHaveHandShapes\)/,
'input handShapes uses the same effective selection as the bundle');
assert.match(fn, /_sortedChartTransformArray\(out\.handShapes, 'start_time'\)/);
assert.match(fn, /if \(Number\.isFinite\(out\.centOffset\)\) hwState\._xfCentOffset = out\.centOffset;/);
});
test('unordered provider timelines are copied and normalized for searches and anchor scans', () => {
const highwaySrc = fs.readFileSync(highwayJs, 'utf8');
const drawSrc = fs.readFileSync(highwayDrawJs, 'utf8');
const snippets = [
extractBlock(highwaySrc, 'function _clearChartTransformStage()'),
extractBlock(highwaySrc, 'function _cloneChartTransformValue(value, seen = new WeakMap())'),
extractBlock(highwaySrc, 'function _sortedChartTransformArray(items, key)'),
extractBlock(highwaySrc, 'function _reportChartTransformFailure(provider, error)'),
extractBlock(highwaySrc, 'function _restageChartTransform()'),
extractBlock(highwaySrc, 'function bsearchTime(arr, time)'),
extractBlock(highwaySrc, 'function getAnchorAt(t)'),
extractBlock(highwaySrc, 'function getMaxFretInWindow(t)'),
extractBlock(drawSrc, 'export function bsearch(arr, time)').replace('export ', ''),
].join('\n');
const providerOutput = {
notes: [{ t: 9 }, { t: 1 }, { t: 5 }],
chords: [{ t: 8 }, { t: 2 }],
anchors: [
{ time: 10, fret: 20, width: 2 },
{ time: 0, fret: 1, width: 3 },
{ time: 5, fret: 10, width: 4 },
],
allNotes: [{ t: 7 }, { t: 0 }, { t: 3 }],
allChords: [{ t: 6 }, { t: 4 }],
handShapes: [{ start_time: 9 }, { start_time: 1 }],
stringCount: 4,
tuning: [-2, -2, -2, -2],
capo: 2,
centOffset: -12.5,
};
const hwState = {
ready: true,
_xfProvider: { id: 'unordered', transform: () => providerOutput },
_filteredNotes: [],
_filteredChords: [],
_filteredAnchors: [],
_filteredHandShapes: [],
_phrasesHaveHandShapes: true,
notes: [], chords: [], anchors: [], handShapes: [], chordTemplates: [],
stringCount: 6, songInfo: {},
};
const helpers = new Function('hwState', 'window', 'VISIBLE_SECONDS', 'console', `
${snippets}
return { _restageChartTransform, bsearch, bsearchTime, getAnchorAt, getMaxFretInWindow };
`)(hwState, {}, 3, { error() {} });
helpers._restageChartTransform();
assert.deepEqual(hwState._xfNotes.map(n => n.t), [1, 5, 9]);
assert.deepEqual(hwState._xfChords.map(ch => ch.t), [2, 8]);
assert.deepEqual(hwState._xfNotesAll.map(n => n.t), [0, 3, 7]);
assert.deepEqual(hwState._xfChordsAll.map(ch => ch.t), [4, 6]);
assert.deepEqual(hwState._xfAnchors.map(a => a.time), [0, 5, 10]);
assert.deepEqual(hwState._xfHandShapes.map(h => h.start_time), [1, 9]);
assert.equal(hwState._xfStringCount, 4);
assert.deepEqual(hwState._xfTuning, [-2, -2, -2, -2]);
assert.equal(hwState._xfCapo, 2);
assert.equal(hwState._xfCentOffset, -12.5);
assert.deepEqual(providerOutput.notes.map(n => n.t), [9, 1, 5], 'provider output is not mutated');
providerOutput.tuning[0] = 99;
assert.equal(hwState._xfTuning[0], -2, 'staged metadata is detached from provider output');
assert.equal(helpers.bsearch(hwState._xfNotes, 5), 1);
assert.equal(helpers.bsearchTime(hwState._xfAnchors, 5), 1);
assert.equal(helpers.getAnchorAt(6).time, 5);
assert.equal(helpers.getMaxFretInWindow(0), 14);
hwState._filteredNotes = null;
hwState._filteredChords = null;
hwState._xfProvider.transform = () => ({
notes: [{ t: 4 }, { t: 2 }],
chords: [{ t: 3 }, { t: 1 }],
});
helpers._restageChartTransform();
assert.deepEqual(hwState._xfNotesAll.map(n => n.t), [2, 4], 'unfiltered notes still fall back');
assert.deepEqual(hwState._xfChordsAll.map(ch => ch.t), [1, 3], 'unfiltered chords still fall back');
});
test('provider inputs and staged outputs are isolated from provider mutation', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const snippets = [
extractBlock(src, 'function _clearChartTransformStage()'),
extractBlock(src, 'function _cloneChartTransformValue(value, seen = new WeakMap())'),
extractBlock(src, 'function _sortedChartTransformArray(items, key)'),
extractBlock(src, 'function _reportChartTransformFailure(provider, error)'),
extractBlock(src, 'function _restageChartTransform()'),
].join('\n');
const sourceNote = { t: 1, bendValues: [{ t: 0, v: 1 }] };
const sourceInfo = { tuning: [0, 0], nested: { value: 1 } };
const events = [];
const hwState = {
ready: true,
_xfProvider: null,
_filteredNotes: null, _filteredChords: null, _filteredAnchors: null,
_filteredHandShapes: null, _phrasesHaveHandShapes: false,
notes: [sourceNote], chords: [], anchors: [], handShapes: [], chordTemplates: [],
stringCount: 2, songInfo: sourceInfo,
};
const helpers = new Function('hwState', 'window', 'console', `
${snippets}
return { _restageChartTransform };
`)(hwState, { feedBack: { emit(name, detail) { events.push({ name, detail }); } } }, { error() {} });
hwState._xfProvider = {
id: 'mutating-provider',
transform(input) {
input.notes[0].t = 99;
input.notes[0].bendValues[0].v = 7;
input.songInfo.nested.value = 8;
throw new Error('private provider detail');
},
};
helpers._restageChartTransform();
assert.equal(sourceNote.t, 1);
assert.equal(sourceNote.bendValues[0].v, 1);
assert.equal(sourceInfo.nested.value, 1);
assert.equal(hwState._xfNotes, null);
assert.deepEqual(events.map(event => event.name), ['highway:chart-transform-failed']);
const output = { notes: [{ t: 2, nested: { value: 3 } }] };
hwState._xfProvider = { id: 'stable-provider', transform: () => output };
helpers._restageChartTransform();
output.notes[0].t = 20;
output.notes[0].nested.value = 30;
assert.equal(hwState._xfNotes[0].t, 2);
assert.equal(hwState._xfNotes[0].nested.value, 3);
});
test('async and malformed provider outputs fail closed without a partial stage', async () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const snippets = [
extractBlock(src, 'function _clearChartTransformStage()'),
extractBlock(src, 'function _cloneChartTransformValue(value, seen = new WeakMap())'),
extractBlock(src, 'function _sortedChartTransformArray(items, key)'),
extractBlock(src, 'function _reportChartTransformFailure(provider, error)'),
extractBlock(src, 'function _restageChartTransform()'),
].join('\n');
const events = [];
const errors = [];
const hwState = {
ready: true,
_xfProvider: { id: 'async-provider', transform: async () => { throw new Error('async detail'); } },
_filteredNotes: null, _filteredChords: null, _filteredAnchors: null,
_filteredHandShapes: null, _phrasesHaveHandShapes: false,
notes: [], chords: [], anchors: [], handShapes: [], chordTemplates: [],
stringCount: 6, songInfo: {},
};
const helpers = new Function('hwState', 'window', 'console', `
${snippets}
return { _restageChartTransform };
`)(hwState, { feedBack: { emit(name) { events.push(name); } } }, { error(...args) { errors.push(args); } });
helpers._restageChartTransform();
assert.equal(hwState._xfNotes, null);
assert.equal(events.length, 1);
assert.match(String(errors[0][1]), /must return synchronously/);
await new Promise(resolve => setImmediate(resolve));
assert.match(String(errors[1][1]), /async detail/, 'async rejection stays in the local console');
const output = { chords: [{ t: 1 }] };
Object.defineProperty(output, 'notes', { enumerable: true, get() { throw new Error('bad getter'); } });
hwState._xfProvider = { id: 'getter-provider', transform: () => output };
helpers._restageChartTransform();
assert.equal(hwState._xfNotes, null);
assert.equal(hwState._xfChords, null);
assert.equal(events.length, 2);
});
test('createHighway announces each instance via highway:created', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /emit\('highway:created', \{ highway: api \}\)/,
'factory emits highway:created with the api instance');
});
test('public getters fall through transformed → filtered → raw', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
assert.match(src, /getNotes\(\)\s*\{\s*return hwState\._xfNotesAll !== null/);
assert.match(src, /getChords\(\)\s*\{\s*return hwState\._xfChordsAll !== null/);
assert.match(src, /getFilteredNotes\(\)\s*\{\s*if \(hwState\._xfNotes !== null\) return hwState\._xfNotes;/);
assert.match(src, /getFilteredChords\(\)\s*\{\s*if \(hwState\._xfChords !== null\) return hwState\._xfChords;/);
assert.match(src, /getChordTemplates\(\)\s*\{\s*return hwState\._xfChordTemplates !== null/);
assert.match(src, /getStringCount\(\)\s*\{\s*return hwState\._xfStringCount !== null/);
assert.match(src, /getTuning\(\)\s*\{\s*return hwState\._xfTuning !== null/);
assert.match(src, /getCapo\(\)\s*\{\s*return hwState\._xfCapo !== null/);
assert.match(src, /getCentOffset\(\)\s*\{\s*return hwState\._xfCentOffset !== null/);
assert.match(src, /getSongInfo\(\)\s*\{\s*return hwState\.songInfo;\s*\}/,
'getSongInfo keeps the original chart metadata contract');
});
test('anchor zoom helpers read the staged anchors first', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const anchorSites = src.match(/hwState\._xfAnchors !== null \? hwState\._xfAnchors\s*\n?\s*: hwState\._filteredAnchors !== null/g) || [];
assert.ok(anchorSites.length >= 2, 'getAnchorAt and getMaxFretInWindow both staged-aware');
});
test('init and reconnect clear the stage but keep the provider', () => {
const src = fs.readFileSync(highwayJs, 'utf8');
const initBody = extractBlock(src, 'init(canvasEl, container)');
const reconnectBody = extractBlock(src, 'reconnect(filename, arrangement)');
assert.match(initBody, /_clearChartTransformStage\(\);/, 'init clears the stage');
assert.match(reconnectBody, /_clearChartTransformStage\(\);/, 'reconnect clears the stage');
assert.ok(!/init\([\s\S]{0,2000}_xfProvider = null/.test(src.slice(src.indexOf('const api = {'))),
'api reset paths never drop the installed provider');
});
test('default 2D draw path prefers the staged views (drawNotes/drawChords/drawSustains)', () => {
const src = fs.readFileSync(highwayDrawJs, 'utf8');
const noteSites = src.match(/hwState\._xfNotes !== null \? hwState\._xfNotes/g) || [];
assert.ok(noteSites.length >= 2, 'drawNotes and drawSustains staged-aware');
assert.match(src, /hwState\._xfChords !== null \? hwState\._xfChords/, 'drawChords staged-aware');
});
test('highway_3d nut labels prefer the transform-aware bundle tuning/capo', () => {
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js'), 'utf8');
assert.match(src, /let tuning = Array\.isArray\(bundle\.tuning\) \? bundle\.tuning : \(songInfo && songInfo\.tuning\)/,
'label derivation reads a well-formed bundle.tuning first, songInfo otherwise');
assert.match(src, /let cap = bundle\.capo;/,
'label derivation reads bundle.capo first');
// Both cache paths must key on the same bundle-first capo the labels
// use (songInfo stays as the fallback branch of each ternary), and all
// three sites share the same final fallback (0) so cache signatures
// match rendered output.
assert.match(src, /const capo =\s*\n\s*bundle && Number\.isFinite\(bundle\.capo\) \? bundle\.capo\s*\n\s*: \(si && Number\.isFinite\(si\.capo\) \? si\.capo : 0\)/,
'label signature keys on bundle.capo first with a 0 fallback');
assert.match(src, /const capo =\s*\n\s*Number\.isFinite\(bundle\.capo\) \? bundle\.capo\s*\n\s*: \(si && Number\.isFinite\(si\.capo\) \? si\.capo : 0\)/,
'cheap-key fast path keys on bundle.capo first with a 0 fallback');
});
test('chord template reads route through the effective-templates helper', () => {
const src = fs.readFileSync(highwayDrawJs, 'utf8');
assert.match(src, /export function _effChordTemplates\(hwState\)/);
assert.ok(!/getChordTemplateInfo\([^)]*,\s*hwState\.chordTemplates\)/.test(src),
'no direct hwState.chordTemplates read remains at template-info call sites');
assert.match(src, /_chordRenderCacheTemplates !== effTemplates/, 'render cache keys on effective templates');
});
+4 -2
View File
@@ -51,8 +51,10 @@ test('_ensureChordRenderCache keys off src, _inverted, AND chordTemplates', () =
); );
assert.match(src, eqEither('hwState\\._chordRenderCacheSrc', 'src'), 'cache must key on src'); assert.match(src, eqEither('hwState\\._chordRenderCacheSrc', 'src'), 'cache must key on src');
assert.match(src, eqEither('hwState\\._chordRenderCacheInverted', 'hwState\\._inverted'), 'cache must key on _inverted'); assert.match(src, eqEither('hwState\\._chordRenderCacheInverted', 'hwState\\._inverted'), 'cache must key on _inverted');
assert.match(src, neqEither('hwState\\._chordRenderCacheTemplates', 'hwState\\.chordTemplates'), assert.match(src, neqEither('hwState\\._chordRenderCacheTemplates', 'effTemplates'),
'cache must key on chordTemplates (detected via !== for change-flag)'); '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', () => { test('chordTemplates change resets fretline preview and frame-mismatch warner', () => {