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
19 changed files with 2211 additions and 1634 deletions
+175 -1603
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');
+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);
}
+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;
+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