Compare commits

...
Author SHA1 Message Date
byrongamatosandClaude Opus 4.8 212afca2fb refactor(app): lift the shared player state onto a container (R3a)
static/js/player-state.js — one exported object, two fields. app.js's 70 reference
sites rewritten. Provably a no-op; nothing shrinks.

WHY NOW. Every slice carved out of app.js so far only ever READ the state it shared
(loopA/loopB, _audioSeekGen, currentFilename), so a read-only getter hook was enough
and no container was needed — twice I checked and twice I got away with it. That
runs out at count-in: it genuinely WRITES `isPlaying` (it starts and stops playback,
4 sites) and `lastAudioTime` (2). `import { isPlaying }` then `isPlaying = true`
THROWS — an imported binding cannot be assigned to. So the state has to live on an
object: `S.isPlaying = true` is a property write, and works from any module holding
the same S. Same shape stems, studio, and editor all converged on.

DELIBERATELY SMALL. app.js has ~104 top-level `let` scalars; lifting all of them is
a ~977-site rewrite for no benefit, because most are private to one cluster and
travel with it. Only what a carved module must WRITE goes here. Add on demand.

THE REWRITE IS AST-DRIVEN, NOT TEXTUAL — and that is not fussiness. Of 100 textual
occurrences of these two names, only 70 resolve to the module binding:
  * 22 are member accesses (`someObj.isPlaying`, `window.feedBack.isPlaying`)
  * 4 are the LOCAL PARAMETER of `function setPlayButtonState(isPlaying)` — a blind
    replace yields `function setPlayButtonState(S.isPlaying)`
  * 1 is an object key
  * 2 are shorthand properties `{ isPlaying }`, which must become
    `{ isPlaying: S.isPlaying }` — and acorn gives a shorthand's key and value the
    SAME range, so rewriting both produced `isPlaying: S.isPlaying: S.isPlaying`
    until I deduped by range
A find-and-replace corrupts all 29. The rewrite walks the AST, skips shadows, member
properties and keys, and replaces identifier RANGES.

`window.feedBack.isPlaying` — the PUBLIC mirror — is a different thing and is
untouched. Two test sandboxes stub it; those were left alone deliberately.

VERIFIED WITH REAL PLAYBACK. A/B against origin/main in two browsers, real song:
togglePlay -> the public mirror goes true -> false -> true across two toggles, the
audio element's paused state follows, seekBy works — IDENTICAL, zero page errors.

Harnesses: 8 vm-sandbox suites slice playback code out of app.js and now see
S.isPlaying — juce_engine_reroute, loop_restart, play_button_reroute_guard,
playback_app_adapter, song_restart, song_seek, speed_reset, and the python
idempotence source-assert. Each gets the same container in its sandbox; every
assertion is unchanged.

pytest 2396, node 1040/1040, ESLint 0, tailwind clean, Codex 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:32:36 +02:00
cb236e6c04 refactor(app): carve the A–B loop out of app.js (R3a) (#888)
static/js/loops.js (261) — bodies VERBATIM. app.js 8,421 → 8,224.
The second slice out of the strongly-connected core.

It OWNS the loop state — loopA, loopB, _loopMutationGen. Nothing outside writes
them: restartCurrentSong() looked like it did, but it declares its own local `let
loopA/loopB` shadows, so the module-level bindings only ever change in setLoop /
setLoopStart / setLoopEnd / clearLoop. All four move here. No state container.

DIRECTION IS THE WHOLE DESIGN. loops and section-practice are mutually dependent —
the SCC in miniature. clearLoop() must drop section-practice's selection, and
practiceSection() must call setLoop(). Both edges cannot be imports or no-cycle
(rightly) rejects it. So:

    section-practice  ->  reaches loops through the HOST SEAM (host.setLoop, …)
    loops             ->  imports section-practice DIRECTLY

section-practice is the higher-level feature — a consumer of loops, not the reverse
— so it is the one that takes the indirection. app.js hands the loop module's
exports across into the seam for it. Graph stays acyclic; no-cycle passes.

THE CONTRACT TEST EARNED ITS KEEP IMMEDIATELY. It failed on the first build with
"these hooks are wired by app.js but no module reads them: playSong". My dependency
scan had counted a mention of playSong() inside a COMMENT in loops.js as a real
call. Wired but unused is precisely the "fossil of a rename" case the test exists
for — and it caught it on a path no test executes.

VERIFIED BY DRIVING BOTH SIDES OF THE SEAM. A/B against origin/main in two browsers,
real song loaded:
  * setLoop(5,12) -> true; getLoop() -> 5,12 — IDENTICAL
  * clearLoop() (loops -> section-practice, a direct import) -> getLoop() ->
    null,null — IDENTICAL
  * onPhraseNext() (section-practice -> loops, ACROSS THE SEAM) -> ok — IDENTICAL
  * loadSavedLoop / saveCurrentLoop / deleteSelectedLoop on window — IDENTICAL
  * zero page errors either side. An unwired hook throws, so a live app is itself
    proof the seam is wired.

Harness: loop_api extracts the loop helpers by signature — retargeted to loops.js,
`export` stripped for the vm sandbox, and the sandbox's existing _audioSeek /
_audioTime / formatTime spies are now routed through a `host` object so every
assertion holds unchanged, just through the indirection the real code uses. It is
SPLIT: one test still reads app.js for the window.feedBack API surface, which stayed.

pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 21:32:31 +02:00
64f04565e2 refactor(app): the host seam + carve section practice out of app.js (R3a) (#887)
static/js/host.js (99) + static/js/section-practice.js (1,214).
app.js 9,461 → 8,409.

THE FIRST SLICE OUT OF THE STRONGLY-CONNECTED CORE. What is left in app.js is not
a tree, it is a cycle: seeding a dependency closure from section-practice, from
loops, from count-in, or from the JUCE seek shim all return the SAME 178-function
set, and setLoop() and practiceSection() call each other directly. No closure-based
carve can cut it at any seed. So it is cut BY NAME, and the calls back into app.js
go through a host seam.

  61 functions + its own 24 _sectionPractice*/_sectionParents* scalars (read nowhere
  else) move out. 11 hooks come back in. 4 of those are read-only GETTERS —
  loopA/loopB/_audioSeekGen/_loopMutationGen are only ever READ here, never written,
  so app.js keeps owning them and NO state container is needed (a 977-site lift
  avoided).

app.js used to reach IN and reset the module's state by hand (clearLoop() zeroed the
selection; changeArrangement() invalidated the parent count). It cannot now — an
imported binding is read-only — so those are exported as resetSelection() and
invalidateParentCount(). Strictly better: the module owns its own invariants instead
of trusting two callers on the far side of the file to zero the right three fields.

═══ THE SILENT-NO-OP PROBLEM, SOLVED ═══
The obvious host seam is an object of no-op defaults. That is a TRAP and we walked
into it once: the plugin loader's seam defaulted populateVizPicker to `() => {}`, so
a dropped wiring line would have left the viz picker quietly not refreshing with NO
test, boot check, or bot noticing. Two layers stop it here:

  1. RUNTIME — host.js is a Proxy with NO defaults and NO stubs. Reading an unwired
     hook THROWS. An unwired hook cannot degrade into a no-op because there is
     nothing to degrade INTO. configureHost() also rejects a non-function at WIRE
     time, and refuses to run twice.

  2. STATIC — tests/js/host_contract.test.js asserts the hooks the modules USE are
     exactly the hooks app.js WIRES. This is the layer that matters: a runtime throw
     only fires if the broken path executes, and the whole danger of a seam is the
     paths that never run in a smoke test. VERIFIED TO BITE in all three drift
     directions: drop a hook from configureHost -> fails; rename host.setLoop in the
     module -> fails; wire a hook nobody uses -> fails.

Writing that guard took three tries and each failure is instructive: (a) the
configureHost regex anchored `});` at column 0, ran past the indented close, and
swallowed app.js's 66-name window contract — 77 "hooks"; (b) an import-stripping
regex with `[\s\S]*?` ate 14,000 characters INCLUDING the drift the bite test was
meant to catch — a guard with a hole is worse than no guard, because you trust it;
(c) `host.js'` in the import path backtracked from `js` to a "hook" called `j`.
The bite tests are what surfaced all three.

CODEX FOUND A REAL RACE [P2]. configureHost() was inside the async boot function,
after several awaits — but the window handlers (onPhraseNext, …) go live during
app.js's SYNCHRONOUS module evaluation. A user clicking one in that window would hit
"[host] … was read before configureHost() ran". It is now a bare top-level statement
sitting immediately before the window contract, so the seam is always wired before a
handler can be reached. Verified live: invoking a handler 1.2s in — well before the
boot awaits settle — works.

VERIFIED. A/B against origin/main in two browsers with a REAL song loaded: popover
toggle, practice-mode change, phrase-next, and clearLoop (all of which cross the seam
— setLoop/clearLoop/_audioTime/loopA/loopB) — IDENTICAL, zero page errors. Since an
unwired hook throws, a live app is itself proof the seam is wired.

Harnesses: section_practice_dismiss retargeted; loop_api's clearLoop sandbox gains a
resetSelection SPY (not a stub) and ASSERTS it fires — the guarantee is still tested,
just through the seam.

pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:58:41 +02:00
f53d566dbc refactor(app): give the <audio> element a module of its own (R3a) (#886)
static/js/audio-el.js — one exported const. app.js's diff is 5 lines.
This is a HINGE, not a carve: nothing shrinks, but almost everything left in
app.js is blocked behind it.

WHY. `audio` is `document.getElementById('audio')` with 162 references in app.js
and 173 outside any one cluster. Every remaining cluster measured — settings,
app-updates, count-in (208 fns), exit-confirm (212), library-render (220) — lists
`audio` among its inbound symbols, because they all touch playback and playback
reaches for the element directly. A module that needs it cannot import app.js to
get it (that closes a cycle and fails import-x/no-cycle), so today the only way to
carve any of them would be a host seam — the exact thing #878 had to build and
#880 had to tear out.

WHY IT'S SAFE. `audio` is a `const` and is NEVER reassigned anywhere in core, so a
read-only import binding is exactly right and no state container is needed. The
162 call sites are untouched — the binding keeps its name, it is just imported
instead of declared. (Contrast the reassigned scalars — isPlaying, _avOffsetMs —
which CANNOT be shared this way: an imported binding cannot be written to. Those
still need containers, and that is the next problem, not this one.)

TIMING. app.js is <script type="module">, so it evaluates after the HTML is parsed
and its imports evaluate just before its body — the same moment app.js used to run
this exact lookup. If the element had not been in the document, `audio` would be
null and app.js's top-level `audio.addEventListener(...)` calls would throw and
kill the module. They don't.

VERIFIED WITH REAL PLAYBACK, not a boot check. A/B against origin/main in two
browsers: app alive with zero page errors (which is itself the proof the import
resolved), #audio is an AUDIO element, togglePlay/seekBy live, and playSong() on a
real library song sets audio.src and the element reports a duration — IDENTICAL on
both sides.

pytest 2396, node 1038/1038, ESLint 0 (no-cycle clean), tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 20:44:01 +02:00
b5dd585d25 refactor(app): carve settings backup + plugin updates out of app.js (R3a) (#885)
Two leaves, one PR. app.js 9,651 → 9,457.

static/js/settings-io.js (155) — exportSettings + importSettings, the Settings
backup bundle. Imports nothing. The two-phase rationale comment (server first and
atomic; then a best-effort localStorage merge) is the contract and moved with the
code.

plugin-updates → INTO static/js/plugin-loader.js, not a module of its own.
checkPluginUpdates + updatePlugin are plugin MANAGEMENT; they belong with the code
that loads plugins. A new file for 50 lines would have been a file for its own
sake.

All four are inline handlers on the Settings screen and already in app.js's window
contract, so app.js re-exposes the imported bindings unchanged.

VERIFIED BY DRIVING BOTH FLOWS. A/B against origin/main in two browsers:
  * checkPluginUpdates() -> hits the API and settles the button back to
    "Check for Updates" — IDENTICAL
  * exportSettings() -> POSTs /api/settings/export and writes
    "Exported feedBack-settings…" to #backup-status — IDENTICAL (fetch intercepted
    so the assertion is on the real call, not a stub)
  * all four resolve on window — IDENTICAL
  * zero console/page errors either side

Zero harnesses broke. pytest 2396, node 1038/1038, ESLint 0, tailwind clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:42:54 +02:00
d47883c5e5 refactor(app): carve the tuning-display helpers out of app.js (R3a) (#884)
static/js/tuning-display.js (228 lines) — bodies VERBATIM. app.js 9,838 → 9,650.
A LEAF: imports nothing.

Tuning NAME resolution (Drop D / Eb Standard / raw-offset fallback), bass
detection, effective string count, and the target FREQUENCIES + note names the
tuner checks against. Pure functions over a small MIDI/note-name table; the 3
_TUNING_* tables are read nowhere else and move in.

NOT A SLICE — a node-level extract. The span 2309-2535 INTERLEAVES the functions
with the `window.*` / `window.feedBack.*` assignments that publish them, and one
of those is `window.feedBack = window.feedBack || {}` — the BUS BOOTSTRAP, not
tuning code at all. Every ExpressionStatement stays exactly where it was; only the
16 functions and 3 tables move. app.js re-exposes the imported bindings from the
same lines, so the public surface and its ordering are untouched (constitution II
names window.feedBack).

  app.js -> { plugin-loader, viz, diagnostics-export, dom, highway-colors,
              tuning-display }

HARNESSES — 4 broke, and 3 of them broke in the SAME informative way: they sliced
app.js from `function isBassArrangement(` UP TO the marker
`window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;` — an end-marker
that (correctly) stayed behind in app.js. The module is now nothing BUT the tuning
helpers, so there is no block to slice: they read it whole and strip `export ` so
the vm sandbox still evaluates it as a script.
  tuner_auto_open is SPLIT — its autoplay-gate test still reads app.js, so it keeps
  APP_JS and gains TUNING_JS. Retargeting its path wholesale (my first attempt)
  silently pointed the autoplay test at the wrong file.

VERIFIED BY DRIVING THE CONTRACT. A/B against origin/main in two browsers, through
the real window surface: displayTuningName -> "E Standard" / "Drop D" /
"Eb Standard", parseRawTuningOffsets('-2,0,0,0,0,0') -> [-2,0,0,0,0,0],
isBassArrangement, effectiveStringCount, displayTuningTargets, and
window.feedBack.displayTuningName / .songTuningContext — IDENTICAL on both, zero
console/page errors either side.

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:37:44 +02:00
ebbfc8da6f refactor(app): carve the highway string-colours out of app.js (R3a) (#883)
static/js/highway-colors.js (601 lines) — bodies VERBATIM.
app.js 10,415 → 9,837. Under 10k.

DECOMPOSED, not sliced. The "settings" blob measured 47 fns / 19 inbound and was
not carvable as-is. Seeding the closure from a FUNCTION (initHighwayColors) found
only 18 fns and left 4 HWC_* constants used outside it — i.e. the seed was wrong,
not the cluster. Re-seeding from the STATE (every function touching HWC_*/`_hwc*`)
found the true cluster: 45 top-level nodes, lines 2751-3331, CONTIGUOUS, with ZERO
foreign nodes inside the span.

  INBOUND: 0.  EXPORTS: 2 (initHighwayColors, hwcInitSettingsUI).

The other 43 symbols — the HWC_* tables, the 12 presets, the theme store, the
share codec, the picker handlers, the window.feedBack.highwayColors facade — are
used nowhere else in core and stay private. No inline on*= handlers here (the
Settings buttons are wired by addEventListener inside hwcInitSettingsUI), so
nothing needed re-exposing on window. The three bus listeners register inside
initHighwayColors, which app.js calls — not at module top level — so no ordering
change.

THE no-undef GATE EARNED ITS KEEP. My closure said INBOUND=0; the module actually
uses `uiPrompt` (the "name this theme" prompt). It was missed because uiPrompt is
no longer an app.js DECLARATION — it's an IMPORT BINDING (from #882's dom.js), and
I was collecting declarations only. `no-undef` with typeof:true caught it.
  => Lesson for the next carve: seed `tops` from ImportDeclaration bindings too.
  => And it VALIDATES carving dom.js early: this module just imports uiPrompt from
     it. Had dom.js still been stranded in app.js, this carve would have needed a
     host seam.

  app.js -> { plugin-loader, viz, diagnostics-export, dom, highway-colors }
  plugin-loader -> viz
  highway-colors -> dom
  viz, diagnostics-export, dom -> (leaves)

VERIFIED BY DRIVING THE FACADE. A/B against origin/main in two browsers:
window.feedBack.highwayColors installed, identical method surface, 12 presets,
identical default slot colours, and a share-code encode→decode round-trip
returning #112233 — IDENTICAL on both, zero console/page errors either side.

Harnesses: highway_colors_facade + highway_string_colors retargeted (both
brace-extract blocks out of the source by signature).

pytest 2396, node 1038/1038, ESLint 0, tailwind-fresh clean, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 19:22:14 +02:00
27 changed files with 3085 additions and 2433 deletions
+188 -2380
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
// The one <audio> element the whole app plays through.
//
// This exists so that code carved out of app.js can reach the player without
// importing app.js back — which would close a cycle and fail the import-x/no-cycle
// gate. It is the same handle app.js has always held (`document.getElementById`
// on the element in the shell), just given a home of its own.
//
// It is deliberately a `const`, and it is never reassigned anywhere in core — so a
// read-only import binding is exactly right, and no state container is needed.
// (Contrast the reassigned scalars — isPlaying, _avOffsetMs, … — which cannot be
// shared this way, because an imported binding cannot be written to.)
//
// Module scripts evaluate after the HTML is parsed, so the element is already in
// the document by the time this runs. app.js is loaded as <script type="module">,
// and its imports evaluate before its body — the same point at which app.js used
// to run this exact lookup itself.
export const audio = document.getElementById('audio');
+601
View File
@@ -0,0 +1,601 @@
// Highway string colours — user theming for the 2D + bundled 3D highways.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
//
// Slot→hex colours (per named string slot, so a 6-string map survives a 4-string
// bass and a 7-string's Low B), named themes in localStorage, a copy/paste share
// code, and the Settings-screen picker UI. The highways colour by raw string
// INDEX, so a translation table maps named slots → per-index colours for the
// current arrangement, recomputed whenever a song loads.
//
// Exports exactly two entry points; the other 43 symbols (the HWC_* tables, the
// theme store, the picker handlers, the window.feedBack facade) are used nowhere
// else in core and stay private. The Settings buttons are wired by
// addEventListener inside hwcInitSettingsUI — there are no inline on*= handlers
// here, so nothing needs re-exposing on window.
//
// It does import uiPrompt from ./dom.js (the "name this theme" prompt) — which is
// precisely why dom.js was carved out first: without it this module would have
// needed a host seam back into app.js.
import { uiPrompt } from './dom.js';
// Colors are assigned per NAMED string (Low E, A, D, G, B, High E, plus the
// extended low strings of 7/8-string guitars), so a string keeps its color
// when the string count changes (e.g. Low E stays the same from a 6-string
// guitar to a 4-string bass, and on a 7-string the extra Low B takes the
// 7-string slot rather than bumping every color over). The highways color by
// raw string INDEX, so a small translation table maps named slots → per-index
// colors for the current arrangement; this is recomputed whenever a song loads
// (its string count / bass-vs-guitar may differ). Applies to BOTH the 2D and
// bundled 3D highway; stored client-side; shared via a copy/paste code.
const HWC_KEY_ACTIVE = 'highwayStringColors'; // JSON slot→hex map (active)
const HWC_KEY_THEMES = 'highwayColorThemes'; // { "<name>": {slot:hex} }
const HWC_KEY_NAME = 'highwayColorActiveName'; // selected saved theme name, or ''
const HWC_HEX_RE = /^#[0-9a-fA-F]{6}$/;
// Named color slots, in display order (high → low, then extended low strings).
const HWC_SLOTS = [
{ key: 'highE', label: 'High E', sub: '1st' },
{ key: 'B', label: 'B', sub: '2nd' },
{ key: 'G', label: 'G', sub: '3rd' },
{ key: 'D', label: 'D', sub: '4th' },
{ key: 'A', label: 'A', sub: '5th' },
{ key: 'lowE', label: 'Low E', sub: '6th / lowest' },
{ key: 'low7', label: 'Low B', sub: '7-string' },
{ key: 'low8', label: 'Low F#', sub: '8-string' },
];
const HWC_SLOT_KEYS = HWC_SLOTS.map((s) => s.key);
// Hardcoded fallback (matches the highway defaults) for before the 2D highway
// is queryable.
const HWC_DEFAULT_FALLBACK = { lowE: '#cc0000', A: '#cca800', D: '#0066cc', G: '#cc6600', B: '#00cc66', highE: '#9900cc', low7: '#cc00aa', low8: '#00cccc' };
// One-click string-color presets. Each is a full named-slot → hex map (every
// slot, so 7/8-string charts get a sensible color too) keyed by the same slot
// names as HWC_SLOTS, so "Low E" always lands on the lowE slot regardless of
// string count. Hues are chosen for the dark scene (~#080810): each color is
// bright enough to read on black and distinct from its neighbours.
// - warmcool: an ordered low→high spectrum (warm reds at the bass end →
// cool blues/violet at the treble end) so pitch reads as color temperature.
// - vivid: punchier, higher-saturation take on the classic mapping for a
// stage-bright look.
// - colorblind: the OkabeIto accessible qualitative palette (vermillion,
// orange, yellow, bluish-green, sky-blue, blue, reddish-purple), the most
// distinguishable option for deuteranopia/protanopia.
// - colorblind_deuteranope: a deuteranope-tuned variant of the OkabeIto set
// above, contributed by a deuteranopic player who still found that set hard
// to separate. Retunes the six main strings (red / yellow-green / blue /
// orange / teal / deep-purple) and keeps its 7/8-string colors unchanged.
// - neon: electric, max-saturation hues whose LIGHTNESS deliberately zig-zags
// between neighbours (bright→bright→brightest→dark blue→bright green→dark
// violet) so adjacent strings separate harder than vivid — a stage/stream
// "pop" set, not a vivid duplicate.
// - accessible: a CVD-safe set ORDERED by ascending lightness low→high (deep
// blue → vermilion → azure → orange → yellow → cream). Unlike the unordered
// OkabeIto 'colorblind' set, the value ramp teaches pitch low→high AND
// survives grayscale/colorblindness; no red/green pair carries meaning.
// - ember: a warm, lower-intensity family for long sessions, luminance-stepped
// from rust/ember at the bass through warm gold to cream at the treble. The
// bass embers stay light enough to clear the near-black scene.
// - tapedeck: a vintage-print, slightly desaturated ochre-tinted family
// (rust-red → mustard → avocado → teal → faded denim → dusty plum). Muted
// hues collapse, so neighbour LIGHTNESS deliberately zig-zags to keep the
// dusty mid-strings (avocado/teal/denim) distinct on the dark board.
// - crtgreen / crtamber: monochrome CRT-phosphor families (green / amber)
// stepped by STRICT ASCENDING LIGHTNESS low→high. Mono sets collapse on hue,
// so lightness alone carries the ordering. Verified to stay legible even on
// the matching phosphor scene board (green-on-green / amber-on-amber).
// - pitchramp: a smooth low→high hue sweep (violet → blue → teal → green →
// yellow → warm-white) with rising lightness — memorable + teaches order.
// - sunrise: a soft dawn gradient (plum → rose → coral → amber → gold → cream),
// warm and lower-intensity, lightness-stepped low→high.
const HWC_PRESETS = [
{
id: 'warmcool', label: 'Warm → Cool',
colors: { lowE: '#ff3b30', A: '#ff7a18', D: '#ffc400', G: '#36c46a', B: '#2196f3', highE: '#9b5cff', low7: '#ff2d78', low8: '#00c2c7' },
},
{
id: 'vivid', label: 'Vivid',
colors: { lowE: '#ff2222', A: '#ffd000', D: '#1e8bff', G: '#ff7a00', B: '#16d65a', highE: '#b24bff', low7: '#ff3cc0', low8: '#15d8d8' },
},
{
id: 'colorblind', label: 'Colorblind-friendly',
colors: { lowE: '#d55e00', A: '#e69f00', D: '#f0e442', G: '#009e73', B: '#56b4e9', highE: '#cc79a7', low7: '#0072b2', low8: '#999999' },
},
{
id: 'colorblind_deuteranope', label: 'Colorblind (deuteranope)',
colors: { lowE: '#aa1414', A: '#88de00', D: '#1889e3', G: '#c6601c', B: '#00f5b2', highE: '#4d2173', low7: '#0072b2', low8: '#999999' },
},
{
id: 'neon', label: 'Neon',
colors: { lowE: '#ff1f4e', A: '#ff9d00', D: '#e9ff00', G: '#1844ff', B: '#00ff84', highE: '#d000ff', low7: '#ff00aa', low8: '#00f0ff' },
},
{
id: 'accessible', label: 'Accessible (ordered)',
colors: { lowE: '#2453c0', A: '#c44a00', D: '#3f93cf', G: '#ec9a1e', B: '#f2d43c', highE: '#f5eecb', low7: '#173f96', low8: '#0f2c6b' },
},
{
id: 'ember', label: 'Warm Ember',
colors: { lowE: '#c0392b', A: '#e0552a', D: '#ef7d2e', G: '#f6a13a', B: '#f4c95d', highE: '#f7e3a8', low7: '#9e2f23', low8: '#7d2418' },
},
{
id: 'tapedeck', label: 'Tape Deck',
colors: { lowE: '#b04632', A: '#d8ad42', D: '#5f7a34', G: '#54b3a6', B: '#5e83ad', highE: '#b98abb', low7: '#8f3526', low8: '#6f2a1e' },
},
{
id: 'crtgreen', label: 'CRT Green',
colors: { lowE: '#0a5a23', A: '#108a30', D: '#1fb53f', G: '#3ad94f', B: '#74f06a', highE: '#c7ffb0', low7: '#08491c', low8: '#063514' },
},
{
id: 'crtamber', label: 'CRT Amber',
colors: { lowE: '#7a3a02', A: '#a85f06', D: '#cf8410', G: '#e8a82a', B: '#f4cf5e', highE: '#ffeeb8', low7: '#5f2d01', low8: '#471f00' },
},
{
id: 'pitchramp', label: 'Pitch Ramp',
colors: { lowE: '#7a2390', A: '#2f5ad8', D: '#1f9bc4', G: '#2fb84a', B: '#cfd22a', highE: '#f3e0c0', low7: '#5e1a78', low8: '#440f5e' },
},
{
id: 'sunrise', label: 'Sunrise',
colors: { lowE: '#8a3a6e', A: '#bf4a5e', D: '#e0664f', G: '#f29a55', B: '#f7c873', highE: '#fce8b8', low7: '#6e2c5c', low8: '#54214a' },
},
];
// Translation table: chart string index → named slot, for a given string count
// and bass/guitar family. Mirrors the 3D highway's _baseOpenStringMidis: bass
// shares the low strings (E A D G), 7/8-string guitars prepend lower strings,
// and sub-6 guitars truncate from the high end. Index 0 is always the lowest.
function _hwcSlotKeysForChart(sc, isBass) {
sc = Math.max(1, Math.min(8, (sc | 0) || 6));
if (isBass) {
if (sc <= 4) return ['lowE', 'A', 'D', 'G'].slice(0, sc);
if (sc === 5) return ['low7', 'lowE', 'A', 'D', 'G'];
return ['low8', 'low7', 'lowE', 'A', 'D', 'G'].slice(0, sc);
}
if (sc <= 6) return ['lowE', 'A', 'D', 'G', 'B', 'highE'].slice(0, sc);
if (sc === 7) return ['low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
return ['low8', 'low7', 'lowE', 'A', 'D', 'G', 'B', 'highE'];
}
// Current arrangement shape (string count + bass-vs-guitar) from the 2D highway.
function _hwcChartShape() {
let sc = 6, arr = '';
try { sc = window.highway?.getStringCount?.() || 6; } catch (_) {}
try { arr = window.highway?.getSongInfo?.()?.arrangement || window.feedBack?.currentSong?.arrangement || ''; } catch (_) {}
return { sc: Math.max(1, Math.min(8, sc)), isBass: /bass/i.test(String(arr)) };
}
// Normalize an arbitrary value to a slot→hex map of validated lowercase colors
// (absent / invalid slots are omitted).
function _hwcNormalize(slotMap) {
const out = {};
if (slotMap && typeof slotMap === 'object' && !Array.isArray(slotMap)) {
for (const k of HWC_SLOT_KEYS) {
const v = (typeof slotMap[k] === 'string') ? slotMap[k].trim().toLowerCase() : '';
if (HWC_HEX_RE.test(v)) out[k] = v;
}
}
return out;
}
// Canonical default color per named slot (the classic highway mapping).
// Fixed, not read back from the highway (which may already be name-remapped for
// a 7/8-string chart), so the pickers always preview the true per-name default.
function getHighwayDefaultSlotColors() {
return { ...HWC_DEFAULT_FALLBACK };
}
// Active (user-customized) slot→hex map from storage ({} when none set).
function getHighwayStringColors() {
try {
const raw = localStorage.getItem(HWC_KEY_ACTIVE);
if (raw) return _hwcNormalize(JSON.parse(raw));
} catch (_) { /* corrupt / blocked */ }
return {};
}
// Defaults overlaid with the user's custom slots (custom wins). Always a full
// 8-slot map, so name-mapping has a color for every string of any arrangement.
function _hwcMergedSlotColors() {
return { ...getHighwayDefaultSlotColors(), ...getHighwayStringColors() };
}
// True when the slot→index mapping is the identity (index 0 = lowest = Low E):
// guitar ≤6 strings and 4-string bass. For these the name mapping equals the
// stock index order, so we leave the highways on their hand-tuned defaults
// (byte-identical) unless the user set custom colors. Extended-range charts —
// 7/8-string guitar and 5/6-string bass — prepend lower strings (Low B/F#),
// shifting Low E up an index, so their defaults must be name-remapped too.
function _hwcMappingIsIdentity(sc, isBass) {
return isBass ? sc <= 4 : sc <= 6;
}
// Translate a full slot map into the index-keyed array the highways consume.
function _hwcEffectiveIndexColors(slotMap, sc, isBass) {
const keys = _hwcSlotKeysForChart(sc, isBass);
return keys.map((k) => slotMap[k] || null);
}
// Persist the user's custom slot map (or clear it), then apply. Only slots that
// actually DIFFER from the default are stored — so reverting every picker to its
// stock color persists as empty and the identity/stock path is restored (rather
// than pinning the highways on an all-default "custom" theme).
function applyHighwayStringColors(slotMap, opts) {
const persist = !opts || opts.persist !== false;
const colors = _hwcNormalize(slotMap);
const defaults = getHighwayDefaultSlotColors();
const overrides = {};
for (const k of Object.keys(colors)) {
if (colors[k] !== defaults[k]) overrides[k] = colors[k];
}
if (persist) {
try {
if (Object.keys(overrides).length) localStorage.setItem(HWC_KEY_ACTIVE, JSON.stringify(overrides));
else localStorage.removeItem(HWC_KEY_ACTIVE);
} catch (_) {}
}
reapplyHighwayStringColors();
}
// Apply a named one-click string-color preset (see HWC_PRESETS) to all strings.
// Persists + applies to both highways (via applyHighwayStringColors), then —
// when the Settings UI is mounted — refreshes the per-string pickers so their
// swatches show the preset's colors. Unknown id is a no-op.
function applyHighwayStringPreset(id) {
const preset = HWC_PRESETS.find((p) => p.id === id);
if (!preset) return false;
applyHighwayStringColors(preset.colors);
try { if (typeof hwcRenderPickers === 'function') hwcRenderPickers(); } catch (_) {}
return true;
}
// Apply colors by NAMED string to both highways for the current arrangement.
// Colors follow the string name regardless of count: Low E stays Low E's color
// on a 6-, 7-, or 8-string. Defaults map identically to the stock order for
// 6-string/bass (so those stay byte-identical); 7/8-string remaps the defaults
// too so Low E keeps its color. The String Colors UI replaces the 3D highway's
// old palette picker, so core always drives the 3D string colors here.
function reapplyHighwayStringColors() {
const { sc, isBass } = _hwcChartShape();
const custom = getHighwayStringColors();
const hasCustom = Object.keys(custom).length > 0;
if (!hasCustom && _hwcMappingIsIdentity(sc, isBass)) {
// Pure stock defaults in natural order — leave the hand-tuned highway
// defaults intact, and make sure the 3D is on its plain default palette
// (clears any stale 'custom' / leftover palette selection).
try { window.highway?.setStringColors?.(null); } catch (_) {}
try {
if (localStorage.getItem('h3d_bg_palette') !== 'default') window.h3dBgSetPalette?.('default');
} catch (_) {}
try { window.feedBack?.emit?.('highway:stringColors', {}); } catch (_) {}
return;
}
const eff = _hwcEffectiveIndexColors(_hwcMergedSlotColors(), sc, isBass);
try { window.highway?.setStringColors?.(eff); } catch (_) {}
try { window.h3dBgSetStringColors?.(eff); } catch (_) {}
try { window.feedBack?.emit?.('highway:stringColors', custom); } catch (_) {}
}
function _hwcReadThemes() {
// Null-prototype store: theme names come from user input / share codes, so
// names like `constructor`/`toString`/`__proto__` must not collide with
// inherited Object properties or mutate the prototype.
try {
const parsed = JSON.parse(localStorage.getItem(HWC_KEY_THEMES) || '{}');
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return Object.create(null);
const out = Object.create(null);
for (const [name, colors] of Object.entries(parsed)) out[name] = _hwcNormalize(colors);
return out;
} catch (_) { return Object.create(null); }
}
function _hwcWriteThemes(o) { try { localStorage.setItem(HWC_KEY_THEMES, JSON.stringify(o)); } catch (_) {} }
function listHighwayColorThemes() { return Object.keys(_hwcReadThemes()); }
function getActiveHighwayColorThemeName() { try { return localStorage.getItem(HWC_KEY_NAME) || ''; } catch (_) { return ''; } }
function saveHighwayColorTheme(name, slotMap) {
name = String(name || '').trim();
if (!name) return false;
const o = _hwcReadThemes();
o[name] = _hwcNormalize(slotMap);
_hwcWriteThemes(o);
try { localStorage.setItem(HWC_KEY_NAME, name); } catch (_) {}
return true;
}
function deleteHighwayColorTheme(name) {
const o = _hwcReadThemes();
if (Object.prototype.hasOwnProperty.call(o, name)) { delete o[name]; _hwcWriteThemes(o); }
if (getActiveHighwayColorThemeName() === name) { try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {} }
}
// Select a saved theme by name, or pass '' to revert to defaults.
function selectHighwayColorTheme(name) {
if (!name) {
try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {}
applyHighwayStringColors(null);
return;
}
const o = _hwcReadThemes();
if (!Object.prototype.hasOwnProperty.call(o, name)) return;
try { localStorage.setItem(HWC_KEY_NAME, name); } catch (_) {}
applyHighwayStringColors(o[name]);
}
// Compact, paste-friendly share code: "SLOPHWY2." + base64url(JSON{n,c}) where
// c is the named slot→hex map.
function encodeHighwayColorShare(name, slotMap) {
const payload = { n: String(name || '').slice(0, 60), c: _hwcNormalize(slotMap) };
const json = JSON.stringify(payload);
let b64;
try { b64 = btoa(unescape(encodeURIComponent(json))); } catch (_) { b64 = btoa(json); }
return 'SLOPHWY2.' + b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function decodeHighwayColorShare(code) {
if (typeof code !== 'string') return null;
let s = code.trim();
// Require the exact versioned prefix. Anything else (a future/legacy
// SLOPHWY*, or unprefixed text) is rejected so the version boundary is real.
const PREFIX = 'SLOPHWY2.';
if (s.slice(0, PREFIX.length).toUpperCase() !== PREFIX) return null;
s = s.slice(PREFIX.length);
s = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '=';
let json;
try { json = decodeURIComponent(escape(atob(s))); } catch (_) { try { json = atob(s); } catch (_) { return null; } }
let obj;
try { obj = JSON.parse(json); } catch (_) { return null; }
if (!obj || typeof obj.c !== 'object' || Array.isArray(obj.c)) return null;
return { name: String(obj.n || '').slice(0, 60), colors: _hwcNormalize(obj.c) };
}
// Import a share code: store it as a (uniquely named) saved theme and apply.
function importHighwayColorShare(code) {
const parsed = decodeHighwayColorShare(code);
if (!parsed) return null;
let name = parsed.name || 'Imported';
const existing = _hwcReadThemes();
if (Object.prototype.hasOwnProperty.call(existing, name)) {
let i = 2;
while (Object.prototype.hasOwnProperty.call(existing, name + ' ' + i)) i++;
name = name + ' ' + i;
}
saveHighwayColorTheme(name, parsed.colors);
applyHighwayStringColors(parsed.colors);
return { name, colors: parsed.colors };
}
// Startup: apply persisted colors to the 2D highway immediately and re-apply on
// every song load (string count / bass-vs-guitar can change the slot→index
// mapping) and whenever a viz renderer (re)initializes (the 3D loads async +
// rebuilds per song, so a one-shot apply could land before it exists).
let _hwcWired = false;
export function initHighwayColors() {
reapplyHighwayStringColors();
if (!_hwcWired && window.feedBack && typeof window.feedBack.on === 'function') {
_hwcWired = true;
window.feedBack.on('viz:renderer:ready', reapplyHighwayStringColors);
window.feedBack.on('song:loaded', reapplyHighwayStringColors);
window.feedBack.on('song:ready', reapplyHighwayStringColors);
}
_hwcInstallFacade();
}
// ── Public plugin API: window.feedBack.highwayColors ─────────────────────
// A stable, documented facade over the (otherwise private) string-color
// manager so plugins can read / react to / set the user's per-string colors
// without reaching into internals. This is a synchronous data-plane API, not a
// capability domain — consistent with the constitution keeping highway/viz
// surfaces off the capability graph until a dedicated render-facade slice
// lands. Colors are keyed by NAMED string slot (see `slots`); use
// `keysForChart`/`toEffective` to map names → per-string-index for a given
// arrangement. See docs/plugin-capability-inventory.md.
const _hwcChangeWrappers = new WeakMap();
function _hwcInstallFacade() {
if (!window.feedBack || window.feedBack.highwayColors) return;
const api = {
version: 1,
// Ordered named slots: [{ key, label, sub }]. `key` is the stable id.
slots: HWC_SLOTS.map((s) => ({ key: s.key, label: s.label, sub: s.sub })),
// User-set overrides only (named slot → hex); empty object = defaults.
get() { return getHighwayStringColors(); },
// Canonical default color per named slot.
getDefaults() { return getHighwayDefaultSlotColors(); },
// Defaults overlaid with overrides — the colors in effect, by name.
getResolved() { return _hwcMergedSlotColors(); },
// Which named slot each chart string index maps to, for an arrangement
// (index 0 = lowest string). e.g. (7,false) → ['low7','lowE','A',...].
keysForChart(stringCount, isBass) { return _hwcSlotKeysForChart(stringCount, !!isBass); },
// Per-string-INDEX hex array (resolved colors) for an arrangement.
// Omit args to use the currently-loaded chart's shape.
toEffective(stringCount, isBass) {
const shape = (typeof stringCount === 'number')
? { sc: stringCount, isBass: !!isBass }
: _hwcChartShape();
return _hwcEffectiveIndexColors(_hwcMergedSlotColors(), shape.sc, shape.isBass);
},
// The per-index colors actually applied to the live 2D highway now.
getCurrent() {
try { return (window.highway && window.highway.getStringColors) ? window.highway.getStringColors() : []; }
catch (_) { return []; }
},
// Set colors programmatically (persists + applies to both highways).
// Pass a named slot map, or null/{} to revert to defaults.
apply(slotMap) { return applyHighwayStringColors(slotMap); },
// One-click presets: [{ id, label, colors }] (full named-slot maps).
presets: HWC_PRESETS.map((p) => ({ id: p.id, label: p.label, colors: { ...p.colors } })),
// Apply a preset by id (persists + applies to both highways).
applyPreset(id) { return applyHighwayStringPreset(id); },
// Share-code interop (the "SLOPHWY2." copy/paste format).
encodeShare(name, slotMap) { return encodeHighwayColorShare(name, slotMap); },
decodeShare(code) { return decodeHighwayColorShare(code); },
// Subscribe to color changes; handler receives the resolved slot map.
// Returns an unsubscribe fn that removes exactly THIS subscription;
// offChange(fn) removes every subscription registered with that fn.
// (Each fn maps to a Set of wrappers so repeated mount/init paths that
// subscribe the same handler don't clobber each other or leak.)
onChange(fn) {
if (typeof fn !== 'function' || !window.feedBack) return () => {};
const wrapper = () => {
try { fn(api.getResolved()); } catch (e) { console.error('[highwayColors] onChange handler threw', e); }
};
let set = _hwcChangeWrappers.get(fn);
if (!set) { set = new Set(); _hwcChangeWrappers.set(fn, set); }
set.add(wrapper);
window.feedBack.on('highway:stringColors', wrapper);
return () => {
if (window.feedBack) window.feedBack.off('highway:stringColors', wrapper);
const s = _hwcChangeWrappers.get(fn);
if (s) { s.delete(wrapper); if (!s.size) _hwcChangeWrappers.delete(fn); }
};
},
offChange(fn) {
const set = _hwcChangeWrappers.get(fn);
if (set && window.feedBack) {
for (const wrapper of set) window.feedBack.off('highway:stringColors', wrapper);
_hwcChangeWrappers.delete(fn);
}
},
};
window.feedBack.highwayColors = api;
}
// ── Highway String Colors — Settings UI wiring ───────────────────────────
// Pickers are per NAMED string (see HWC_SLOTS). Assigning "Low E" a color
// keeps Low E that color regardless of string count — the translation table
// (_hwcSlotKeysForChart) handles the index remapping per arrangement.
function _hwcStatus(msg) {
const el = document.getElementById('hwc-status');
if (!el) return;
el.textContent = msg || '';
if (msg) {
clearTimeout(_hwcStatus._t);
_hwcStatus._t = setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 2500);
}
}
// Render one color input per named slot, seeded from active colors (falling
// back to the highway defaults for that slot).
function hwcRenderPickers() {
const host = document.getElementById('hwc-pickers');
if (!host) return;
const defaults = getHighwayDefaultSlotColors();
const active = getHighwayStringColors();
host.innerHTML = '';
for (const slot of HWC_SLOTS) {
const val = active[slot.key] || defaults[slot.key] || '#888888';
const wrap = document.createElement('label');
wrap.className = 'flex items-center gap-2 text-xs text-gray-400';
const input = document.createElement('input');
input.type = 'color';
input.id = 'hwc-color-' + slot.key;
input.dataset.slot = slot.key;
input.value = val;
input.style.width = '2.5rem';
input.style.height = '1.75rem';
input.style.padding = '2px';
input.style.cursor = 'pointer';
input.className = 'rounded border border-gray-800 bg-dark-700';
input.addEventListener('input', () => hwcOnColorInput());
wrap.appendChild(input);
const span = document.createElement('span');
span.textContent = slot.label;
wrap.appendChild(span);
const sub = document.createElement('span');
sub.className = 'text-gray-600';
sub.textContent = slot.sub;
wrap.appendChild(sub);
host.appendChild(wrap);
}
}
function hwcReadPickers() {
const out = {};
for (const slot of HWC_SLOTS) {
const el = document.getElementById('hwc-color-' + slot.key);
if (el) out[slot.key] = el.value;
}
return out;
}
// Live apply on any picker change. Leaves the saved-theme select alone so a
// tweaked-but-unsaved state is allowed; "Save as…" captures it.
function hwcOnColorInput() {
applyHighwayStringColors(hwcReadPickers());
}
function hwcPopulateThemeSelect() {
const sel = document.getElementById('hwc-theme-select');
if (!sel) return;
const names = listHighwayColorThemes().sort((a, b) => a.localeCompare(b));
const current = getActiveHighwayColorThemeName();
sel.innerHTML = '';
const def = document.createElement('option');
def.value = '';
def.textContent = 'Default colors';
sel.appendChild(def);
for (const n of names) {
const opt = document.createElement('option');
opt.value = n;
opt.textContent = n;
sel.appendChild(opt);
}
sel.value = (current && names.includes(current)) ? current : '';
}
function hwcOnSelectTheme(name) {
selectHighwayColorTheme(name);
hwcRenderPickers();
}
async function hwcSaveTheme() {
const name = await uiPrompt({ title: 'Save Highway Colors', label: 'Theme name', value: getActiveHighwayColorThemeName() || 'My Colors', okLabel: 'Save' });
if (!name) return;
saveHighwayColorTheme(name, hwcReadPickers());
hwcPopulateThemeSelect();
_hwcStatus('Saved “' + name + '”');
}
function hwcDeleteTheme() {
const name = getActiveHighwayColorThemeName();
if (!name) { _hwcStatus('No saved theme selected'); return; }
deleteHighwayColorTheme(name);
applyHighwayStringColors(null);
hwcPopulateThemeSelect();
hwcRenderPickers();
_hwcStatus('Deleted “' + name + '”');
}
function hwcReset() {
try { localStorage.removeItem(HWC_KEY_NAME); } catch (_) {}
applyHighwayStringColors(null);
hwcPopulateThemeSelect();
hwcRenderPickers();
_hwcStatus('Reset to defaults');
}
async function hwcCopyShare() {
const name = getActiveHighwayColorThemeName() || 'Highway Colors';
const code = encodeHighwayColorShare(name, hwcReadPickers());
let copied = false;
try { await navigator.clipboard.writeText(code); copied = true; } catch (_) {}
if (!copied) {
// Fallback: drop the code into the import field so it can be copied manually.
const inp = document.getElementById('hwc-import-code');
if (inp) { inp.value = code; inp.select(); }
}
_hwcStatus(copied ? 'Share code copied' : 'Copy failed — code shown below');
}
function hwcImport() {
const inp = document.getElementById('hwc-import-code');
const code = inp ? inp.value : '';
const res = importHighwayColorShare(code);
if (!res) { _hwcStatus('Invalid share code'); return; }
if (inp) inp.value = '';
hwcPopulateThemeSelect();
hwcRenderPickers();
_hwcStatus('Imported “' + res.name + '”');
}
export function hwcInitSettingsUI() {
hwcPopulateThemeSelect();
hwcRenderPickers();
}
+99
View File
@@ -0,0 +1,99 @@
// The host seam — how a carved-out module calls back into app.js.
//
// WHY THIS EXISTS. What is left in app.js is not a tree, it is a cycle: seeding a
// dependency closure from count-in, from loops, from section-practice, or from the
// JUCE seek shim all return the SAME 178-function set, and setLoop() and
// practiceSection() call each other directly. So a module carved out of that
// component will always need to call back into app.js — and it cannot `import`
// app.js to do it, because app.js imports the module, and that closes a cycle the
// import-x/no-cycle gate (rightly) rejects.
//
// So app.js hands its functions DOWN, once, at boot: `configureHost({ playSong, … })`.
//
// ─── THE FAILURE MODE THIS IS BUILT TO PREVENT ───────────────────────────────
//
// The obvious way to write this is a plain object with no-op defaults. That is a
// TRAP, and we walked into it once already: the plugin loader's host seam defaulted
// `populateVizPicker` to `() => {}`, which means that if the wiring call in app.js
// is ever dropped, renamed, or drifts, the loader keeps running, the viz picker
// silently stops refreshing, and NOTHING — no test, no boot check, no bot — says a
// word. A feature just quietly stops existing.
//
// Two layers stop that here, and the second is the one that actually closes it:
//
// 1. RUNTIME — reading an unwired hook THROWS. There are no defaults and no
// stubs. `host.playSong` either is the real function or it is a loud error.
// An unwired hook cannot degrade into a no-op, because there is nothing for
// it to degrade INTO.
//
// 2. STATIC — tests/js/host_contract.test.js asserts that the set of hooks the
// modules USE is exactly the set app.js WIRES. This is the important one:
// layer 1 only fires if the broken path actually executes, and the whole
// danger of this seam is paths that don't run in a smoke test. The static
// check catches a drifted or misspelled hook in CI, on a path nobody ran.
//
// Consequence for anyone adding a hook: add it to the configureHost({…}) call in
// app.js *and* use it as `host.<name>`. The contract test fails on either alone —
// deliberately. A hook wired but never used is dead weight; a hook used but never
// wired is a bug that would otherwise hide.
const _hooks = Object.create(null);
let _configured = false;
/**
* Called ONCE by app.js at boot, before any carved module runs. Every value must
* be a function — a hook that is accidentally `undefined` (a typo, a renamed
* export, a dropped line) fails HERE, at startup, rather than silently much later.
*/
export function configureHost(hooks) {
if (_configured) {
throw new Error('[host] configureHost() called twice — it must be wired exactly once, at boot.');
}
const bad = Object.entries(hooks || {})
.filter(([, v]) => typeof v !== 'function')
.map(([k]) => k);
if (bad.length) {
throw new Error(
`[host] these hooks are not functions: ${bad.join(', ')}. `
+ 'A hook is usually undefined because it was renamed or its line was dropped.',
);
}
Object.assign(_hooks, hooks);
_configured = true;
}
/**
* The seam itself. Reading a hook that was never wired THROWS — it never returns
* undefined and never returns a silent no-op. See the note at the top: a no-op
* default is precisely the bug this module exists to make impossible.
*/
export const host = new Proxy(Object.create(null), {
get(_target, name) {
if (typeof name === 'symbol') return undefined; // let JS probe it freely
if (!_configured) {
throw new Error(
`[host] host.${name} was read before configureHost() ran. `
+ 'app.js must call configureHost() at boot, before any carved module executes.',
);
}
const fn = _hooks[name];
if (typeof fn !== 'function') {
throw new Error(
`[host] host.${name} is not wired. Add it to the configureHost({ … }) `
+ 'call in app.js. (tests/js/host_contract.test.js should have caught this in CI.)',
);
}
return fn;
},
// Keep the object honest for anything that introspects it.
has(_target, name) { return name in _hooks; },
ownKeys() { return Object.keys(_hooks); },
getOwnPropertyDescriptor(_target, name) {
return name in _hooks
? { value: _hooks[name], enumerable: true, configurable: true, writable: false }
: undefined;
},
set(_target, name) {
throw new Error(`[host] host.${String(name)} is read-only — hooks are wired only via configureHost().`);
},
});
+261
View File
@@ -0,0 +1,261 @@
// The AB loop — set / clear / persist, and the saved-loops list.
//
// The second slice out of app.js's strongly-connected core, and it owns the loop
// state: loopA, loopB, _loopMutationGen. Nothing outside this module writes them
// (restartCurrentSong() looked like it did, but it declares its own local shadows).
//
// DIRECTION MATTERS HERE. loops and section-practice are mutually dependent — the
// SCC in miniature. clearLoop() has to drop section-practice's selection, and
// practiceSection() has to call setLoop(). Both directions cannot be imports or the
// no-cycle gate (rightly) rejects it. So the edge is oriented:
//
// section-practice -> reaches loops through the HOST SEAM (host.setLoop, …)
// loops -> imports section-practice DIRECTLY
//
// section-practice is the higher-level feature — it is a consumer of loops, not the
// other way round — so it is the one that gets the indirection. app.js wires this
// module's exports into the seam for it.
//
// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js
// fails CI if the hooks used here and the hooks app.js wires ever drift apart.
import { esc, uiPrompt } from './dom.js';
import { host } from './host.js';
import {
_setSectionPracticeMode,
_syncSectionPracticeFromLoop,
_updateSectionPracticeHighlight,
practiceSection,
resetSelection,
} from './section-practice.js';
// ── A-B Loop ────────────────────────────────────────────────────────────
export let loopA = null;
export let loopB = null;
// Bumped on every NON-practiceSection loop mutation (direct setLoop from Saved
// Loops / the plugin API, and clearLoop). practiceSection() captures it and bails
// if it changes mid-retry, so a stale section retry can't overwrite a loop the
// user just set/cleared by another path. practiceSection's own setLoop calls pass
// skipSectionSync and do NOT bump it (they must not supersede themselves).
export let _loopMutationGen = 0;
export function setLoopStart() {
loopA = host._audioTime();
document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
updateLoopUI();
}
export function setLoopEnd() {
if (loopA === null) return;
loopB = host._audioTime();
if (loopB <= loopA) { loopB = null; return; }
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
updateLoopUI();
// Manual A/B arming is a loop mutation like setLoop()'s — emit the same
// transport event so event-driven consumers (note_detect drill sync) see
// button-armed loops without having to poll getLoop().
window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } });
}
export function clearLoop(options) {
const { emitTransportEvent = true } = options || {};
// playSong() clears the loop on every song load, so only signal a
// loop-cleared transport event when a loop was actually active —
// otherwise every song switch emits a spurious playback:loop-cleared.
const hadLoop = loopA !== null || loopB !== null;
_setSectionPracticeMode(false, { skipClearLoop: true });
loopA = null;
loopB = null;
document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition';
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition';
document.getElementById('btn-loop-clear').classList.add('hidden');
document.getElementById('btn-loop-save').classList.add('hidden');
document.getElementById('loop-label').textContent = '';
document.getElementById('saved-loops').value = '';
resetSelection();
_updateSectionPracticeHighlight(host._audioTime());
if (hadLoop && emitTransportEvent && typeof window !== 'undefined') {
window.feedBack?.playback?.transportEvent?.('loop-cleared', {
requesterId: 'core.loop',
reason: 'app loop cleared',
loop: { enabled: false, state: 'inactive' },
});
}
}
// Resync #saved-loops + #btn-loop-delete with the currently-active
// loopA/loopB. Used by both setLoop's success path (so plugin-driven
// loops show up correctly in the dropdown) and loadSavedLoop's
// failure path (so a cancelled selection reverts to the still-active
// loop). Without this sync, deleteSelectedLoop could target a stale
// option that doesn't match the active loop.
function _syncSavedLoopSelection() {
const sel = document.getElementById('saved-loops');
const delBtn = document.getElementById('btn-loop-delete');
if (!sel || !delBtn) return;
let selected = '';
if (loopA !== null && loopB !== null) {
for (const opt of sel.options) {
if (Number(opt.dataset.start) === loopA && Number(opt.dataset.end) === loopB) {
selected = opt.value;
break;
}
}
}
sel.value = selected;
delBtn.classList.toggle('hidden', !selected);
}
// Programmatically set both loop endpoints and seek to A. The dropdown
// path (loadSavedLoop) and the plugin-API path (window.feedBack.setLoop)
// both funnel through here so the UI state stays canonical regardless of
// who triggered the loop.
//
// Returns true if the seek landed at A and the loop is now active;
// returns false if the seek was cancelled by teardown or landed off-target
// (JUCE clamp / HTML5 snap > 50ms from A). On false, loopA/loopB are NOT
// committed and the UI is not painted — the prior loop (if any) stays
// active. Throws on invalid inputs.
export async function setLoop(a, b, options) {
const { emitTransportEvent = true, skipSectionSync = false, commitGuard = null } = options || {};
const aNum = Number(a);
const bNum = Number(b);
if (!Number.isFinite(aNum) || !Number.isFinite(bNum) || bNum <= aNum) {
throw new Error(`setLoop: requires finite a and b with b > a (got a=${a}, b=${b})`);
}
// Don't arm loopA/loopB before the seek lands — the 60Hz tick's wrap
// detector (`ct >= loopB`) would trigger startCountIn against
// half-applied state.
const r = await host._audioSeek(aNum, 'loop-set');
if (!r.completed || Math.abs(r.to - aNum) > 0.05) return false;
// Caller-owned staleness gate, re-checked after the awaited seek and before
// we commit loopA/loopB. practiceSection() passes this so a superseded retry
// (newer section click, mode turned off, or song/arrangement teardown that
// happened during the seek) does not arm a stale loop. Returning false here
// leaves the prior loop (if any) untouched, same as the off-target path.
if (typeof commitGuard === 'function' && !commitGuard()) return false;
loopA = aNum;
loopB = bNum;
// A direct (non-practice) loop set supersedes any in-flight practiceSection
// retry; practiceSection passes skipSectionSync and is exempt so it doesn't
// cancel itself.
if (!skipSectionSync) _loopMutationGen++;
document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition';
updateLoopUI();
// Sync the saved-loops dropdown so a plugin-driven setLoop call
// surfaces the matching saved option (and Delete button) — otherwise
// the dropdown can stay on a stale selection and deleteSelectedLoop
// would target the wrong record.
_syncSavedLoopSelection();
// practiceSection() passes skipSectionSync: it sets its own section state
// under a request-gen guard, so the shared setLoop path must NOT re-sync
// here — otherwise a stale (superseded / mode-off) practiceSection retry
// that lands inside setLoop would re-arm the loop and flip the mode back on
// before the caller's gen check can bail. Direct callers (Saved Loops,
// window.feedBack.setLoop) still sync so their chip selection tracks.
if (!skipSectionSync && typeof _syncSectionPracticeFromLoop === 'function') {
_syncSectionPracticeFromLoop();
}
if (emitTransportEvent && typeof window !== 'undefined') {
window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } });
}
return true;
}
export function updateLoopUI() {
const label = document.getElementById('loop-label');
const hasLoop = loopA !== null && loopB !== null;
if (hasLoop) {
label.textContent = `${host.formatTime(loopA)}${host.formatTime(loopB)}`;
document.getElementById('btn-loop-clear').classList.remove('hidden');
document.getElementById('btn-loop-save').classList.remove('hidden');
} else if (loopA !== null) {
label.textContent = `${host.formatTime(loopA)} → ?`;
document.getElementById('btn-loop-clear').classList.add('hidden');
document.getElementById('btn-loop-save').classList.add('hidden');
} else {
label.textContent = '';
}
host._updateEditRegionBtn();
}
export async function loadSavedLoops() {
const sel = document.getElementById('saved-loops');
const delBtn = document.getElementById('btn-loop-delete');
if (!host.currentFilename()) { sel.classList.add('hidden'); delBtn.classList.add('hidden'); return; }
const resp = await fetch(`/api/loops?filename=${encodeURIComponent(decodeURIComponent(host.currentFilename()))}`);
const loops = await resp.json();
sel.innerHTML = '<option value="">Saved Loops</option>';
for (const l of loops) {
sel.innerHTML += `<option value="${l.id}" data-start="${l.start}" data-end="${l.end}">${esc(l.name)} (${host.formatTime(l.start)}${host.formatTime(l.end)})</option>`;
}
if (loops.length > 0) {
sel.classList.remove('hidden');
} else {
sel.classList.add('hidden');
}
delBtn.classList.add('hidden');
}
export async function loadSavedLoop(loopId) {
const sel = document.getElementById('saved-loops');
const opt = sel.selectedOptions[0];
const delBtn = document.getElementById('btn-loop-delete');
if (!loopId || !opt?.dataset.start) {
delBtn.classList.add('hidden');
return;
}
let ok = false;
try {
// Pass raw strings — setLoop's Number() coercion is stricter than
// parseFloat (rejects "12abc") so malformed dataset values throw
// and fall into the catch instead of silently truncating.
ok = await setLoop(opt.dataset.start, opt.dataset.end);
} catch (err) {
// Malformed dataset (server returned bad data): treat the same as
// a failed seek so the dropdown resyncs and we don't propagate an
// uncaught rejection out of the onchange handler.
console.warn('[loadSavedLoop] setLoop threw:', err);
ok = false;
}
if (!ok) {
// Seek aborted, landed off-target, or input was malformed.
// Resync the dropdown with the still-active loop so the UI
// doesn't lie about which loop is loaded.
_syncSavedLoopSelection();
return;
}
// Success path: setLoop already called _syncSavedLoopSelection,
// which surfaces the delete button when the new loop matches a
// saved option (which the dropdown selection guarantees here).
}
export async function saveCurrentLoop() {
if (loopA === null || loopB === null || !host.currentFilename()) return;
const name = await uiPrompt({ title: 'Save Loop', label: 'Loop name', value: 'Loop', okLabel: 'Save' });
if (name === null) return; // cancelled
const finalName = name.trim() || 'Loop'; // never persist an empty name
await fetch('/api/loops', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: decodeURIComponent(host.currentFilename()),
name: finalName,
start: loopA,
end: loopB,
}),
});
await loadSavedLoops();
document.getElementById('btn-loop-save').classList.add('hidden');
}
export async function deleteSelectedLoop() {
const sel = document.getElementById('saved-loops');
const loopId = sel.value;
if (!loopId) return;
await fetch(`/api/loops/${loopId}`, { method: 'DELETE' });
clearLoop();
await loadSavedLoops();
}
+34
View File
@@ -0,0 +1,34 @@
// Shared, MUTABLE player state.
//
// WHY A CONTAINER AND NOT PLAIN EXPORTS. An imported binding is read-only. Every
// slice carved out of app.js so far has only ever READ the state it shares
// (loopA/loopB, _audioSeekGen, currentFilename), so a getter hook was enough and no
// container was needed. That runs out here: count-in genuinely WRITES `isPlaying`
// (it starts and stops playback) and `lastAudioTime`. `import { isPlaying }` then
// `isPlaying = true` throws — the binding cannot be assigned to.
//
// So the state moves onto an object. `S.isPlaying = true` is a property write, which
// works from any module holding the same `S`. This is the same shape the stems,
// studio, and editor migrations converged on.
//
// It is deliberately SMALL. app.js has ~104 top-level `let` scalars; lifting all of
// them would be a ~977-site rewrite for no benefit, since most are private to one
// cluster and travel with it. Only the ones a carved module must WRITE belong here.
// Add to it when a carve actually needs it, not before.
//
// NB app.js's own 71 reference sites were rewritten mechanically — but from the AST,
// not by text substitution. Of 100 textual occurrences of these two names, only 71
// resolve to the module binding: 22 are member accesses (`someObj.isPlaying`), 4 are
// the local parameter of setPlayButtonState(isPlaying), one is an object key, and two
// are shorthand properties (`{ isPlaying }`) that must become `{ isPlaying: S.isPlaying }`.
// A blind find-and-replace corrupts all 29.
export const S = {
/** Is the transport running? Written by playback, count-in, and the JUCE shims. */
isPlaying: false,
/**
* The last audio position we saw, in seconds. Used to detect a seek that did not
* land where it was asked to (JUCE can clamp; HTML5 can round).
*/
lastAudioTime: 0,
};
+59
View File
@@ -802,3 +802,62 @@ export async function bootstrapPluginsAndUi() {
_streamPluginStartup();
return plugins;
}
// ── Plugin updates ──────────────────────────────────────────────────────
// The Settings-screen "Check for updates" / "Update" buttons. Carved out of
// app.js (R3a) into the loader rather than a module of their own: this is plugin
// MANAGEMENT, it belongs with the code that loads them. Both are inline handlers,
// so app.js re-exposes them on window.
export async function checkPluginUpdates() {
const btn = document.getElementById('btn-check-updates');
const status = document.getElementById('updates-status');
const list = document.getElementById('plugin-updates-list');
btn.disabled = true;
btn.textContent = 'Checking...';
status.textContent = '';
list.innerHTML = '';
try {
const resp = await fetch('/api/plugins/updates');
const data = await resp.json();
const updates = data.updates || {};
const keys = Object.keys(updates);
if (keys.length === 0) {
status.textContent = 'All plugins are up to date.';
} else {
status.textContent = `${keys.length} update${keys.length > 1 ? 's' : ''} available`;
for (const id of keys) {
const u = updates[id];
const row = document.createElement('div');
row.className = 'flex items-center gap-3 bg-dark-700 rounded-lg px-4 py-2';
row.innerHTML = `
<span class="text-sm text-gray-300 flex-1">${u.name} <span class="text-xs text-gray-500">(${u.behind} commit${u.behind > 1 ? 's' : ''} behind — ${u.local}${u.remote})</span></span>
<button onclick="updatePlugin('${id}', this)" class="bg-accent/20 hover:bg-accent/30 text-accent-light px-3 py-1 rounded-lg text-xs transition">Update</button>`;
list.appendChild(row);
}
}
} catch (e) {
status.textContent = 'Failed to check for updates.';
}
btn.disabled = false;
btn.textContent = 'Check for Updates';
}
export async function updatePlugin(pluginId, btn) {
btn.disabled = true;
btn.textContent = 'Updating...';
try {
const resp = await fetch(`/api/plugins/${pluginId}/update`, { method: 'POST' });
const data = await resp.json();
if (data.ok) {
btn.textContent = 'Updated — restart to apply';
btn.className = 'bg-green-900/30 text-green-400 px-3 py-1 rounded-lg text-xs';
} else {
btn.textContent = 'Failed';
btn.title = data.error || '';
}
} catch (e) {
btn.textContent = 'Error';
}
}
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
// Settings backup — the export / import bundle.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
//
// Two entry points, both inline handlers on the Settings screen, so app.js keeps
// re-exposing them on window. The import is two-phase (server first, atomic; then
// a best-effort localStorage merge) — the rationale comment below is the contract
// and moved with the code.
//
// Bundles server config + every localStorage key + opted-in plugin server
// files into a single JSON file.
//
// Apply semantics — phased, NOT all-or-nothing across the two stores:
// 1. Server first (/api/settings/import). Phase-1 validation guards
// the whole bundle; phase-2 disk commit is per-file but ordered
// so a mid-apply failure surfaces a `partial` field. A server
// failure short-circuits before any localStorage write, so the
// browser side stays untouched on validation refusals.
// 2. localStorage second, only after the server returns ok. Applied
// as a MERGE (no clear): bundled keys overwrite, locally-present
// keys absent from the bundle are preserved (so a plugin
// installed after the export keeps its first-run defaults).
// A localStorage exception here (quota / private mode) is
// surfaced verbatim — server state is already committed and we
// don't pretend the import was clean.
//
// In short: the server side is atomic in phase 1 and surface-partial in
// phase 2; the localStorage side is best-effort merge after server
// success. Failures are reported, never silenced.
export async function exportSettings() {
const status = document.getElementById('backup-status');
status.textContent = 'Exporting...';
try {
const resp = await fetch('/api/settings/export');
if (!resp.ok) {
status.textContent = `Export failed (HTTP ${resp.status})`;
return;
}
const bundle = await resp.json();
// Layer in the browser's localStorage. Use the standard Storage
// iteration API (length + key(i)) rather than Object.keys —
// Object.keys on a Storage instance is not deterministic across
// browsers and can both miss entries and include non-entry
// properties depending on the implementation. Keys are preserved
// verbatim as strings; that's how localStorage stores them, and
// round-trip fidelity matters more than re-typing values that
// were never typed in the first place.
const localStorageData = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key === null) continue;
const value = localStorage.getItem(key);
if (value !== null) localStorageData[key] = value;
}
bundle.local_storage = localStorageData;
// Trigger download via blob + temporary <a download>. We honor the
// server's Content-Disposition filename when present, otherwise
// fall back to a date-stamped default.
let filename = 'feedBack-settings.json';
const disposition = resp.headers.get('Content-Disposition');
if (disposition) {
const match = /filename="([^"]+)"/.exec(disposition);
if (match) filename = match[1];
}
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
status.textContent = `Exported ${filename}`;
} catch (e) {
status.textContent = `Export failed: ${e.message}`;
}
}
export async function importSettings(file) {
if (!file) return;
const status = document.getElementById('backup-status');
if (!confirm('Import will overwrite settings present in the bundle (server config, browser preferences, and opted-in plugin data) and reload the page. Settings not in the bundle (e.g. from plugins installed after the export) are preserved. Continue?')) {
status.textContent = 'Import cancelled';
return;
}
let bundle;
try {
bundle = JSON.parse(await file.text());
} catch (e) {
status.textContent = `Import failed: not valid JSON (${e.message})`;
return;
}
status.textContent = 'Importing...';
let resp, data;
try {
resp = await fetch('/api/settings/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bundle),
});
data = await resp.json();
} catch (e) {
status.textContent = `Import failed: ${e.message}`;
return;
}
// Two failure shapes to surface: our own validation handler
// returns `{ok: false, error: "..."}`, but if the body fails
// FastAPI's request-level validation (e.g. top-level value is
// an array, not an object), the response is the framework's
// `{detail: ...}` shape with no `ok` key. `resp.ok` distinguishes
// both from success without depending on which path produced
// the failure.
if (!resp.ok || data.ok === false) {
let msg = data.error;
if (!msg && data.detail) {
msg = typeof data.detail === 'string'
? data.detail
: JSON.stringify(data.detail);
}
status.textContent = `Import failed: ${msg || `HTTP ${resp.status}`}`;
return;
}
// Server applied successfully. Now apply the localStorage portion as
// a MERGE (not clear+restore): keys in the bundle overwrite, keys
// present locally but absent from the bundle are preserved. This
// matters when a plugin was installed *after* the export — wiping
// its localStorage would erase first-run defaults the plugin set on
// load, leaving it in a worse state than before the import. The
// tradeoff is that orphan keys from removed plugins or renamed key
// schemes also linger; cleaning those up is the user's job.
const ls = bundle.local_storage;
if (ls && typeof ls === 'object') {
try {
for (const [key, value] of Object.entries(ls)) {
if (typeof value === 'string') localStorage.setItem(key, value);
}
} catch (e) {
// Quota exceeded / private mode etc. Server side already
// committed, so we surface the partial state rather than
// pretending it succeeded.
status.textContent = `Server applied, but localStorage write failed: ${e.message}`;
return;
}
}
const warnings = (data.warnings || []).join('; ');
status.textContent = warnings ? `Imported with warnings: ${warnings}. Reloading...` : 'Imported. Reloading...';
setTimeout(() => location.reload(), 800);
}
+228
View File
@@ -0,0 +1,228 @@
// Tuning display — naming, string counts, and target frequencies.
//
// Carved verbatim out of static/app.js (R3a). A LEAF module: imports nothing.
//
// Turns raw per-string semitone offsets into things a human reads: a tuning NAME
// ("Drop D", "Eb Standard", or a raw-offsets fallback), whether an arrangement is
// bass, its effective string count, and the target FREQUENCIES + note names the
// tuner checks against. Pure functions over a small MIDI/note-name table.
//
// The window / window.feedBack assignments for these stay in app.js — they are the
// public contract (constitution II names window.feedBack), and app.js re-exposes
// the imported bindings from exactly where it always did, so nothing about the
// surface or its ordering changes.
// Display-only tuning label helpers — never mutate offsets or affect playback.
function _looksLikeRawTuningOffsets(str) {
if (!str || typeof str !== 'string') return false;
const s = str.trim();
if (!s) return false;
if (/^-?\d+$/.test(s)) return true;
if (/^-?\d+(?: -?\d+)+$/.test(s)) return true;
if (/^-?\d+(?:,-?\d+)+$/.test(s)) return true;
if (/^-?\d+(-?\d+){2,}$/.test(s)) return true;
return false;
}
function _tuningNameFromOffsets(offsets) {
if (!offsets || !offsets.length) return '';
const standard = {
0: 'E Standard', '-1': 'Eb Standard', '-2': 'D Standard',
'-3': 'C# Standard', '-4': 'C Standard', '-5': 'B Standard',
'-6': 'Bb Standard', '-7': 'A Standard',
1: 'F Standard', 2: 'F# Standard',
};
// Uniform offsets across 4 (bass) / 5 / 6 strings name the same Standard;
// a 4-string bass [0,0,0,0] must read "E Standard", not "Custom Tuning".
if (offsets.length >= 4 && offsets.every((o) => o === offsets[0])) {
const name = standard[offsets[0]];
if (name) return name;
}
if (offsets.length >= 4 && offsets[0] === offsets[1] - 2
&& offsets.slice(1).every((o) => o === offsets[1])) {
const noteNames = ['E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B', 'C', 'C#', 'D', 'Eb'];
return 'Drop ' + noteNames[((offsets[0] % 12) + 12) % 12];
}
const named = {
'-2,0,0,0,0,0': 'Drop D',
'-4,-2,-2,-2,-2,-2': 'Drop C',
'-2,-2,0,0,0,0': 'Double Drop D',
'0,0,0,-1,0,0': 'Open G',
'-2,-2,0,0,-2,-2': 'Open D',
'-2,0,0,0,-2,0': 'DADGAD',
'0,2,2,1,0,0': 'Open E',
'-2,0,0,2,3,2': 'Open D (alt)',
};
if (offsets.length === 6) {
const key = offsets.join(',');
if (named[key]) return named[key];
}
return 'Custom Tuning';
}
export function displayTuningName(value, offsets) {
// Explicit offsets win — always name them.
if (Array.isArray(offsets) && offsets.length > 0) {
return _tuningNameFromOffsets(offsets);
}
if (value && typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed || trimmed === 'Unknown') return '';
if (!_looksLikeRawTuningOffsets(trimmed)) {
return trimmed;
}
// A raw offset string (now served by the API) — parse and name it so a
// known tuning like "-1 -1 -1 -1 -1 -1" reads "Eb Standard" rather than
// collapsing to "Custom Tuning".
const parsed = (typeof parseRawTuningOffsets === 'function')
? parseRawTuningOffsets(trimmed) : null;
if (parsed && parsed.length) return _tuningNameFromOffsets(parsed);
return 'Custom Tuning';
}
return '';
}
export function isBassArrangement(context) {
const ctx = context && typeof context === 'object' ? context : {};
if (typeof ctx.isBass === 'boolean') return ctx.isBass;
const label = ((ctx.arrangement || '') + ' ' + (ctx.arrangement_smart_name || '')).toLowerCase();
if (/\bbass\b/.test(label)) return true;
if (/\b(lead|rhythm|combo|guitar)\b/.test(label)) return false;
return false;
}
export function effectiveStringCount(offsets, context) {
if (!Array.isArray(offsets) || !offsets.length) return 0;
const ctx = context && typeof context === 'object' ? context : {};
const isBass = isBassArrangement(ctx);
let sc = ctx.stringCount > 0 ? Number(ctx.stringCount) : 0;
if (!isBass) {
if (sc > 0 && sc <= 5 && offsets.length >= 6) sc = 6;
if (!sc) sc = offsets.length >= 6 ? offsets.length : 6;
} else if (!sc) {
sc = offsets.length >= 5 ? offsets.length : 4;
}
return Math.min(sc, offsets.length);
}
export function songTuningContext(songInfo) {
if (!songInfo || typeof songInfo !== 'object') return {};
return {
stringCount: songInfo.stringCount,
arrangement: songInfo.arrangement,
arrangement_smart_name: songInfo.arrangement_smart_name,
};
}
// Open-string target notes (display only) — mirrors plugins/tuner/utils/tuning-utils.js.
const _TUNING_BASE_MIDI = {
4: [28, 33, 38, 43],
5: [23, 28, 33, 38, 43],
6: [40, 45, 50, 55, 59, 64],
7: [35, 40, 45, 50, 55, 59, 64],
8: [30, 35, 40, 45, 50, 55, 59, 64],
};
const _TUNING_NOTE_SHARP = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const _TUNING_NOTE_FLAT = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B'];
function _tuningMidiToFreq(m) {
return Math.pow(2, (m - 69) / 12) * 440;
}
function _tuningOffsetsToFreqs(offsets, isBass) {
const len = offsets.length;
let base;
if (len === 4 || len === 5) {
base = isBass ? _TUNING_BASE_MIDI[len] : _TUNING_BASE_MIDI[6];
} else {
base = _TUNING_BASE_MIDI[len] || _TUNING_BASE_MIDI[6];
}
return offsets.map((offset, i) => {
const root = i < base.length ? base[i] : base[base.length - 1];
return _tuningMidiToFreq(root + offset);
});
}
function _noteNameFromFreq(freq, useFlats) {
const midi = 69 + 12 * Math.log2(freq / 440);
const rounded = Math.round(midi);
const names = useFlats ? _TUNING_NOTE_FLAT : _TUNING_NOTE_SHARP;
return names[((rounded % 12) + 12) % 12];
}
function _octaveNoteFromFreq(freq, useFlats) {
const midi = 69 + 12 * Math.log2(freq / 440);
const rounded = Math.round(midi);
const octave = Math.floor(rounded / 12) - 1;
return _noteNameFromFreq(freq, useFlats) + octave;
}
function _stringOrdinalLabel(n) {
const v = n % 100;
if (v >= 11 && v <= 13) return n + 'th';
const suffix = { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th';
return n + suffix;
}
function _tuningTargetFreqs(offsets, context) {
if (!Array.isArray(offsets) || !offsets.length) return [];
const ctx = context && typeof context === 'object' ? context : {};
const stringCount = effectiveStringCount(offsets, ctx);
const trimmed = offsets.slice(0, stringCount);
if (!trimmed.length) return [];
const isBass = isBassArrangement(ctx);
try {
return _tuningOffsetsToFreqs(trimmed, isBass);
} catch (_) {
return [];
}
}
// Flat vs sharp spelling. A caller that knows the preference can pass
// ctx.useFlats; otherwise we infer from a flat-keyed tuning name. The v3
// card/HUD pass "Custom Tuning" (raw offsets carry no key), so those default
// to sharps unless an explicit useFlats is supplied.
function _resolveTargetUseFlats(ctx) {
if (typeof ctx.useFlats === 'boolean') return ctx.useFlats;
return typeof ctx.tuningName === 'string' && /\b[A-G]b\b/.test(ctx.tuningName);
}
export function displayTuningTargetDetails(offsets, context) {
const ctx = context && typeof context === 'object' ? context : {};
const useFlats = _resolveTargetUseFlats(ctx);
const freqs = _tuningTargetFreqs(offsets, ctx);
return freqs.map((f, i) => {
const stringNumber = freqs.length - i;
const note = _noteNameFromFreq(f, useFlats);
const octaveNote = _octaveNoteFromFreq(f, useFlats);
return {
stringNumber,
note,
octaveNote,
title: _stringOrdinalLabel(stringNumber) + ' string: ' + octaveNote,
};
});
}
export function displayTuningTargets(offsets, context) {
const ctx = context && typeof context === 'object' ? context : {};
const useFlats = _resolveTargetUseFlats(ctx);
const freqs = _tuningTargetFreqs(offsets, ctx);
if (!freqs.length) return '';
return freqs.map((f) => _noteNameFromFreq(f, useFlats)).join(' ');
}
export function parseRawTuningOffsets(value) {
if (Array.isArray(value) && value.length) return value;
if (!value || typeof value !== 'string') return null;
const s = value.trim();
if (/^-?\d+(?: -?\d+)+$/.test(s)) {
return s.split(/\s+/).map((n) => Number(n));
}
if (/^-?\d+(?:,-?\d+)+$/.test(s)) {
return s.split(',').map((n) => Number(n));
}
return null;
}
+3 -1
View File
@@ -8,7 +8,9 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
// The highway string-colour manager was carved out of app.js into its own
// module (R3a).
const appJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-colors.js');
function extractBlock(src, signature) {
const start = src.indexOf(signature);
+4 -2
View File
@@ -13,7 +13,9 @@ const path = require('node:path');
const highwayJs = path.join(__dirname, '..', '..', 'static', 'highway.js');
const highway3dJs = path.join(__dirname, '..', '..', 'plugins', 'highway_3d', 'screen.js');
const appJs = path.join(__dirname, '..', '..', 'static', 'app.js');
// The highway string-colour manager was carved out of app.js into its own
// module (R3a).
const appJs = path.join(__dirname, '..', '..', 'static', 'js', 'highway-colors.js');
// Brace-balanced extraction (same helper shape as highway_note_state.test.js).
function extractBlock(src, signature) {
@@ -86,7 +88,7 @@ test('3D gem-body gradients follow the active palette (not hardcoded)', () => {
assert.match(apply, /_recolorGemGradients\(\)/, '_applyPaletteToMaterials must recolor gems on palette change');
});
// ── Core color manager (static/app.js) ────────────────────────────────────
// ── Core color manager (static/js/highway-colors.js) ──────────────────────
test('app.js color manager name-maps to both highways, with identity no-op + builtin guard', () => {
const src = fs.readFileSync(appJs, 'utf8');
+113
View File
@@ -0,0 +1,113 @@
// The host-seam contract: the hooks the modules USE must be exactly the hooks
// app.js WIRES.
//
// This is the test that makes the seam safe. static/js/host.js already throws at
// runtime when an unwired hook is read — but a runtime throw only fires if the
// broken path actually executes, and the entire danger of a host seam is the paths
// that DON'T run in a smoke test. That is not hypothetical: the plugin loader's
// seam defaulted a hook to `() => {}`, and a dropped wiring line would have left
// the viz picker silently not refreshing with no test, boot check, or bot noticing.
//
// So this closes it statically. Rename a hook in app.js, drop a line from the
// configureHost({…}) call, or typo a `host.foo` in a module, and CI fails — on a
// path nobody ever ran.
//
// It is deliberately symmetric:
// * used but not wired -> a latent crash (host.js would throw at runtime)
// * wired but not used -> dead weight, and usually the fossil of a rename
// Both fail.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.join(__dirname, '..', '..');
const APP_JS = path.join(ROOT, 'static', 'app.js');
const JS_DIR = path.join(ROOT, 'static', 'js');
// Strip comments, so prose about `host.foo` in a header block is not read as a call
// site.
//
// NOTHING ELSE. An earlier version also tried to strip import statements (to stop
// `from './host.js'` reading as a hook called `js`) and its `[\s\S]*?` spanned lines
// and silently ate 14,000 characters of the file — including, in the bite test, the
// very drift it was supposed to catch. A guard with a hole in it is worse than no
// guard, because you trust it. The `host.js` path is excluded far more cheaply,
// below, by refusing a match followed by a quote.
function scrub(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/^\s*\/\/[^\n]*$/gm, '');
}
// `host.<name>` — but not `host.js'` from the `from './host.js'` import path, which is
// the one string in these files that looks like a hook and isn't.
//
// The trailing class must forbid a WORD character as well as a quote. With only
// `(?!['"])`, `host.js'` fails on `js` (a quote follows), then BACKTRACKS to `j` —
// where the next char is `s`, not a quote — and happily reports a hook called `j`.
// Forbidding `[\w$]` too leaves it nowhere to backtrack to.
const HOOK_RE = /(?<![\w$.])host\.([A-Za-z_$][\w$]*)(?![\w$'"])/g;
/** Every `host.<name>` referenced by a carved module. */
function hooksUsed() {
const used = new Map(); // name -> [files]
for (const file of fs.readdirSync(JS_DIR)) {
if (!file.endsWith('.js') || file === 'host.js') continue;
const raw = fs.readFileSync(path.join(JS_DIR, file), 'utf8');
if (!/from\s+'\.\/host\.js'/.test(raw)) continue;
for (const m of scrub(raw).matchAll(HOOK_RE)) {
if (!used.has(m[1])) used.set(m[1], []);
used.get(m[1]).push(file);
}
}
return used;
}
/** Every hook app.js passes to configureHost({ … }). */
function hooksWired() {
const src = scrub(fs.readFileSync(APP_JS, 'utf8'));
// NB the closing brace is INDENTED (the call sits inside the boot function), so
// anchoring on `\n});` at column 0 runs straight past it and swallows the next
// object literal in the file — which is how this first read 77 "hooks", most of
// them app.js's window contract.
const call = src.match(/configureHost\(\{([\s\S]*?)\n\s*\}\);/);
if (!call) return null; // no seam wired yet — fine until there is one
const wired = new Set();
for (const m of call[1].matchAll(/(?:^|,)\s*([A-Za-z_$][\w$]*)\s*(?=[,:}]|$)/gm)) {
wired.add(m[1]);
}
return wired;
}
test('every host.<hook> a module uses is wired by app.js', () => {
const used = hooksUsed();
if (used.size === 0) return; // no consumers yet
const wired = hooksWired();
assert.ok(wired, 'modules import ./host.js but app.js never calls configureHost({ … })');
const missing = [...used.keys()]
.filter((h) => !wired.has(h))
.map((h) => `${h} (used in ${used.get(h).join(', ')})`);
assert.deepEqual(
missing, [],
'these hooks are read by a module but never wired by app.js — they would throw at runtime, '
+ 'on whatever path happens to reach them',
);
});
test('every hook app.js wires is actually used by a module', () => {
const wired = hooksWired();
if (!wired || wired.size === 0) return;
const used = hooksUsed();
const unused = [...wired].filter((h) => !used.has(h));
assert.deepEqual(
unused, [],
'these hooks are wired by app.js but no module reads them — dead weight, and usually '
+ 'the fossil of a rename that left the other half behind',
);
});
+5 -1
View File
@@ -84,7 +84,11 @@ function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows A
json: () => Promise.resolve({ path: '/local/song.ogg' }),
}),
document: { hidden: false },
isPlaying: true,
// `isPlaying` moved onto the shared player-state container so a carved module
// can WRITE it (an imported binding is read-only). The sliced code now reads and
// writes S.isPlaying, so the sandbox provides the same container — the
// assertions below are unchanged.
S: { isPlaying: true, lastAudioTime: 0 },
audio,
jucePlayer,
__calls: calls,
+44 -12
View File
@@ -11,11 +11,16 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
// The A-B loop was carved out of app.js into its own module (R3a). The
// window.feedBack API surface it is published through stayed in app.js.
const LOOPS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'loops.js');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
function extractFunction(src, signature) {
function extractFunction(rawSrc, signature) {
// loops.js is an ES module; the vm sandbox evaluates plain script text.
const src = rawSrc.replace(/^export /gm, '');
const start = src.indexOf(signature);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in static/js/loops.js`);
let scan = start + signature.length;
if (src[scan] === '(') {
let parenDepth = 1;
@@ -44,10 +49,18 @@ function buildSandbox() {
const seekCalls = [];
const sectionPracticeModeCalls = [];
const transportEvents = [];
// clearLoop() used to zero section-practice's three selection scalars by hand.
// They now live in static/js/section-practice.js, which owns them, so clearLoop
// calls its exported resetSelection() instead. This is a SPY, not a stub — the
// test below still asserts the reset happens, it just asserts it through the
// seam rather than by reaching into someone else's state.
const resetSelectionCalls = [];
const sandbox = {
seekCalls,
sectionPracticeModeCalls,
transportEvents,
resetSelectionCalls,
resetSelection: () => resetSelectionCalls.push(true),
// Mutable state (declared as `var` in eval prelude so it lives on
// the sandbox global and the extracted functions can read/write).
// The actual values are set below.
@@ -81,6 +94,7 @@ function buildSandbox() {
// updateLoopUI references formatTime for the label; we don't
// assert on the label text in these tests, so a stub is enough.
formatTime: (s) => String(s),
_updateEditRegionBtn: () => {},
window: {
feedBack: {
playback: {
@@ -89,6 +103,19 @@ function buildSandbox() {
},
},
};
// The loop module reaches back into app.js through the host seam
// (static/js/host.js), so the extracted bodies call host._audioSeek(),
// host._audioTime(), and so on. Point the seam at the SAME spies the sandbox
// already had: the assertions below are unchanged, they just travel through the
// indirection the real code now uses.
sandbox.host = {
_audioSeek: (...a) => sandbox._audioSeek(...a),
_audioTime: () => sandbox._audioTime(),
formatTime: (...a) => sandbox.formatTime(...a),
_updateEditRegionBtn: () => sandbox._updateEditRegionBtn(),
currentFilename: () => 'test-song.sloppak',
startCountIn: () => {},
};
vm.createContext(sandbox);
return sandbox;
}
@@ -121,7 +148,7 @@ function loadFunctions(sandbox, src) {
}
test('setLoop mutates loopA/loopB and seeks to A', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
@@ -137,7 +164,7 @@ test('setLoop mutates loopA/loopB and seeks to A', async () => {
test('setLoop returns false and leaves loopA/loopB untouched on cancelled seek', async () => {
// Plugin-facing contract: cancelled seek (teardown gen bump) returns
// false; the loop is NOT armed.
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
sandbox._audioSeek = () => Promise.resolve({ completed: false, from: NaN, to: NaN });
loadFunctions(sandbox, src);
@@ -154,7 +181,7 @@ test('setLoop returns false and leaves loopA/loopB untouched on cancelled seek',
test('setLoop returns false and leaves loopA/loopB untouched on off-target landing', async () => {
// JUCE rollback / HTML5 clamp: completed:true but to drifts > 50ms
// from the requested a. The loop is NOT armed.
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
sandbox._audioSeek = (s) => Promise.resolve({ completed: true, from: 0, to: s + 0.5 });
loadFunctions(sandbox, src);
@@ -172,7 +199,7 @@ test('setLoop coerces string inputs (parseFloat-style)', async () => {
// loadSavedLoop passes parseFloat(dataset.start) — but the dataset
// values may already be strings. Number() coercion in setLoop must
// accept finite numeric strings.
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
@@ -183,7 +210,7 @@ test('setLoop coerces string inputs (parseFloat-style)', async () => {
});
test('setLoop rejects non-finite inputs', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
@@ -193,7 +220,7 @@ test('setLoop rejects non-finite inputs', async () => {
});
test('setLoop rejects b <= a', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
@@ -201,8 +228,8 @@ test('setLoop rejects b <= a', async () => {
await assert.rejects(() => sandbox.__setLoop(10, 5), /b > a/);
});
test('clearLoop resets loopA/loopB to null', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
test('clearLoop resets loopA/loopB to null (and asks section-practice to drop its selection)', async () => {
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
@@ -211,6 +238,11 @@ test('clearLoop resets loopA/loopB to null', async () => {
const { loopA, loopB } = sandbox.__getLoop();
assert.equal(loopA, null);
assert.equal(loopB, null);
assert.equal(
sandbox.resetSelectionCalls.length, 1,
'clearLoop must ask section-practice to drop its selection (it used to zero the '
+ 'scalars by hand; the module owns them now)',
);
assert.equal(sandbox.sectionPracticeModeCalls.length, 1);
assert.equal(sandbox.sectionPracticeModeCalls[0].on, false);
// Field-wise: vm-context objects break deepStrictEqual across realms.
@@ -218,7 +250,7 @@ test('clearLoop resets loopA/loopB to null', async () => {
});
test('loop helpers emit transport snapshots by default and can suppress adapter echoes', async () => {
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const sandbox = buildSandbox();
loadFunctions(sandbox, src);
@@ -256,7 +288,7 @@ test('loadSavedLoop funnels through setLoop (no duplicated UI mutation)', () =>
// re-implementing the loopA/loopB assignment. Catches a future drift
// where someone "fixes" loadSavedLoop and forgets to keep setLoop in
// sync.
const src = fs.readFileSync(APP_JS, 'utf8');
const src = fs.readFileSync(LOOPS_JS, 'utf8');
const fn = extractFunction(src, 'async function loadSavedLoop(');
assert.match(fn, /await\s+setLoop\(/, 'loadSavedLoop must call setLoop');
// The pre-refactor body assigned loopA = parseFloat(...) directly;
+6 -6
View File
@@ -55,8 +55,10 @@ function buildSandbox() {
loopA: 10,
loopB: 20,
_countingIn: false,
isPlaying: false,
lastAudioTime: 0,
// isPlaying / lastAudioTime moved onto the shared player-state container
// (static/js/player-state.js) so a carved module can WRITE them — an imported
// binding is read-only. Same values, same assertions, one indirection.
S: { isPlaying: false, lastAudioTime: 0 },
// Browser-ish globals.
performance: { now: () => Date.now() },
@@ -135,8 +137,7 @@ test('loop:restart fires once when wrap path runs', async () => {
var _countInGen = 0;
var _countInTimer = null;
var _countInRaf = 0;
var isPlaying = false;
var lastAudioTime = 0;
var S = { isPlaying: false, lastAudioTime: 0 };
${startCountInSrc}
globalThis.__startCountIn = startCountIn;
`;
@@ -180,8 +181,7 @@ test('loop:restart aborts when seek lands far from loopA (JUCE rollback)', async
var _countInGen = 0;
var _countInTimer = null;
var _countInRaf = 0;
var isPlaying = false;
var lastAudioTime = 0;
var S = { isPlaying: false, lastAudioTime: 0 };
${startCountInSrc}
globalThis.__startCountIn = startCountIn;
globalThis.__getCountingIn = () => _countingIn;
+6 -3
View File
@@ -29,8 +29,11 @@ async function runTogglePlayRejecting({ rerouteInProgress }) {
const buttonStates = [];
const sandbox = {
console: { log() {}, warn() {}, error() {} },
// not-playing -> togglePlay takes the HTML5 play branch
isPlaying: false,
// not-playing -> togglePlay takes the HTML5 play branch.
// isPlaying / lastAudioTime moved onto the shared player-state container
// (static/js/player-state.js) so a carved module can WRITE them — an imported
// binding is read-only. Same values, same assertions, one indirection.
S: { isPlaying: false, lastAudioTime: 0 },
_audioSeekGen: 0,
_playAttemptGen: 0,
setPlayButtonState(v) { buttonStates.push(v); },
@@ -51,7 +54,7 @@ async function runTogglePlayRejecting({ rerouteInProgress }) {
vm.createContext(sandbox);
vm.runInContext(TOGGLE_PLAY_SRC, sandbox, { filename: 'app.js#togglePlay' });
await vm.runInContext('togglePlay()', sandbox);
return { buttonStates, isPlaying: sandbox.isPlaying };
return { buttonStates, isPlaying: sandbox.S.isPlaying };
}
test('reroute-aborted play() leaves the button on Pause (isPlaying stays true)', async () => {
+4 -1
View File
@@ -84,5 +84,8 @@ test('playback adapter suppresses duplicate HTML5 pause events before emitting c
const src = fs.readFileSync(APP_JS, 'utf8');
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
assert.match(fn, /if \(!window\._juceMode && wasPlaying\) \{\s*isPlaying = false;\s*window\.feedBack\.isPlaying = false;\s*audio\.pause\(\);\s*_markPlaybackPaused\(\);\s*\}/);
// isPlaying moved onto the shared player-state container so a carved module can
// WRITE it (an imported binding is read-only). window.feedBack.isPlaying — the
// public mirror — is unchanged.
assert.match(fn, /if \(!window\._juceMode && wasPlaying\) \{\s*S\.isPlaying = false;\s*window\.feedBack\.isPlaying = false;\s*audio\.pause\(\);\s*_markPlaybackPaused\(\);\s*\}/);
});
+3 -2
View File
@@ -15,9 +15,10 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
// _installSectionPracticeDismiss was carved out of app.js into its own module (R3a).
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'js', 'section-practice.js'), 'utf8');
const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/);
assert.ok(m, '_installSectionPracticeDismiss() not found in static/app.js');
assert.ok(m, '_installSectionPracticeDismiss() not found in static/js/section-practice.js');
const body = m[0];
test('the outside-click dismiss binds in the CAPTURE phase', () => {
+5 -3
View File
@@ -19,7 +19,9 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
const sandbox = {
loopA,
loopB,
isPlaying,
// isPlaying moved onto the shared player-state container so a carved module can
// WRITE it (an imported binding is read-only). Same value, same assertions.
S: { isPlaying, lastAudioTime: 0 },
__cancelCountInCalls: 0,
__seekCalls: [],
__startCountInCalls: [],
@@ -42,7 +44,7 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
},
__togglePlay() {
sandbox.__togglePlayCalls++;
sandbox.isPlaying = true;
sandbox.S.isPlaying = true;
return Promise.resolve();
},
};
@@ -53,7 +55,7 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
function loadRestart(sandbox, src, { audioSeekImpl } = {}) {
const restartSrc = extractFunction(src, 'async function restartCurrentSong(');
const code = `
var isPlaying = ${sandbox.isPlaying};
var S = { isPlaying: ${sandbox.S.isPlaying}, lastAudioTime: 0 };
function _cancelCountIn() { __cancelCountInCalls++; }
async function _audioSeek(s, reason) {
return (${audioSeekImpl || '__audioSeek'})(s, reason);
+4 -1
View File
@@ -77,7 +77,10 @@ function loadFunctions(sandbox, src) {
// _audioSeek now syncs the jump-fix tracker so far seeks don't
// trigger an immediate revert; declare it here so the sandbox
// assignment lands on a real binding rather than an implicit global.
let lastAudioTime = 0;
// lastAudioTime moved onto the shared player-state container
// (static/js/player-state.js) so a carved module can WRITE it — an imported
// binding is read-only. The sliced code writes S.lastAudioTime now.
let S = { isPlaying: false, lastAudioTime: 0 };
// _audioSeek wraps jucePlayer.seek in a timeout race; pull in the
// helper + constant. Tests can override jucePlayer.seek to vary
// behavior; the timeout (2 s) is well above any test setTimeout.
+4 -1
View File
@@ -143,7 +143,10 @@ function loadPlaySong(sandbox) {
: '';
const code = `
var artAbortController = null;
var isPlaying = true;
// isPlaying moved onto the shared player-state container so a carved module can
// WRITE it (an imported binding is read-only). NB window.feedBack.isPlaying — the
// public mirror stubbed above — is a different thing and is unchanged.
var S = { isPlaying: true, lastAudioTime: 0 };
var currentFilename = null;
var _playerOriginScreen = null;
var _pendingAutostart = false;
+9 -6
View File
@@ -7,20 +7,23 @@ const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The tuning-display helpers were carved out of app.js into their own module (R3a);
// the autoplay-gate test below still reads app.js.
const TUNING_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
const TUNING_UTILS_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'tuning-utils.js');
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
function loadTuningHelpers() {
const src = fs.readFileSync(APP_JS, 'utf8');
const start = src.indexOf('function isBassArrangement(');
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
const end = src.indexOf(endMarker);
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
const src = fs.readFileSync(TUNING_JS, 'utf8');
// The module is nothing BUT the tuning helpers now, so there is no block to
// slice out — take it whole. `export` is stripped so the vm sandbox can still
// evaluate it as a plain script (the window.* contract lives in app.js).
const body = src.replace(/^export /gm, '');
const sandbox = { window: { feedBack: {} }, exports: {} };
vm.createContext(sandbox);
vm.runInContext(
src.slice(start, end + endMarker.length),
body,
sandbox
);
return sandbox.window.feedBack;
+2 -1
View File
@@ -6,7 +6,8 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The tuning-display helpers were carved out of app.js into their own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
+7 -6
View File
@@ -6,7 +6,8 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The tuning-display helpers were carved out of app.js into their own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
const HIGHWAY_JS = path.join(__dirname, '..', '..', 'static', 'highway.js');
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
@@ -14,14 +15,14 @@ const V3_HTML = path.join(__dirname, '..', '..', 'static', 'v3', 'index.html');
function loadTuningHelpers() {
const src = fs.readFileSync(APP_JS, 'utf8');
const start = src.indexOf('function isBassArrangement(');
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
const end = src.indexOf(endMarker);
if (start === -1 || end === -1) throw new Error('tuning helper block not found in app.js');
// The module is nothing BUT the tuning helpers now, so there is no block to
// slice out — take it whole. `export` is stripped so the vm sandbox can still
// evaluate it as a plain script (the window.* contract lives in app.js).
const body = src.replace(/^export /gm, '');
const sandbox = { window: { feedBack: {} }, exports: {} };
vm.createContext(sandbox);
vm.runInContext(
src.slice(start, end + endMarker.length) + '\n'
body + '\n'
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
+ 'exports.displayTuningTargetDetails = displayTuningTargetDetails;\n'
+ 'exports.isBassArrangement = isBassArrangement;\n'
+7 -6
View File
@@ -7,7 +7,8 @@ const path = require('node:path');
const vm = require('node:vm');
const SONGS_JS = path.join(__dirname, '..', '..', 'static', 'v3', 'songs.js');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
// The tuning-display helpers were carved out of app.js into their own module (R3a).
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'tuning-display.js');
function extractBlock(src, startMarker) {
const start = src.indexOf(startMarker);
@@ -27,14 +28,14 @@ function extractBlock(src, startMarker) {
function loadTuningHelpers() {
const src = fs.readFileSync(APP_JS, 'utf8');
const start = src.indexOf('function _looksLikeRawTuningOffsets(');
const endMarker = 'window.feedBack.parseRawTuningOffsets = parseRawTuningOffsets;';
const end = src.indexOf(endMarker);
if (start === -1 || end === -1) throw new Error('tuning helpers not found');
// The module is nothing BUT the tuning helpers now, so there is no block to
// slice out — take it whole. `export` is stripped so the vm sandbox can still
// evaluate it as a plain script (the window.* contract lives in app.js).
const body = src.replace(/^export /gm, '');
const sandbox = { window: { feedBack: {} }, exports: {} };
vm.createContext(sandbox);
vm.runInContext(
src.slice(start, end + endMarker.length) + '\n'
body + '\n'
+ 'exports.displayTuningName = displayTuningName;\n'
+ 'exports.displayTuningTargets = displayTuningTargets;\n'
+ 'exports.parseRawTuningOffsets = parseRawTuningOffsets;',
+3 -1
View File
@@ -186,7 +186,9 @@ def test_app_event_bus_dispatches_locally_and_preserves_juce_stop_state():
source = (ROOT / "static" / "app.js").read_text(encoding="utf-8")
assert "this.dispatchEvent(new CustomEvent(event, { detail }))" in source
assert "const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || isPlaying" in source
# `isPlaying` moved onto the shared player-state container (static/js/player-state.js)
# so a carved module can WRITE it — an imported binding is read-only.
assert "const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || S.isPlaying" in source
assert "sm.emit('song:resume', payload)" in source
assert "window.feedBack.emit('song:resume', payload)" in source