mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-12 08:38:30 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1018533a6e |
+3214
-207
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
||||
// 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');
|
||||
@@ -1,389 +0,0 @@
|
||||
// Count-in — the 1-2-3-4 click before playback, plus the song-credits overlay that
|
||||
// shares its lifecycle and timers.
|
||||
//
|
||||
// The third slice out of app.js's strongly-connected core, and the first that had to
|
||||
// WRITE shared state rather than just read it. It starts and stops playback, so it sets
|
||||
// `isPlaying` and `lastAudioTime`. An imported binding is read-only — `isPlaying = true`
|
||||
// throws — which is exactly why those two scalars were lifted onto the container in
|
||||
// ./player-state.js. Every earlier slice only READ what it shared, so a getter hook
|
||||
// sufficed; this one could not.
|
||||
//
|
||||
// It imports the loop module directly (setLoop / loopA / loopB — a count-in that starts
|
||||
// inside an A-B loop must begin at A). Nothing imports count-in back: app.js and
|
||||
// section-practice both reach it through the host seam, so the graph stays acyclic.
|
||||
//
|
||||
// app.js's autoplay path used to reach IN and set the credits timers itself. It cannot
|
||||
// now, and it should not have to — so the module exports the OPERATIONS instead
|
||||
// (armCreditsHideOnPlay, scheduleCreditsHide, holdCreditsThen, isCountingIn) and owns
|
||||
// its own timer invariants. Same reason section-practice grew resetSelection().
|
||||
//
|
||||
// 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 { audio } from './audio-el.js';
|
||||
import { host } from './host.js';
|
||||
import { loopA, loopB, setLoop } from './loops.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
// ── Count-in click sound (Web Audio API) ────────────────────────────────
|
||||
let _audioCtx = null;
|
||||
export function playClick(high = false) {
|
||||
if (!_audioCtx) _audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = _audioCtx.createOscillator();
|
||||
const gain = _audioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(_audioCtx.destination);
|
||||
osc.frequency.value = high ? 1200 : 800;
|
||||
osc.type = 'sine';
|
||||
gain.gain.setValueAtTime(0.5, _audioCtx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, _audioCtx.currentTime + 0.08);
|
||||
osc.start(_audioCtx.currentTime);
|
||||
osc.stop(_audioCtx.currentTime + 0.08);
|
||||
}
|
||||
|
||||
let _countingIn = false;
|
||||
let _countOverlay = null;
|
||||
// Generation token so teardown can cancel an in-progress count-in. Each
|
||||
// startCountIn() captures the gen at entry; rewindStep, the loop-wrap
|
||||
// then-callback, and beginCount's tick all bail when their captured gen
|
||||
// no longer matches. Bumped by _cancelCountIn().
|
||||
let _countInGen = 0;
|
||||
let _countInTimer = null;
|
||||
let _countInRaf = 0;
|
||||
// Feedpak credits overlay (manifest `authors:`, spec §5.4): shown on the
|
||||
// highway when a song is loaded, alongside the count-in. Torn down together
|
||||
// with the count-in via _cancelCountIn().
|
||||
let _creditsOverlay = null;
|
||||
let _creditsTimer = null;
|
||||
let _creditsHideOnPlay = null;
|
||||
let _creditsMaxTimer = null;
|
||||
const _CREDITS_HOLD_MS = 3000;
|
||||
// Backstop: the overlay's primary dismiss is song:play, but playback can fail
|
||||
// to start without emitting it (HTML5 autoplay rejection, JUCE start failure,
|
||||
// a count-in handoff that never plays). This hard cap guarantees the credits
|
||||
// never linger over the highway. Generous enough to outlast a normal count-in.
|
||||
const _CREDITS_MAX_MS = 12000;
|
||||
export function _cancelCountIn() {
|
||||
_countInGen++;
|
||||
_countingIn = false;
|
||||
hideCountOverlay();
|
||||
// The credits overlay rides the count-in lifecycle (and its no-count-in
|
||||
// hold timer), so a teardown — leaving the player, loading another song —
|
||||
// must clear it too, or it lingers on the next screen.
|
||||
hideSongCreditsOverlay();
|
||||
if (_countInTimer) { clearTimeout(_countInTimer); _countInTimer = null; }
|
||||
if (_countInRaf) { cancelAnimationFrame(_countInRaf); _countInRaf = 0; }
|
||||
}
|
||||
|
||||
export function showCountOverlay(n) {
|
||||
if (!_countOverlay) {
|
||||
_countOverlay = document.createElement('div');
|
||||
_countOverlay.className = 'fixed inset-0 z-[100] flex items-center justify-center pointer-events-none';
|
||||
document.body.appendChild(_countOverlay);
|
||||
}
|
||||
_countOverlay.innerHTML = `<span class="text-9xl font-black text-white/30">${n}</span>`;
|
||||
}
|
||||
|
||||
export function hideCountOverlay() {
|
||||
if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; }
|
||||
}
|
||||
|
||||
// Map a feedpak author `role` to a friendly "<verb> by" credit line. The
|
||||
// recommended vocabulary is from feedpak spec §5.4; unknown roles are
|
||||
// title-cased ("foo" → "Foo by"); a missing role shows the bare name.
|
||||
const _CREDIT_ROLE_VERBS = {
|
||||
charter: 'Charted by',
|
||||
transcriber: 'Transcribed by',
|
||||
arranger: 'Arranged by',
|
||||
editor: 'Edited by',
|
||||
mixer: 'Mixed by',
|
||||
engineer: 'Engineered by',
|
||||
proofreader: 'Proofread by',
|
||||
};
|
||||
|
||||
function _creditLineLabel(role) {
|
||||
if (!role) return '';
|
||||
const key = String(role).trim().toLowerCase();
|
||||
if (_CREDIT_ROLE_VERBS[key]) return _CREDIT_ROLE_VERBS[key];
|
||||
return key.charAt(0).toUpperCase() + key.slice(1) + ' by';
|
||||
}
|
||||
|
||||
// Show the feedpak contributor credits over the highway. `authors` is the
|
||||
// sanitized [{name, role}] list from window.feedBack.currentSong.authors.
|
||||
// Anchored to the lower third (bottom-center) so it never collides with the
|
||||
// vertically-centered count-in number, and pointer-events-none so it never
|
||||
// intercepts clicks. No-op when there are no contributors to show.
|
||||
export function showSongCreditsOverlay(authors) {
|
||||
if (!Array.isArray(authors) || authors.length === 0) return;
|
||||
if (!_creditsOverlay) {
|
||||
_creditsOverlay = document.createElement('div');
|
||||
_creditsOverlay.className = 'song-credits-overlay';
|
||||
document.body.appendChild(_creditsOverlay);
|
||||
}
|
||||
// Build via DOM + textContent — author names are untrusted pack data and
|
||||
// must never be interpolated as HTML.
|
||||
_creditsOverlay.replaceChildren();
|
||||
const card = document.createElement('div');
|
||||
card.className = 'song-credits-card';
|
||||
|
||||
const eyebrow = document.createElement('div');
|
||||
eyebrow.className = 'song-credits-eyebrow';
|
||||
eyebrow.textContent = 'Credits';
|
||||
card.appendChild(eyebrow);
|
||||
|
||||
const title = (window.feedBack && window.feedBack.currentSong
|
||||
&& window.feedBack.currentSong.title) || '';
|
||||
if (title) {
|
||||
const heading = document.createElement('div');
|
||||
heading.className = 'song-credits-heading';
|
||||
heading.textContent = title;
|
||||
card.appendChild(heading);
|
||||
}
|
||||
|
||||
for (const a of authors) {
|
||||
if (!a || !a.name) continue;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'song-credits-line';
|
||||
const label = _creditLineLabel(a.role);
|
||||
if (label) {
|
||||
const lab = document.createElement('span');
|
||||
lab.className = 'song-credits-role';
|
||||
lab.textContent = label + ' ';
|
||||
row.appendChild(lab);
|
||||
}
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'song-credits-name';
|
||||
nm.textContent = a.name;
|
||||
row.appendChild(nm);
|
||||
card.appendChild(row);
|
||||
}
|
||||
_creditsOverlay.appendChild(card);
|
||||
// Arm the backstop so the overlay self-clears even if playback never starts
|
||||
// / never emits song:play. song:play (or any teardown) clears it earlier.
|
||||
if (_creditsMaxTimer) clearTimeout(_creditsMaxTimer);
|
||||
_creditsMaxTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_MAX_MS);
|
||||
}
|
||||
|
||||
export function hideSongCreditsOverlay() {
|
||||
if (_creditsTimer) { clearTimeout(_creditsTimer); _creditsTimer = null; }
|
||||
if (_creditsMaxTimer) { clearTimeout(_creditsMaxTimer); _creditsMaxTimer = null; }
|
||||
if (_creditsHideOnPlay) {
|
||||
window.feedBack.off('song:play', _creditsHideOnPlay);
|
||||
_creditsHideOnPlay = null;
|
||||
}
|
||||
if (_creditsOverlay) { _creditsOverlay.remove(); _creditsOverlay = null; }
|
||||
}
|
||||
|
||||
export async function startCountIn(opts = {}) {
|
||||
if (_countingIn) return;
|
||||
_countingIn = true;
|
||||
// Snapshot the current gen so every delayed callback (rewind frames,
|
||||
// post-seek then, count-in ticks, post-count play) can bail if a
|
||||
// teardown bumped the gen mid-flight via _cancelCountIn().
|
||||
const gen = _countInGen;
|
||||
const immediate = !!opts.immediate;
|
||||
if (window._juceMode) {
|
||||
await host.jucePlayer().pause().catch((err) => console.error('[app] host.jucePlayer().pause error in count-in:', err));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
if (gen !== _countInGen) return; // teardown during pause
|
||||
|
||||
// Section-practice entry: already at loop A after setLoop(); skip the
|
||||
// B→A rewind animation used on loop wrap and go straight to clicks.
|
||||
if (immediate) {
|
||||
if (loopA === null || loopB === null) {
|
||||
_countingIn = false;
|
||||
return;
|
||||
}
|
||||
S.lastAudioTime = loopA;
|
||||
highway.setTime(loopA);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
|
||||
}
|
||||
beginCount();
|
||||
return;
|
||||
}
|
||||
|
||||
// Rewind animation: sweep highway time from B to A
|
||||
const rewindDuration = 400; // ms
|
||||
const rewindStart = performance.now();
|
||||
const fromTime = loopB;
|
||||
const toTime = loopA;
|
||||
|
||||
function rewindStep(now) {
|
||||
if (gen !== _countInGen) return; // teardown mid-rewind
|
||||
const elapsed = now - rewindStart;
|
||||
const t = Math.min(elapsed / rewindDuration, 1);
|
||||
// Ease out quad
|
||||
const eased = 1 - (1 - t) * (1 - t);
|
||||
const currentT = fromTime + (toTime - fromTime) * eased;
|
||||
highway.setTime(currentT);
|
||||
if (t < 1) {
|
||||
_countInRaf = requestAnimationFrame(rewindStep);
|
||||
} else {
|
||||
_countInRaf = 0;
|
||||
// Rewind done — set final position and start count.
|
||||
// Await the JUCE seek so the engine has repositioned before
|
||||
// we start the click track (HTML5 path is synchronous).
|
||||
host._audioSeek(loopA, 'loop-wrap').then((r) => {
|
||||
if (gen !== _countInGen) return; // teardown during seek
|
||||
// Abort the loop restart in two cases:
|
||||
// 1. Cancelled (player torn down): don't beginCount on a
|
||||
// new session.
|
||||
// 2. Off-target landing (JUCE rollback / clamp far from
|
||||
// loopA): proceeding would emit loop:restart and start
|
||||
// a count-in from the wrong position. Audio is at
|
||||
// r.from / r.to, which is not where the loop wants to
|
||||
// resume — better to drop this iteration than play out
|
||||
// of sync.
|
||||
// 50 ms tolerance: well within JUCE's normal seek precision
|
||||
// but tight enough to catch a real rollback or no-op.
|
||||
if (!r.completed || Math.abs(r.to - loopA) > 0.05) {
|
||||
// startCountIn paused audio at entry but left isPlaying
|
||||
// alone — beginCount would have set it on resume. On
|
||||
// abort, sync the transport: audio is paused, so
|
||||
// isPlaying must reflect that and the button + plugin
|
||||
// host must agree.
|
||||
_countingIn = false;
|
||||
if (S.isPlaying) {
|
||||
S.isPlaying = false;
|
||||
host.setPlayButtonState(false);
|
||||
if (window.feedBack) {
|
||||
window.feedBack.isPlaying = false;
|
||||
window.feedBack.emit('song:pause', host._songEventPayload());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Use the verified post-seek clock for the chart so audio
|
||||
// and chart stay in sync if JUCE clamped to slightly
|
||||
// before/after loopA. The loop:restart event keeps `time:
|
||||
// loopA` because subscribers treat that as the semantic
|
||||
// marker for "new iteration starts at A", not the actual
|
||||
// audio position.
|
||||
S.lastAudioTime = r.to;
|
||||
highway.setTime(r.to);
|
||||
window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA });
|
||||
beginCount();
|
||||
});
|
||||
}
|
||||
}
|
||||
_countInRaf = requestAnimationFrame(rewindStep);
|
||||
|
||||
function beginCount() {
|
||||
const bpm = highway.getBPM(loopA);
|
||||
const beatInterval = 60 / bpm;
|
||||
let count = 0;
|
||||
|
||||
function tick() {
|
||||
if (gen !== _countInGen) return; // teardown mid-count
|
||||
count++;
|
||||
if (count > 4) {
|
||||
hideCountOverlay();
|
||||
_countingIn = false;
|
||||
if (window._juceMode) {
|
||||
host.jucePlayer().play().then((started) => {
|
||||
if (gen !== _countInGen) return; // teardown during play start
|
||||
if (!started) return;
|
||||
S.isPlaying = true;
|
||||
host.setPlayButtonState(true);
|
||||
window.feedBack.isPlaying = true;
|
||||
const payload = host._songEventPayload();
|
||||
window.feedBack.emit('song:play', payload);
|
||||
window.feedBack.emit('song:resume', payload);
|
||||
}).catch((err) => console.error('[app] host.jucePlayer().play error:', err));
|
||||
} else {
|
||||
audio.play().then(() => {
|
||||
if (gen !== _countInGen) return;
|
||||
S.isPlaying = true;
|
||||
host.setPlayButtonState(true);
|
||||
}).catch((err) => {
|
||||
if (gen !== _countInGen) return;
|
||||
// An engine reroute's deliberate pause aborts this play()
|
||||
// while playback continues on JUCE — don't reset the
|
||||
// button (mirrors the togglePlay guard).
|
||||
if (window._juceRerouteInProgress) return;
|
||||
// Same rationale as togglePlay: don't claim playback
|
||||
// started if the Promise rejected.
|
||||
console.error('[app] audio.play() rejected after count-in:', err);
|
||||
S.isPlaying = false;
|
||||
host.setPlayButtonState(false);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
showCountOverlay(count);
|
||||
playClick(count === 1);
|
||||
_countInTimer = setTimeout(tick, beatInterval * 1000);
|
||||
}
|
||||
_countInTimer = setTimeout(tick, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// Start-of-song count-in: a 4-beat click before playback begins, gated by the
|
||||
// "Countdown before song" setting (Gameplay tab). Mirrors the loop count-in's
|
||||
// overlay + click + gen-token cancellation, but counts from the song's current
|
||||
// position (0 at song start) with no loop A/B rewind. startCountIn() is loop-
|
||||
// coupled (early-returns when loopA/loopB are null), so this is a sibling
|
||||
// rather than an overload. Hands off to togglePlay() once the count completes.
|
||||
export async function startSongCountIn() {
|
||||
if (_countingIn) return;
|
||||
_countingIn = true;
|
||||
// Snapshot the gen so a teardown (showScreen/playSong calls _cancelCountIn)
|
||||
// bumps it and every delayed callback below bails.
|
||||
const gen = _countInGen;
|
||||
if (window._juceMode) {
|
||||
await host.jucePlayer().pause().catch((err) => console.error('[app] host.jucePlayer().pause error in song count-in:', err));
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
if (gen !== _countInGen) return; // teardown during pause
|
||||
const startT = S.lastAudioTime || 0;
|
||||
let bpm = highway.getBPM(startT);
|
||||
// Pre-chart / malformed-tempo fallback: 4 beats at 120 BPM (500 ms each).
|
||||
if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120;
|
||||
const beatInterval = 60 / bpm;
|
||||
let count = 0;
|
||||
function tick() {
|
||||
if (gen !== _countInGen) return; // teardown mid-count
|
||||
count++;
|
||||
if (count > 4) {
|
||||
hideCountOverlay();
|
||||
_countingIn = false;
|
||||
// Hand off to the normal play path — togglePlay() flips isPlaying,
|
||||
// updates the button, and emits song:play/resume for plugins.
|
||||
Promise.resolve(host.togglePlay()).catch((err) => console.warn('[app] play after count-in failed:', err));
|
||||
return;
|
||||
}
|
||||
showCountOverlay(count);
|
||||
playClick(count === 1);
|
||||
_countInTimer = setTimeout(tick, beatInterval * 1000);
|
||||
}
|
||||
// First beat after a short lead-in, matching the loop count-in's 500 ms.
|
||||
_countInTimer = setTimeout(tick, 500);
|
||||
}
|
||||
|
||||
// ── Operations app.js's autoplay path used to perform by reaching in ────────
|
||||
// It used to assign _creditsTimer / _creditsHideOnPlay directly. Imported bindings are
|
||||
// read-only, and the module should own its own timer invariants anyway.
|
||||
|
||||
/** Is a count-in running? app.js's timeupdate handler suppresses highway sync during one. */
|
||||
export function isCountingIn() {
|
||||
return _countingIn;
|
||||
}
|
||||
|
||||
/** Dismiss the credits the moment real playback begins. Fires once. */
|
||||
export function armCreditsHideOnPlay() {
|
||||
_creditsHideOnPlay = () => { _creditsHideOnPlay = null; hideSongCreditsOverlay(); };
|
||||
window.feedBack.on('song:play', _creditsHideOnPlay, { once: true });
|
||||
}
|
||||
|
||||
/** Let the credits dwell, then clear them. Used when autoplay-exit is disabled. */
|
||||
export function scheduleCreditsHide() {
|
||||
_creditsTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_HOLD_MS);
|
||||
}
|
||||
|
||||
/** Let the credits dwell, then run `then` (the autoplay start). */
|
||||
export function holdCreditsThen(then) {
|
||||
_creditsTimer = setTimeout(() => { _creditsTimer = null; then(); }, _CREDITS_HOLD_MS);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// 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().`);
|
||||
},
|
||||
});
|
||||
@@ -1,994 +0,0 @@
|
||||
// The desktop (JUCE) audio integration — three self-installing shims.
|
||||
//
|
||||
// The largest single slice out of app.js's core: 938 lines, ~12% of what was left.
|
||||
//
|
||||
// _installJuceEngineRoutingWatcher routes a song to the JUCE engine or HTML5 as the
|
||||
// desktop output device enters/leaves exclusive/ASIO
|
||||
// _installRendererBusFeeder feeds the highway renderer bus from whichever
|
||||
// transport is actually running
|
||||
// _installJuceAudioElementShim patches audio.play/pause so the rest of the app
|
||||
// can keep talking to the <audio> element while JUCE
|
||||
// owns the transport
|
||||
//
|
||||
// They EXPORT NOTHING. All three are IIFEs that publish through `window.*`
|
||||
// (_juceMode, _reevaluateJuceRouting, _reevaluateRendererBus, …) — which is why app.js
|
||||
// only needs a side-effect import for two of them, plus _resetJuceAudioShimChain.
|
||||
//
|
||||
// ORDERING, CHECKED: importing this module runs the IIFEs EARLIER than before —
|
||||
// imports evaluate ahead of app.js's body, and therefore ahead of configureHost().
|
||||
// That is safe because none of them touches a hook at execution depth: they only
|
||||
// register listeners and patch audio.play/pause (and `audio` is itself an imported
|
||||
// module now). Verified by walking the AST at IIFE-body depth. If a hook were ever
|
||||
// read there it would THROW loudly — see ./host.js — rather than silently misbehave.
|
||||
//
|
||||
// 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 { audio } from './audio-el.js';
|
||||
import { host } from './host.js';
|
||||
import { setSpeed } from './player-controls.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
(function _installJuceEngineRoutingWatcher() {
|
||||
const juceApi = window.feedBackDesktop?.audio;
|
||||
if (!juceApi || typeof juceApi.isAudioRunning !== 'function') {
|
||||
// Desktop bridge present but audio API incomplete — the whole
|
||||
// exclusive reroute chain is dead and this line is the only witness.
|
||||
// (Docker sphere has no bridge at all: stay silent, nothing to
|
||||
// diagnose there and no debug flag to gate on.)
|
||||
if (window.feedBackDesktop) {
|
||||
console.log('[asio-diag] routing watcher NOT installed (audio api incomplete)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let _rerouteInFlight = false;
|
||||
// URL that JUCE's loadBackingTrack *explicitly rejected* (ok === false —
|
||||
// e.g. a codec it can't read). The poll below would otherwise retry the
|
||||
// same doomed track every 350 ms; remember it and skip until the song
|
||||
// changes. Only a hard JUCE reject is memoised here — transient failures
|
||||
// (a network blip on /api/audio-local-path, an isAudioRunning() race
|
||||
// during a device restart) are deliberately NOT memoised so they retry.
|
||||
let _rerouteRejectedUrl = null;
|
||||
// Exclusive-style output backends silence every other client on the
|
||||
// endpoint — including our own <audio> element. The share mode IS the
|
||||
// JUCE output device type: "Windows Audio (Exclusive Mode)" is a
|
||||
// hardcoded, unlocalised JUCE type name; ASIO drivers typically hold
|
||||
// the endpoint exclusively too. "Windows Audio (Low Latency Mode)" is
|
||||
// shared and must NOT match.
|
||||
function _isExclusiveOutputType(t) {
|
||||
return t === 'Windows Audio (Exclusive Mode)' || t === 'ASIO';
|
||||
}
|
||||
// [feedpak-route] diagnostics: log the raw outputType string once per
|
||||
// value change (this runs on a 350ms poll — logging every tick would
|
||||
// flood the diagnostics buffer).
|
||||
let _loggedOutputType;
|
||||
// [asio-diag] verbose diagnostics, gated on --debug (preload exposes
|
||||
// audio.debugEnabled). Resolved once at install; until it resolves the
|
||||
// flag stays false and verbose lines are skipped. Shared with the
|
||||
// renderer-bus feeder below via window._asioDiagEnabled.
|
||||
let _asioDiag = false;
|
||||
if (typeof juceApi.debugEnabled === 'function') {
|
||||
juceApi.debugEnabled().then((v) => {
|
||||
_asioDiag = !!v;
|
||||
// Deferred install line: the flag resolves async, so logging at
|
||||
// IIFE entry would race it. Change-detection isn't needed — this
|
||||
// runs once per page load.
|
||||
if (_asioDiag) console.log('[asio-diag] routing watcher installed');
|
||||
}).catch(() => {});
|
||||
}
|
||||
window._asioDiagEnabled = () => _asioDiag;
|
||||
async function _outputIsExclusive() {
|
||||
if (typeof juceApi.getCurrentDevice !== 'function') {
|
||||
if (_loggedOutputType !== '<no-getCurrentDevice>') {
|
||||
_loggedOutputType = '<no-getCurrentDevice>';
|
||||
console.warn('[feedpak-route] juceApi.getCurrentDevice missing — cannot detect exclusive output');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const dev = await juceApi.getCurrentDevice();
|
||||
const t = dev?.outputType || dev?.type || '';
|
||||
const excl = _isExclusiveOutputType(t);
|
||||
if (t !== _loggedOutputType) {
|
||||
_loggedOutputType = t;
|
||||
console.log('[feedpak-route] outputType=', JSON.stringify(t), '→ exclusive=', excl);
|
||||
// [asio-diag] full device object on every type change — shows
|
||||
// the exact strings the predicate saw (inputType vs outputType,
|
||||
// device names, duplex), so a driver reporting a non-'ASIO'
|
||||
// type name is visible in tester logs.
|
||||
if (_asioDiag) {
|
||||
try {
|
||||
console.log('[asio-diag] getCurrentDevice=', JSON.stringify(dev));
|
||||
} catch (_) { /* circular/hostile object — skip */ }
|
||||
}
|
||||
}
|
||||
return excl;
|
||||
} catch (e) {
|
||||
if (_loggedOutputType !== '<getCurrentDevice-failed>') {
|
||||
_loggedOutputType = '<getCurrentDevice-failed>';
|
||||
console.warn('[feedpak-route] getCurrentDevice failed:', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// highway.js's initial song-load routing consults this for the same
|
||||
// feedpak-under-exclusive decision the watcher makes below.
|
||||
window._juceOutputIsExclusive = _outputIsExclusive;
|
||||
// Returns true when window._currentSongAudio no longer references the exact
|
||||
// snapshot object captured at reroute entry — i.e. the song was swapped (or
|
||||
// cleared) mid-flight. Staleness is detected by object-reference identity,
|
||||
// not by URL value.
|
||||
function _isStale(songAudio) {
|
||||
return window._currentSongAudio !== songAudio;
|
||||
}
|
||||
|
||||
// Migrates the loaded song from the HTML5 element onto the JUCE backing
|
||||
// transport. Throws only on transient/unexpected failures.
|
||||
// `songAudio` is the snapshot captured at reroute entry; if it stops being
|
||||
// the current song mid-flight we abort without mutating global routing.
|
||||
// Returns a distinct string outcome — the caller must NOT conflate them:
|
||||
// 'switched' — song now plays via JUCE.
|
||||
// 'rejected' — JUCE hard-rejected the track (codec). Caller memoises it.
|
||||
// 'stale' — the loaded song changed mid-flight; aborted, NOT memoised.
|
||||
// (a transient transport-start failure throws instead — also not memoised.)
|
||||
async function _switchHtml5ToJuce(songAudio) {
|
||||
const url = songAudio.url;
|
||||
const wasPlaying = S.isPlaying;
|
||||
const pos = audio.currentTime || 0;
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'desktop-native',
|
||||
state: 'switching',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio engine became active',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
// Mark a reroute in progress so the <audio> 'play'/'pause' listeners
|
||||
// suppress their song:play / song:pause emissions: the migration is
|
||||
// transparent — playback genuinely continues — so plugin state and
|
||||
// window.feedBack.isPlaying must NOT flip. This also silences the
|
||||
// "Audio paused unexpectedly" diagnostic. A REFCOUNT (not a boolean)
|
||||
// lets an overlapping reroute's deferred release coexist: each switch
|
||||
// increments on entry and decrements after its own timeout; listeners
|
||||
// treat any count > 0 as "reroute active".
|
||||
window._juceRerouteInProgress = (window._juceRerouteInProgress || 0) + 1;
|
||||
audio.pause();
|
||||
try {
|
||||
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(url)}`);
|
||||
if (!res.ok) {
|
||||
console.warn('[feedpak-route] audio-local-path HTTP', res.status, 'for', url);
|
||||
throw new Error('HTTP ' + res.status);
|
||||
}
|
||||
const { path } = await res.json();
|
||||
console.log('[feedpak-route] audio-local-path resolved:', (typeof path === 'string' && path.split(/[\\/]/).pop()) || '<missing>');
|
||||
if (_isStale(songAudio)) return 'stale'; // song changed mid-fetch
|
||||
const ok = await juceApi.loadBackingTrack(path);
|
||||
if (ok === false) {
|
||||
// JUCE rejected the track — stay on HTML5, resume if needed.
|
||||
console.warn('[juce-reroute] loadBackingTrack rejected; staying on HTML5');
|
||||
// Only resume if the element still has a source. In the normal
|
||||
// flow audio.src is intact here, but a prior HTML5→JUCE switch
|
||||
// clears it — re-point + load before resuming so a bounced
|
||||
// reroute doesn't try to play() an empty element.
|
||||
if (S.isPlaying && !_isStale(songAudio)) {
|
||||
if (!audio.src) { audio.src = url; audio.load(); }
|
||||
try { await audio.play(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'browser-media',
|
||||
state: 'degraded',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio route rejected track; kept browser media route',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
return 'rejected';
|
||||
}
|
||||
if (_isStale(songAudio)) return 'stale';
|
||||
const dur = await juceApi.getBackingDuration();
|
||||
await juceApi.seekBacking(pos);
|
||||
// Start the new transport BEFORE committing global routing state, so
|
||||
// a play() failure can't leave us in "JUCE mode, nothing playing"
|
||||
// (the silent-song state this watcher exists to prevent).
|
||||
// jucePlayer.play() RETURNS false (it does not throw) when
|
||||
// startBacking fails — check the result, don't just await it.
|
||||
// A play() failure is a TRANSIENT transport-start issue, not a hard
|
||||
// codec reject: throw (rather than returning 'rejected') so the
|
||||
// caller's catch path handles it WITHOUT memoising the URL, leaving
|
||||
// it free to retry on the next poll. Only 'rejected' is memoised.
|
||||
// Re-read isPlaying as late as possible: the user can press Pause
|
||||
// during the multi-await fetch/IPC chain above. Starting the JUCE
|
||||
// transport off a stale `wasPlaying` snapshot would resume a song
|
||||
// the user just paused. Only start it if playback is still wanted.
|
||||
if (S.isPlaying) {
|
||||
const started = await host.jucePlayer().play();
|
||||
if (started === false) {
|
||||
if (!_isStale(songAudio) && S.isPlaying) {
|
||||
try { await audio.play(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
throw new Error('host.jucePlayer().play() failed (transient transport start)');
|
||||
}
|
||||
}
|
||||
if (_isStale(songAudio)) {
|
||||
// Song changed while JUCE was spinning up — undo and bail.
|
||||
await host.jucePlayer().pause().catch(() => {});
|
||||
return 'stale';
|
||||
}
|
||||
if (window.jucePlayer) {
|
||||
host.jucePlayer()._dur = dur;
|
||||
host.jucePlayer()._pos = pos;
|
||||
host.jucePlayer()._pollAt = performance.now();
|
||||
}
|
||||
window._juceMode = true;
|
||||
window._juceAudioUrl = url;
|
||||
const _spSlider = document.getElementById?.('speed-slider');
|
||||
if (_spSlider) setSpeed(_spSlider.value / 100);
|
||||
audio.src = '';
|
||||
try {
|
||||
const apply = window.feedBack?.audio?.applySongVolume;
|
||||
if (typeof apply === 'function') await apply();
|
||||
} catch (_) { /* best-effort */ }
|
||||
console.log('[juce-reroute] HTML5 → JUCE @', pos.toFixed(2), 's playing=', wasPlaying);
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'desktop-native',
|
||||
state: 'active',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio route active',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
return 'switched';
|
||||
} catch (err) {
|
||||
// Path lookup, JSON parse, or a JUCE IPC call threw partway through.
|
||||
// audio.pause() already ran above; restore HTML5 playback so a
|
||||
// previously playing song isn't left silently paused, then re-throw
|
||||
// so the caller logs it. The caller does NOT memoise this URL —
|
||||
// transient failures must retry on the next poll.
|
||||
if (S.isPlaying && !window._juceMode && !_isStale(songAudio)) {
|
||||
if (!audio.src) { audio.src = url; audio.load(); }
|
||||
try { await audio.play(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'browser-media',
|
||||
state: 'degraded',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio route failed; kept browser media route',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
// Clearing audio.src above dispatches a 'pause' event in a later
|
||||
// task, after this synchronous finally. Defer the refcount
|
||||
// decrement so that trailing event is still suppressed; a 0ms
|
||||
// timeout lands after the pending pause-event task. Decrementing
|
||||
// (rather than zeroing) leaves any overlapping reroute's own
|
||||
// suppression intact.
|
||||
setTimeout(() => {
|
||||
window._juceRerouteInProgress = Math.max(
|
||||
0, (window._juceRerouteInProgress || 1) - 1);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function _switchJuceToHtml5(songAudio) {
|
||||
const url = songAudio.url;
|
||||
const wasPlaying = S.isPlaying;
|
||||
const pos = (window.jucePlayer ? host.jucePlayer().currentTime : 0) || 0;
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'browser-media',
|
||||
state: 'switching',
|
||||
preservedTime: true,
|
||||
safeReason: 'desktop audio engine stopped',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
// Mark a reroute in progress (refcount) so the <audio> 'play' listener
|
||||
// suppresses its song:play emission — the migration is transparent and
|
||||
// playback genuinely continues, so plugin state must not flip. Held
|
||||
// until after the (possibly deferred) audio.play() event has fired.
|
||||
window._juceRerouteInProgress = (window._juceRerouteInProgress || 0) + 1;
|
||||
let _suppressionReleased = false;
|
||||
const _releaseSuppression = () => {
|
||||
if (_suppressionReleased) return;
|
||||
_suppressionReleased = true;
|
||||
// Defer so the 'play' (or 'pause') event task fires while still
|
||||
// suppressed; a 0ms timeout lands after it.
|
||||
setTimeout(() => {
|
||||
window._juceRerouteInProgress = Math.max(
|
||||
0, (window._juceRerouteInProgress || 1) - 1);
|
||||
}, 0);
|
||||
};
|
||||
let _resumeScheduled = false;
|
||||
try {
|
||||
await host.jucePlayer().pause().catch(() => {});
|
||||
if (_isStale(songAudio)) return; // song changed mid-pause
|
||||
window._juceMode = false;
|
||||
window._juceAudioUrl = null;
|
||||
audio.src = url;
|
||||
audio.load();
|
||||
const _spSlider = document.getElementById?.('speed-slider');
|
||||
if (_spSlider) setSpeed(_spSlider.value / 100);
|
||||
// Resume only AFTER the seek so playback starts at `pos`, not at 0
|
||||
// with an audible jump once metadata arrives.
|
||||
const resumeAtPos = () => {
|
||||
try {
|
||||
// The metadata event can land after a fast song switch —
|
||||
// bail before touching currentTime so a stale callback
|
||||
// doesn't seek the newly loaded song to the old position.
|
||||
if (_isStale(songAudio)) return;
|
||||
try { audio.currentTime = pos; } catch (_) { /* ignore */ }
|
||||
// Re-read isPlaying (not the entry snapshot): the user may
|
||||
// have pressed Pause during jucePlayer.pause()/metadata
|
||||
// load — don't resume a song they just paused.
|
||||
if (S.isPlaying) {
|
||||
audio.play().catch(() => { /* ignore */ });
|
||||
}
|
||||
} finally {
|
||||
_releaseSuppression();
|
||||
}
|
||||
};
|
||||
_resumeScheduled = true;
|
||||
if (audio.readyState >= 1) {
|
||||
resumeAtPos();
|
||||
} else {
|
||||
// Wait for metadata to resume at `pos`. But metadata may never
|
||||
// arrive (bad URL, network error) — that would leak the
|
||||
// suppression refcount and permanently silence song:play /
|
||||
// song:pause. Guard with the element's 'error' event AND a
|
||||
// backstop timeout; whichever fires first wins, the others are
|
||||
// detached. _releaseSuppression is idempotent regardless.
|
||||
let _settled = false;
|
||||
const _onMeta = () => { finish(true); };
|
||||
const _onErr = () => { finish(false); };
|
||||
let _backstop;
|
||||
function finish(reachedMetadata) {
|
||||
if (_settled) return;
|
||||
_settled = true;
|
||||
clearTimeout(_backstop);
|
||||
audio.removeEventListener('loadedmetadata', _onMeta);
|
||||
audio.removeEventListener('error', _onErr);
|
||||
if (reachedMetadata) {
|
||||
resumeAtPos(); // resumeAtPos releases suppression
|
||||
} else {
|
||||
_releaseSuppression(); // no resume — just release
|
||||
}
|
||||
}
|
||||
audio.addEventListener('loadedmetadata', _onMeta, { once: true });
|
||||
audio.addEventListener('error', _onErr, { once: true });
|
||||
// 10s is well beyond a normal local-file metadata load.
|
||||
_backstop = setTimeout(() => { finish(false); }, 10000);
|
||||
}
|
||||
} finally {
|
||||
// resumeAtPos owns the release once scheduled; if we returned
|
||||
// early (stale, before scheduling) release here instead.
|
||||
// _releaseSuppression is idempotent so an overlap is harmless.
|
||||
if (!_resumeScheduled) _releaseSuppression();
|
||||
}
|
||||
try {
|
||||
const apply = window.feedBack?.audio?.applySongVolume;
|
||||
if (typeof apply === 'function') await apply();
|
||||
} catch (_) { /* best-effort */ }
|
||||
console.log('[juce-reroute] JUCE → HTML5 @', pos.toFixed(2), 's playing=', wasPlaying);
|
||||
window.feedBack?.playback?.recordRouteChange?.({
|
||||
routeKind: 'browser-media',
|
||||
state: 'active',
|
||||
preservedTime: true,
|
||||
safeReason: 'browser media route active',
|
||||
requesterId: 'core.juce-route',
|
||||
});
|
||||
}
|
||||
|
||||
async function _reevaluateJuceRouting() {
|
||||
if (_rerouteInFlight) return;
|
||||
const songAudio = window._currentSongAudio;
|
||||
// /audio/ songs are always JUCE-routable. A feedpak full-mix
|
||||
// (single-mix pack, no stems) is routable ONLY under an
|
||||
// exclusive-style output — in shared mode it must stay on HTML5 so
|
||||
// the stem mixer / WebAudio path keeps working. Sloppak stem URLs
|
||||
// are never routable (per-stem mix can't ride a single transport).
|
||||
if (!songAudio || (!songAudio.juceEligible && !songAudio.feedpakFullMix)) return;
|
||||
// Don't race highway.js's own initial song-load routing: it owns
|
||||
// _juceMode until _juceRoutingPromise settles. Re-running our switch
|
||||
// concurrently would double-call loadBackingTrack for the same URL.
|
||||
if (window._highwayJuceRoutingPending) return;
|
||||
|
||||
// Claim the in-flight guard SYNCHRONOUSLY, before the first await. The
|
||||
// watcher is driven by a 350ms setInterval; if isAudioRunning() (or any
|
||||
// later await) stalls past the poll period, a second tick would
|
||||
// otherwise pass the `if (_rerouteInFlight) return` check above and run
|
||||
// a concurrent switch — duplicate loadBackingTrack IPCs racing on
|
||||
// _juceMode / audio.src. Setting it here closes that window.
|
||||
_rerouteInFlight = true;
|
||||
try {
|
||||
let running;
|
||||
try { running = await juceApi.isAudioRunning(); }
|
||||
catch (_) { return; }
|
||||
if (_isStale(songAudio)) return; // song changed during IPC
|
||||
// Eligibility is evaluated per tick, not snapshotted at song load:
|
||||
// the output share mode can change mid-song (device switch in the
|
||||
// Audio Engine panel), and a feedpak full-mix must follow it —
|
||||
// exclusive → ride the engine; back to shared → return to HTML5.
|
||||
let eligible = !!songAudio.juceEligible;
|
||||
if (!eligible && songAudio.feedpakFullMix && running) {
|
||||
eligible = await _outputIsExclusive();
|
||||
if (_isStale(songAudio)) return; // song changed during IPC
|
||||
}
|
||||
const wantJuce = !!(running && eligible);
|
||||
// [feedpak-route] diagnostics: one line per decision change (the
|
||||
// watcher polls at 350ms; steady state must not spam the buffer).
|
||||
const _decision = 'running=' + running + ' eligible=' + eligible
|
||||
+ ' feedpakFullMix=' + !!songAudio.feedpakFullMix
|
||||
+ ' juceMode=' + !!window._juceMode + ' url=' + songAudio.url;
|
||||
if (_decision !== window._lastFeedpakRouteDecision) {
|
||||
window._lastFeedpakRouteDecision = _decision;
|
||||
console.log('[feedpak-route] watcher:', _decision);
|
||||
}
|
||||
if (wantJuce === !!window._juceMode) return; // routing already consistent
|
||||
// Don't keep retrying a track JUCE explicitly rejected.
|
||||
if (wantJuce && songAudio.url === _rerouteRejectedUrl) return;
|
||||
|
||||
if (wantJuce) {
|
||||
const outcome = await _switchHtml5ToJuce(songAudio);
|
||||
// Memoise ONLY an explicit hard JUCE reject. A successful
|
||||
// switch clears the memo; a 'stale' abort (song changed
|
||||
// mid-flight) leaves it untouched — it must never be
|
||||
// misclassified as a reject, even if the song object was
|
||||
// swapped and then restored before this point.
|
||||
if (outcome === 'rejected') {
|
||||
_rerouteRejectedUrl = songAudio.url;
|
||||
} else if (outcome === 'switched') {
|
||||
_rerouteRejectedUrl = null;
|
||||
}
|
||||
// outcome === 'stale': leave _rerouteRejectedUrl as-is.
|
||||
} else {
|
||||
await _switchJuceToHtml5(songAudio);
|
||||
// The engine stopped (or a feedpak's output left exclusive
|
||||
// mode). Clear any hard-reject memo so a later engine restart
|
||||
// or mode change re-evaluates the track at least once — the
|
||||
// rejection may have been a transient device/decoder state.
|
||||
_rerouteRejectedUrl = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// Transient failure — log but do NOT memoise, so the next poll retries.
|
||||
console.warn('[juce-reroute] re-route failed (will retry):', e);
|
||||
} finally {
|
||||
_rerouteInFlight = false;
|
||||
}
|
||||
}
|
||||
window._reevaluateJuceRouting = _reevaluateJuceRouting;
|
||||
|
||||
// Clears the hard-reject memo. Called from the song-teardown sites that
|
||||
// null window._currentSongAudio (showScreen, playSong) so that reloading
|
||||
// the same file later gets a fresh routing attempt — a prior reject may
|
||||
// have been a transient JUCE/device state, not a permanent codec issue.
|
||||
window._clearJuceRerouteMemo = function () { _rerouteRejectedUrl = null; };
|
||||
|
||||
// The engine can be started/stopped from several places (the desktop Audio
|
||||
// Engine panel, the audio_engine plugin, note_detect) and via setDevice
|
||||
// restarts — and the contextBridge api object is frozen, so its methods
|
||||
// can't be wrapped. Poll isAudioRunning() while a song is loaded; the check
|
||||
// is a cheap IPC boolean and no-ops once routing is already consistent.
|
||||
// Skip the poll while the document is hidden (background tab / minimised
|
||||
// window) — engine toggles there will be reconciled on the first poll
|
||||
// after the tab is visible again.
|
||||
setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
if (window._currentSongAudio) void _reevaluateJuceRouting();
|
||||
}, 350);
|
||||
})();
|
||||
|
||||
// Renderer-audio bus feeder (desktop Phase 2): when the engine holds the
|
||||
// output endpoint in an exclusive-style mode, Chromium cannot reach the
|
||||
// device, so any song audio still played by the renderer goes silent. The
|
||||
// Phase 1 watcher above already migrates what a single-file transport can
|
||||
// carry (loose /audio/ songs, feedpak full-mixes) onto the native backing
|
||||
// transport. This feeder covers the rest — the stems plugin's multi-stem
|
||||
// WebAudio graph, plus <audio>-element songs the native transport could not
|
||||
// take (e.g. a codec loadBackingTrack rejected).
|
||||
//
|
||||
// Mechanism: capture the renderer-side master with an AudioWorklet tap,
|
||||
// re-point the owning AudioContext at a null sink so it keeps rendering
|
||||
// without a device, and push ~10 ms chunks over IPC into the engine's
|
||||
// renderer bus, where they are mixed into the exclusive output like a
|
||||
// backing track (~10-20 ms added latency on song audio only; the guitar
|
||||
// monitoring path is untouched). Validated by the fix12 tester spike:
|
||||
// null-sink rendering works, clocks hold (drift → 0), no overflow.
|
||||
//
|
||||
// Docker sphere: window.feedBackDesktop is undefined → this whole block is
|
||||
// inert. Shared-mode desktop: the bus stays disabled (no double audio) and
|
||||
// captured contexts keep/regain their default sink.
|
||||
(function _installRendererBusFeeder() {
|
||||
const api = window.feedBackDesktop?.audio;
|
||||
if (!api || typeof api.setRendererBus !== 'function'
|
||||
|| typeof api.pushRendererAudio !== 'function') {
|
||||
// Silent in the Docker sphere (no bridge, no debug flag); a desktop
|
||||
// bridge missing the bus API is the diagnostic case.
|
||||
if (window.feedBackDesktop) {
|
||||
console.log('[asio-diag] renderer-bus feeder NOT installed (api=' + !!api
|
||||
+ ' setRendererBus=' + typeof api?.setRendererBus
|
||||
+ ' pushRendererAudio=' + typeof api?.pushRendererAudio + ')');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Deferred like the watcher's install line: gate on the async debug flag.
|
||||
if (typeof api.debugEnabled === 'function') {
|
||||
api.debugEnabled().then((v) => {
|
||||
if (v) console.log('[asio-diag] renderer-bus feeder installed (loopback-capable='
|
||||
+ (typeof window.navigator?.mediaDevices?.getDisplayMedia === 'function') + ')');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const TAP_WORKLET = `
|
||||
class FeedbackBusTap extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const inp = inputs[0];
|
||||
if (inp && inp[0]) {
|
||||
const L = inp[0], R = inp[1] || inp[0];
|
||||
const out = new Float32Array(L.length * 2);
|
||||
for (let i = 0; i < L.length; i++) { out[i*2] = L[i]; out[i*2+1] = R[i]; }
|
||||
this.port.postMessage(out, [out.buffer]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('feedback-bus-tap', FeedbackBusTap);
|
||||
`;
|
||||
const _tapModuleUrl = URL.createObjectURL(new Blob([TAP_WORKLET], { type: 'application/javascript' }));
|
||||
const _tapModuleLoaded = new WeakSet(); // AudioContexts with the module added
|
||||
|
||||
// One tap per captured graph. `active` gates the push (the worklet keeps
|
||||
// running when inactive — it's silent bookkeeping, not audio).
|
||||
function _makeTap(ctx) {
|
||||
const state = { node: null, active: false, batch: [], batchFrames: 0 };
|
||||
state.attach = async (sourceNode) => {
|
||||
if (!_tapModuleLoaded.has(ctx)) {
|
||||
await ctx.audioWorklet.addModule(_tapModuleUrl);
|
||||
_tapModuleLoaded.add(ctx);
|
||||
}
|
||||
if (!state.node) {
|
||||
state.node = new AudioWorkletNode(ctx, 'feedback-bus-tap', { numberOfInputs: 1, channelCount: 2 });
|
||||
const BATCH = Math.round(ctx.sampleRate / 100); // ~10 ms
|
||||
state.node.port.onmessage = (e) => {
|
||||
if (!state.active) { state.batch = []; state.batchFrames = 0; return; }
|
||||
state.batch.push(e.data);
|
||||
state.batchFrames += e.data.length / 2;
|
||||
if (state.batchFrames >= BATCH) {
|
||||
const merged = new Float32Array(state.batchFrames * 2);
|
||||
let o = 0;
|
||||
for (const c of state.batch) { merged.set(c, o); o += c.length; }
|
||||
api.pushRendererAudio(merged, ctx.sampleRate);
|
||||
state.batch = []; state.batchFrames = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
sourceNode.connect(state.node);
|
||||
// No onward connection: the tap is a sink-side observer; audibility
|
||||
// in shared mode comes from the graph's own destination path.
|
||||
};
|
||||
state.detach = (sourceNode) => {
|
||||
state.active = false;
|
||||
state.batch = []; state.batchFrames = 0;
|
||||
if (state.node && sourceNode) {
|
||||
try { sourceNode.disconnect(state.node); } catch (_) { /* already gone */ }
|
||||
}
|
||||
};
|
||||
return state;
|
||||
}
|
||||
|
||||
// ── Core <audio> element capture ─────────────────────────────────────────
|
||||
// createMediaElementSource permanently reroutes the element into its
|
||||
// context, so it is created lazily — only the first time an exclusive
|
||||
// device actually needs it — and never torn down. From then on the element
|
||||
// always plays through _elCtx; sink toggling routes it to the speakers
|
||||
// (shared mode) or the null sink + bus (exclusive mode).
|
||||
let _elCtx = null, _elSource = null, _elTap = null;
|
||||
async function _ensureElementCapture() {
|
||||
if (_elCtx) return;
|
||||
const el = document.getElementById('audio');
|
||||
if (!el) throw new Error('no core audio element');
|
||||
// Assign the module state ONLY after the whole chain succeeded.
|
||||
// createMediaElementSource throws InvalidStateError when another
|
||||
// consumer (highway_3d's analyser tap) already owns the element's
|
||||
// one-shot source — assigning _elCtx before that throw poisoned every
|
||||
// later tick into `_elTap.active` TypeErrors (tester log 2026-07-11)
|
||||
// while the song kept playing on the default device.
|
||||
const ctx = new AudioContext();
|
||||
let source, tap;
|
||||
try {
|
||||
source = ctx.createMediaElementSource(el);
|
||||
source.connect(ctx.destination);
|
||||
tap = _makeTap(ctx);
|
||||
await tap.attach(source);
|
||||
} catch (e) {
|
||||
try { await ctx.close(); } catch (_) { /* already closed */ }
|
||||
throw e;
|
||||
}
|
||||
_elCtx = ctx; _elSource = source; _elTap = tap;
|
||||
}
|
||||
|
||||
// ── Whole-app loopback capture ───────────────────────────────────────────
|
||||
// Preferred mode: one getDisplayMedia frame-audio capture covers EVERY
|
||||
// sound the app makes (song, previews, UI) — no per-surface taps, so
|
||||
// plugin-private AudioContexts (song-preview, future plugins) survive
|
||||
// exclusive/ASIO output too. The desktop main process answers the request
|
||||
// with this window's own frame (frame-scoped — no other apps' audio).
|
||||
// Local playback is silenced via the suppressLocalAudioPlayback track
|
||||
// constraint, with a page-mute IPC fallback (capture taps frame audio
|
||||
// before the output mute, so a muted page still feeds the stream).
|
||||
let _lbStream = null, _lbCtx = null, _lbTap = null, _lbPageMuted = false;
|
||||
let _loopbackUnavailable = false; // sticky: probe once, then fall back
|
||||
async function _engageLoopback() {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: { suppressLocalAudioPlayback: true },
|
||||
});
|
||||
for (const t of stream.getVideoTracks()) t.stop(); // required, unused
|
||||
const track = stream.getAudioTracks()[0];
|
||||
if (!track) {
|
||||
for (const t of stream.getTracks()) t.stop();
|
||||
throw new Error('no loopback audio track');
|
||||
}
|
||||
try {
|
||||
// Fresh context per session (not reused) so teardown's close()
|
||||
// fully releases the tap worklet node — see _teardownLoopback.
|
||||
_lbCtx = new AudioContext();
|
||||
if (_lbCtx.state !== 'running') await _lbCtx.resume().catch(() => {});
|
||||
const source = _lbCtx.createMediaStreamSource(stream);
|
||||
const tap = _makeTap(_lbCtx);
|
||||
await tap.attach(source);
|
||||
const suppressed = track.getSettings?.().suppressLocalAudioPlayback === true;
|
||||
if (!suppressed && typeof api.setPageMuted === 'function') {
|
||||
_lbPageMuted = (await api.setPageMuted(true)) === true;
|
||||
}
|
||||
if (window._asioDiagEnabled?.()) {
|
||||
console.log('[asio-diag] loopback: suppressed=', suppressed,
|
||||
'pageMuted=', _lbPageMuted, 'rate=', _lbCtx.sampleRate);
|
||||
}
|
||||
await api.setRendererBus(true, 1.0);
|
||||
tap.active = true;
|
||||
_lbStream = stream; _lbTap = tap;
|
||||
_mode = 'loopback';
|
||||
console.log('[renderer-bus] engaged: app loopback → engine bus');
|
||||
} catch (e) {
|
||||
for (const t of stream.getTracks()) t.stop();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
async function _teardownLoopback() {
|
||||
if (_lbTap) _lbTap.active = false;
|
||||
if (_lbStream) for (const t of _lbStream.getTracks()) t.stop();
|
||||
_lbStream = null; _lbTap = null;
|
||||
// Close the capture context so its tap worklet node is released. The
|
||||
// context is per-session (not reused): without this, each exclusive⇄
|
||||
// shared switch orphaned a live worklet on a long-lived context.
|
||||
if (_lbCtx) {
|
||||
try { await _lbCtx.close(); } catch (_) { /* already closed */ }
|
||||
_lbCtx = null;
|
||||
}
|
||||
if (_lbPageMuted && typeof api.setPageMuted === 'function') {
|
||||
try { await api.setPageMuted(false); } catch (_) { /* engine gone */ }
|
||||
}
|
||||
_lbPageMuted = false;
|
||||
}
|
||||
|
||||
// ── Engagement state machine ─────────────────────────────────────────────
|
||||
// 'off' | 'loopback' | 'element' | 'stems' (element/stems = fallback when
|
||||
// loopback capture is unavailable: old desktop main, denied capture)
|
||||
let _mode = 'off';
|
||||
let _stemsGraph = null; // { context, masterNode } snapshot while engaged
|
||||
let _stemsTap = null;
|
||||
const _stemsTaps = new WeakMap(); // context → tap (stems ctx is reused across songs)
|
||||
let _busy = false;
|
||||
|
||||
async function _setSink(ctx, exclusive) {
|
||||
if (typeof ctx.setSinkId !== 'function') throw new Error('setSinkId unsupported');
|
||||
await ctx.setSinkId(exclusive ? { type: 'none' } : '');
|
||||
if (ctx.state !== 'running') await ctx.resume().catch(() => {});
|
||||
// [asio-diag] a context left on the default sink while the bus is
|
||||
// engaged is exactly the "song on the wrong device" symptom — record
|
||||
// every successful sink flip (failures throw and are logged upstream).
|
||||
if (window._asioDiagEnabled?.()) {
|
||||
console.log('[asio-diag] setSink:', exclusive ? 'null-sink' : 'default',
|
||||
'state=', ctx.state, 'rate=', ctx.sampleRate);
|
||||
}
|
||||
}
|
||||
|
||||
async function _disengage() {
|
||||
if (_mode === 'off') return;
|
||||
const prev = _mode;
|
||||
_mode = 'off';
|
||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
||||
if (prev === 'loopback') {
|
||||
await _teardownLoopback();
|
||||
} else if (prev === 'element' && _elCtx) {
|
||||
_elTap.active = false;
|
||||
await _setSink(_elCtx, false).catch(() => {});
|
||||
} else if (prev === 'stems' && _stemsGraph) {
|
||||
if (_stemsTap) _stemsTap.detach(_stemsGraph.masterNode);
|
||||
await _setSink(_stemsGraph.context, false).catch(() => {});
|
||||
_stemsGraph = null; _stemsTap = null;
|
||||
}
|
||||
console.log('[renderer-bus] disengaged (' + prev + ')');
|
||||
}
|
||||
|
||||
async function _engageStems(graph) {
|
||||
await _setSink(graph.context, true);
|
||||
let tap = _stemsTaps.get(graph.context);
|
||||
if (!tap) { tap = _makeTap(graph.context); _stemsTaps.set(graph.context, tap); }
|
||||
await tap.attach(graph.masterNode);
|
||||
await api.setRendererBus(true, 1.0);
|
||||
tap.active = true;
|
||||
_stemsGraph = graph; _stemsTap = tap;
|
||||
_mode = 'stems';
|
||||
console.log('[renderer-bus] engaged: stems graph → engine bus');
|
||||
}
|
||||
|
||||
async function _engageElement() {
|
||||
await _ensureElementCapture();
|
||||
await _setSink(_elCtx, true);
|
||||
await api.setRendererBus(true, 1.0);
|
||||
_elTap.active = true;
|
||||
_mode = 'element';
|
||||
console.log('[renderer-bus] engaged: <audio> element → engine bus');
|
||||
}
|
||||
|
||||
async function _reevaluate() {
|
||||
if (_busy) return;
|
||||
_busy = true;
|
||||
try {
|
||||
let running = false, exclusive = false;
|
||||
try {
|
||||
running = await api.isAudioRunning();
|
||||
} catch (_) { /* engine unreachable → treat as not running */ }
|
||||
if (running) {
|
||||
// Reuse the Phase 1 predicate installed by the routing watcher
|
||||
// (getCurrentDevice + exclusive-type check with change-logged
|
||||
// diagnostics). Fail closed if it is somehow absent.
|
||||
exclusive = !!(await window._juceOutputIsExclusive?.());
|
||||
}
|
||||
|
||||
// The stems plugin publishes its live graph while a multi-stem
|
||||
// song is loaded (and removes it on teardown).
|
||||
const stems = (window.feedBack || window.slopsmith)?.stems?.audioGraph || null;
|
||||
// Element songs: a song is loaded, it is NOT riding the native
|
||||
// transport (Phase 1 owns those), and the stems graph is not the
|
||||
// player. Covers native-transport rejects (codec) in exclusive
|
||||
// mode — without this they would be silent.
|
||||
const songAudio = window._currentSongAudio;
|
||||
const elementSong = !!songAudio && !window._juceMode && !stems;
|
||||
|
||||
let want = 'off';
|
||||
if (running && exclusive) {
|
||||
// Loopback covers ALL app audio (song, previews, UI), so it
|
||||
// engages for the whole exclusive session — not just while a
|
||||
// song is loaded. Per-surface modes remain as fallback when
|
||||
// loopback capture is unavailable (old desktop main without
|
||||
// the display-media handler, capture denied).
|
||||
if (!_loopbackUnavailable) want = 'loopback';
|
||||
else if (stems) want = 'stems';
|
||||
else if (elementSong) want = 'element';
|
||||
}
|
||||
// Song audio riding the native transport must not ALSO ride the
|
||||
// loopback (double-carry into the same engine output). The native
|
||||
// transport plays from the engine, not the page, so page loopback
|
||||
// never hears it — no conflict; loopback stays engaged for
|
||||
// previews/UI while the transport owns the song.
|
||||
|
||||
// [asio-diag] full decision vector, change-gated (500ms poll —
|
||||
// steady state must not flood the buffer). This is the feeder-side
|
||||
// counterpart of the watcher's [feedpak-route] decision line: it
|
||||
// shows WHY the bus did or didn't engage (exclusive predicate,
|
||||
// stems graph presence, native transport ownership, element song).
|
||||
if (window._asioDiagEnabled?.()) {
|
||||
const d = 'running=' + running + ' exclusive=' + exclusive
|
||||
+ ' stems=' + !!stems + ' songAudio=' + !!songAudio
|
||||
+ ' juceMode=' + !!window._juceMode
|
||||
+ ' elementSong=' + elementSong
|
||||
+ ' loopbackUnavailable=' + _loopbackUnavailable
|
||||
+ ' want=' + want + ' mode=' + _mode;
|
||||
if (d !== window._lastRendererBusDecision) {
|
||||
window._lastRendererBusDecision = d;
|
||||
console.log('[asio-diag] renderer-bus:', d);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
|
||||
if (want !== _mode || stemsGraphChanged) {
|
||||
await _disengage();
|
||||
try {
|
||||
if (want === 'loopback') await _engageLoopback();
|
||||
else if (want === 'stems') await _engageStems(stems);
|
||||
else if (want === 'element') await _engageElement();
|
||||
} catch (e) {
|
||||
if (want === 'loopback') {
|
||||
// Capture unavailable (no handler in an old desktop
|
||||
// main, permission denied) — remember and fall back to
|
||||
// the per-surface modes on the next tick.
|
||||
_loopbackUnavailable = true;
|
||||
console.warn('[renderer-bus] loopback capture unavailable — falling back to surface taps:', e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Explicit name/message/stack head — the console-message forward
|
||||
// stringifies a DOMException to the useless "[object DOMException]".
|
||||
console.warn('[renderer-bus] reevaluate failed (will retry):',
|
||||
(e && e.name ? e.name + ': ' + e.message : String(e)),
|
||||
(e && e.stack ? '| ' + String(e.stack).split('\n')[1] : ''));
|
||||
_mode = 'off';
|
||||
// A partial engage may have left the bus enabled with no producer
|
||||
// and the page muted — undo both so a failed tick can't strand
|
||||
// audio in silence until the next successful engage.
|
||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
||||
await _teardownLoopback().catch(() => {});
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Same cadence/rationale as the routing watcher above. Also re-check on
|
||||
// visibility return so a device switch made while hidden is reconciled.
|
||||
setInterval(() => { if (!document.hidden) void _reevaluate(); }, 500);
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) void _reevaluate(); });
|
||||
window._reevaluateRendererBus = _reevaluate;
|
||||
})();
|
||||
|
||||
// Desktop JUCE backing uses an empty <audio> element; plugins such as Section Map
|
||||
// still seek via audio.currentTime / pause / play. Mirror those onto jucePlayer
|
||||
// while _juceMode is active. Same-tick pause+seek coalesce into a single seek
|
||||
// (no stopBacking before seek — HTML5 needed that for buffering; JUCE does not).
|
||||
export let _resetJuceAudioShimChain = function () {};
|
||||
(function _installJuceAudioElementShim() {
|
||||
if (!window.feedBackDesktop?.audio) return;
|
||||
|
||||
const mediaProto = HTMLMediaElement.prototype;
|
||||
const ctDesc = Object.getOwnPropertyDescriptor(mediaProto, 'currentTime');
|
||||
const pausedDesc = Object.getOwnPropertyDescriptor(mediaProto, 'paused');
|
||||
if (!ctDesc?.get || !ctDesc?.set || !pausedDesc?.get) return;
|
||||
|
||||
const nativePlay = mediaProto.play;
|
||||
const nativePause = mediaProto.pause;
|
||||
|
||||
let chain = Promise.resolve();
|
||||
/** Same-tick pause + seek (Section Map): coalesce to one seek — no stopBacking before seek. */
|
||||
let _juceShimBatch = null;
|
||||
let _juceShimBatchFlushScheduled = false;
|
||||
let _juceShimGen = 0;
|
||||
function enqueue(fn) {
|
||||
const gen = _juceShimGen;
|
||||
const p = chain.then(async () => {
|
||||
if (gen !== _juceShimGen) return;
|
||||
return fn(gen);
|
||||
});
|
||||
chain = p.catch((e) => {
|
||||
console.warn('[juce-audio-shim]', e);
|
||||
});
|
||||
return p;
|
||||
}
|
||||
// forUpcomingPlay: caller will enqueue a play() right after, so don't
|
||||
// emit pause-state side effects for a wantsPause batch — play() will
|
||||
// overwrite them anyway.
|
||||
function flushJuceShimBatchNow({ forUpcomingPlay = false } = {}) {
|
||||
_juceShimBatchFlushScheduled = false;
|
||||
const batch = _juceShimBatch;
|
||||
_juceShimBatch = null;
|
||||
if (!batch || !window._juceMode) return;
|
||||
const wantsPause = !!batch.wantsPause;
|
||||
const seekTime = batch.seekTime;
|
||||
if (wantsPause && seekTime !== undefined) {
|
||||
enqueue(async (gen) => {
|
||||
const r = await host._audioSeek(seekTime, 'audio-element-shim');
|
||||
if (!r.completed) return; // seek cancelled by teardown
|
||||
if (gen !== _juceShimGen) return;
|
||||
if (!forUpcomingPlay) {
|
||||
await host.jucePlayer().pause();
|
||||
if (gen !== _juceShimGen) return;
|
||||
S.isPlaying = false;
|
||||
host.setPlayButtonState(false);
|
||||
const sm = window.feedBack;
|
||||
if (sm) {
|
||||
sm.isPlaying = false;
|
||||
sm.emit('song:pause', host._songEventPayload());
|
||||
}
|
||||
}
|
||||
audio.dispatchEvent(new Event('seeked'));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (wantsPause) {
|
||||
enqueue(async (gen) => {
|
||||
await host.jucePlayer().pause();
|
||||
if (gen !== _juceShimGen) return;
|
||||
S.isPlaying = false;
|
||||
host.setPlayButtonState(false);
|
||||
const sm = window.feedBack;
|
||||
if (sm) {
|
||||
sm.isPlaying = false;
|
||||
sm.emit('song:pause', host._songEventPayload());
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (seekTime !== undefined) {
|
||||
enqueue(async (gen) => {
|
||||
const r = await host._audioSeek(seekTime, 'audio-element-shim');
|
||||
if (!r.completed) return; // seek cancelled by teardown
|
||||
if (gen !== _juceShimGen) return;
|
||||
audio.dispatchEvent(new Event('seeked'));
|
||||
});
|
||||
}
|
||||
}
|
||||
function scheduleJuceShimBatchFlush() {
|
||||
if (_juceShimBatchFlushScheduled) return;
|
||||
_juceShimBatchFlushScheduled = true;
|
||||
const flushGen = _juceShimGen;
|
||||
queueMicrotask(() => {
|
||||
if (flushGen !== _juceShimGen) {
|
||||
_juceShimBatchFlushScheduled = false;
|
||||
return;
|
||||
}
|
||||
flushJuceShimBatchNow();
|
||||
});
|
||||
}
|
||||
_resetJuceAudioShimChain = function () {
|
||||
chain = Promise.resolve();
|
||||
_juceShimBatch = null;
|
||||
_juceShimBatchFlushScheduled = false;
|
||||
_juceShimGen++;
|
||||
};
|
||||
|
||||
Object.defineProperty(audio, 'currentTime', {
|
||||
get() {
|
||||
if (window._juceMode) return host.jucePlayer().currentTime;
|
||||
return ctDesc.get.call(this);
|
||||
},
|
||||
set(v) {
|
||||
if (window._juceMode) {
|
||||
const t = Math.max(0, Number(v) || 0);
|
||||
_juceShimBatch = _juceShimBatch || {};
|
||||
_juceShimBatch.seekTime = t;
|
||||
scheduleJuceShimBatchFlush();
|
||||
return;
|
||||
}
|
||||
ctDesc.set.call(this, v);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
Object.defineProperty(audio, 'paused', {
|
||||
get() {
|
||||
if (window._juceMode) return !S.isPlaying;
|
||||
return pausedDesc.get.call(this);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
audio.pause = function () {
|
||||
if (window._juceMode) {
|
||||
_juceShimBatch = _juceShimBatch || {};
|
||||
_juceShimBatch.wantsPause = true;
|
||||
scheduleJuceShimBatchFlush();
|
||||
return;
|
||||
}
|
||||
nativePause.call(audio);
|
||||
};
|
||||
|
||||
audio.play = function () {
|
||||
if (window._juceMode) {
|
||||
if (_juceShimBatch != null) flushJuceShimBatchNow({ forUpcomingPlay: true });
|
||||
const p = enqueue(async (gen) => {
|
||||
const started = await host.jucePlayer().play();
|
||||
if (gen !== _juceShimGen || !started) return;
|
||||
S.isPlaying = true;
|
||||
host.setPlayButtonState(true);
|
||||
const sm = window.feedBack;
|
||||
if (sm) {
|
||||
sm.isPlaying = true;
|
||||
const payload = host._songEventPayload();
|
||||
sm.emit('song:play', payload);
|
||||
sm.emit('song:resume', payload);
|
||||
}
|
||||
});
|
||||
return p.then(() => undefined);
|
||||
}
|
||||
return nativePlay.call(audio);
|
||||
};
|
||||
})();
|
||||
@@ -1,261 +0,0 @@
|
||||
// The A–B 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();
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
// Player controls — the speed and mastery sliders, and the four playback preference
|
||||
// reads (autoplay-exit, up-next, countdown-before-song, confirm-exit).
|
||||
//
|
||||
// The fourth slice out of app.js's strongly-connected core, and by far the easiest:
|
||||
// ONE hook and NO shared mutable state. It is here because these three groups are the
|
||||
// same surface (the controls under the highway) and all three reach the same helper.
|
||||
//
|
||||
// The preference reads are one-line localStorage lookups that half of app.js consults
|
||||
// before deciding whether to auto-start, show the Up Next pill, run a count-in, or
|
||||
// confirm on exit. They travel with the controls that set them.
|
||||
//
|
||||
// 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 { audio } from './audio-el.js';
|
||||
import { host } from './host.js';
|
||||
|
||||
// ── Autoplay & auto-exit (global option, default ON) ──────────────────
|
||||
// One toggle (`autoplayExit` in localStorage) that (a) auto-starts a song
|
||||
// once it's ready and (b) returns to the launching menu when the song
|
||||
// ends. Absence of the key means enabled. The behaviour lives in core
|
||||
// (app.js, shared by the v3 + classic UIs); the end-of-song *score*
|
||||
// screen, when present, is a plugin and hooks the contract below.
|
||||
export function _autoplayExitEnabled() {
|
||||
try { return localStorage.getItem('autoplayExit') !== '0'; } catch (_) { return true; }
|
||||
}
|
||||
|
||||
// ── "Up Next" pill (global option, default ON) ────────────────────────
|
||||
// Gates the v3 player chrome's persistent upcoming-section pill
|
||||
// (#v3-upnext, driven by player-chrome.js's updateUpNext). Client-only
|
||||
// localStorage pref (`showUpNext`); absence of the key means enabled.
|
||||
// player-chrome.js reads window.feedBack.showUpNext each tick and hides
|
||||
// the pill when off.
|
||||
export function _showUpNextEnabled() {
|
||||
try { return localStorage.getItem('showUpNext') !== '0'; } catch (_) { return true; }
|
||||
}
|
||||
|
||||
// "Countdown before song" (Gameplay tab). Mirrored to localStorage by
|
||||
// loadSettings so the song-start path can read it synchronously here — no
|
||||
// async /api/settings fetch on the play hot path. Defaults off.
|
||||
export function _countdownBeforeSongEnabled() {
|
||||
try { return localStorage.getItem('countdownBeforeSong') === '1'; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
export function _curPlaybackSpeed() {
|
||||
try {
|
||||
return window._juceMode
|
||||
? ((window.jucePlayer && window.jucePlayer._speed) || 1)
|
||||
: (document.getElementById('audio')?.playbackRate || 1);
|
||||
} catch (_) { return 1; }
|
||||
}
|
||||
|
||||
// ── "Ask before leaving a song" (Gameplay tab, default OFF) ────────────────
|
||||
// Client-only localStorage pref (`confirmExitSong`); absence = OFF. When ON, a
|
||||
// *user-initiated* exit (Escape, or the player ✕) opens a small confirm instead
|
||||
// of leaving immediately. Auto-exit on song-end and a results screen's own
|
||||
// Close never prompt — they call closeCurrentSong() directly, which stays the
|
||||
// unguarded actual-exit.
|
||||
export function _exitConfirmEnabled() {
|
||||
try { return localStorage.getItem('confirmExitSong') === '1'; } catch (_) { return false; }
|
||||
}
|
||||
|
||||
const SPEED_PRESET_PCTS = [100, 90, 80, 75, 70, 60, 50];
|
||||
const SPEED_SNAP_THRESHOLD = 0.02;
|
||||
let _speedPresetsWired = false;
|
||||
|
||||
function _speedPresetPctFromActive(activePctOrRate) {
|
||||
if (!Number.isFinite(activePctOrRate)) return null;
|
||||
const rate = activePctOrRate <= 1.5 ? activePctOrRate : activePctOrRate / 100;
|
||||
for (const pct of SPEED_PRESET_PCTS) {
|
||||
if (Math.abs(rate - pct / 100) <= SPEED_SNAP_THRESHOLD) return pct;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _updateSpeedPresetButtons(activePctOrRate) {
|
||||
const wrap = document.getElementById('speed-presets');
|
||||
if (!wrap) return;
|
||||
const target = _speedPresetPctFromActive(activePctOrRate);
|
||||
for (const btn of wrap.querySelectorAll('[data-speed-preset]')) {
|
||||
const pct = Number(btn.dataset.speedPreset);
|
||||
btn.classList.toggle('v3-speed-preset-active', target !== null && pct === target);
|
||||
}
|
||||
}
|
||||
|
||||
export function applySpeedPreset(percent) {
|
||||
const slider = document.getElementById('speed-slider');
|
||||
if (!slider) return;
|
||||
const pct = Math.max(
|
||||
Number(slider.min) || 15,
|
||||
Math.min(Number(slider.max) || 150, Number(percent)),
|
||||
);
|
||||
if (!Number.isFinite(pct)) return;
|
||||
slider.value = String(pct);
|
||||
host.handleSliderInput(slider);
|
||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
export function _wireSpeedPresetsOnce() {
|
||||
if (_speedPresetsWired) return;
|
||||
const presets = document.getElementById('speed-presets');
|
||||
if (!presets) return;
|
||||
_speedPresetsWired = true;
|
||||
presets.addEventListener('click', (e) => {
|
||||
const btn = e.target.closest('[data-speed-preset]');
|
||||
if (!btn) return;
|
||||
applySpeedPreset(Number(btn.dataset.speedPreset));
|
||||
});
|
||||
}
|
||||
|
||||
export function setSpeed(v) {
|
||||
const speedSlider = document.getElementById('speed-slider');
|
||||
const rate = Number(v);
|
||||
if (!Number.isFinite(rate)) {
|
||||
return;
|
||||
}
|
||||
if (window._juceMode) {
|
||||
window.jucePlayer?.setRate(rate);
|
||||
const juceAudio = window.feedBackDesktop?.audio;
|
||||
Promise.resolve()
|
||||
.then(() => juceAudio?.setBackingSpeed(rate))
|
||||
// Match the HTML5 path: preserve pitch on the JUCE backing track too.
|
||||
// Optional-chained call is a no-op on desktop builds that predate
|
||||
// setBackingPreservePitch, so this is safe to ship unconditionally.
|
||||
.then(() => juceAudio?.setBackingPreservePitch?.(true))
|
||||
.catch(err => console.warn('[setSpeed] backing speed/preserve-pitch failed:', err));
|
||||
} else {
|
||||
audio.playbackRate = rate;
|
||||
}
|
||||
const speedLabel = document.getElementById('speed-label');
|
||||
if (speedLabel) speedLabel.textContent = rate.toFixed(2) + 'x';
|
||||
host.handleSliderInput(speedSlider);
|
||||
_updateSpeedPresetButtons(rate);
|
||||
}
|
||||
|
||||
export function _resetPlaybackSpeedForNewSong() {
|
||||
// Reset the *actual* playback rate to 1x, not just the visible slider/label
|
||||
// (feedBack#615). The HTML5 <audio> element and the desktop JUCE/backing
|
||||
// engine each retain their own rate, and which one drives the next song
|
||||
// isn't decided until later in the load, so reset all paths unconditionally.
|
||||
// Every setter is idempotent and optional-chained, so this is safe in web
|
||||
// and desktop builds alike — no need to branch on window._juceMode.
|
||||
const speedSlider = document.getElementById('speed-slider');
|
||||
if (speedSlider) speedSlider.value = 100;
|
||||
audio.playbackRate = 1;
|
||||
window.jucePlayer?.setRate?.(1);
|
||||
const juceAudio = window.feedBackDesktop?.audio;
|
||||
Promise.resolve()
|
||||
.then(() => juceAudio?.setBackingSpeed?.(1))
|
||||
.then(() => juceAudio?.setBackingPreservePitch?.(true))
|
||||
.catch(err => console.warn('[resetSpeed] backing speed/preserve-pitch failed:', err));
|
||||
// Mirror setSpeed's UI side-effects (label text + slider fill styling).
|
||||
const speedLabel = document.getElementById('speed-label');
|
||||
if (speedLabel) speedLabel.textContent = (1).toFixed(2) + 'x';
|
||||
host.handleSliderInput(speedSlider);
|
||||
_updateSpeedPresetButtons(100);
|
||||
}
|
||||
// Master-difficulty slider (feedBack#48). Persists partial via
|
||||
// /api/settings — the POST handler merges only the keys present, so
|
||||
// this fire-and-forget call doesn't clobber dlc_dir or other settings.
|
||||
//
|
||||
// Debounced trailing-edge (300ms) so dragging the slider — which fires
|
||||
// oninput per pixel — doesn't flood the server with concurrent writes
|
||||
// to config.json. highway.setMastery() still fires every oninput so
|
||||
// the chart re-filters in real time; only disk persistence waits.
|
||||
let _masteryPersistTimer = null;
|
||||
function _persistMastery(pct) {
|
||||
if (_masteryPersistTimer) clearTimeout(_masteryPersistTimer);
|
||||
_masteryPersistTimer = setTimeout(() => {
|
||||
_masteryPersistTimer = null;
|
||||
fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ master_difficulty: pct }),
|
||||
}).catch(() => { /* best-effort — next setMastery() will retry */ });
|
||||
}, 300);
|
||||
}
|
||||
export function setMastery(v) {
|
||||
_applyMastery(v);
|
||||
}
|
||||
// Shared mastery applier. Master difficulty has two controls that write the
|
||||
// same master_difficulty key: the player-popover slider (#mastery-slider) and
|
||||
// the Gameplay-tab "Note highway speed" slider (#setting-highway-speed). Route
|
||||
// both — and loadSettings' hydration — through here so their positions,
|
||||
// labels, and track fills stay in sync regardless of which the user touches,
|
||||
// plus the live highway re-filter and the debounced persist. All element reads
|
||||
// are null-guarded since either control may be absent (follower window, or the
|
||||
// settings markup not yet rendered).
|
||||
export function _applyMastery(v, opts = {}) {
|
||||
// Guard + clamp: v might be a slider string, a programmatic call from a
|
||||
// plugin, or a restored settings value with a bad shape. Don't let NaN
|
||||
// reach a label (would show "NaN%") or the POST.
|
||||
const parsed = parseInt(v, 10);
|
||||
if (!Number.isFinite(parsed)) return;
|
||||
const pct = Math.max(0, Math.min(100, parsed));
|
||||
const popLabel = document.getElementById('mastery-label');
|
||||
if (popLabel) popLabel.textContent = pct + '%';
|
||||
const popSlider = document.getElementById('mastery-slider');
|
||||
if (popSlider) {
|
||||
if (String(popSlider.value) !== String(pct)) popSlider.value = pct;
|
||||
host.handleSliderInput(popSlider);
|
||||
}
|
||||
const setSlider = document.getElementById('setting-highway-speed');
|
||||
if (setSlider) {
|
||||
if (String(setSlider.value) !== String(pct)) setSlider.value = pct;
|
||||
host.handleSliderInput(setSlider);
|
||||
}
|
||||
// The Gameplay-tab label markup appends a literal "%" after this span
|
||||
// (matching the av-offset "ms" pattern), so write the number alone here —
|
||||
// unlike #mastery-label above, whose markup carries no trailing unit.
|
||||
const setLabel = document.getElementById('setting-highway-speed-val');
|
||||
if (setLabel) setLabel.textContent = pct;
|
||||
highway.setMastery(pct / 100);
|
||||
if (!opts.skipPersist) _persistMastery(pct);
|
||||
}
|
||||
// Reflect phrase-data availability on the slider after every `ready`.
|
||||
// The server omits the `phrases` message entirely for single-level
|
||||
// sources (GP imports, legacy sloppak), so hasPhraseData() is the
|
||||
// right signal to enable/disable the slider.
|
||||
export function _applyMasteryAvailability(hasPhraseData) {
|
||||
const slider = document.getElementById('mastery-slider');
|
||||
if (!slider) return;
|
||||
if (hasPhraseData) {
|
||||
slider.disabled = false;
|
||||
slider.title = 'Master difficulty — low = simpler chart, high = full';
|
||||
} else {
|
||||
slider.disabled = true;
|
||||
slider.title = 'Source chart has a single difficulty level — slider disabled';
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// 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,
|
||||
|
||||
/**
|
||||
* A resume request armed by playSong({ resume }) and consumed on song:ready.
|
||||
* Written by app.js (playSong, and the song:ready listener that consumes it) and
|
||||
* read by the resume-session module — so, like the two above, it cannot be a plain
|
||||
* export.
|
||||
*/
|
||||
pendingResume: null,
|
||||
};
|
||||
@@ -802,62 +802,3 @@ 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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
// Resume last session — the snapshot taken when you leave a song, and the pill that
|
||||
// offers it back.
|
||||
//
|
||||
// The fifth slice out of app.js's strongly-connected core. Small and self-contained:
|
||||
// ONE hook (playSong) plus a currentFilename getter.
|
||||
//
|
||||
// The armed resume request itself lives on the shared container as S.pendingResume,
|
||||
// not here, because app.js WRITES it — playSong({ resume }) arms it and the song:ready
|
||||
// listener consumes it — while this module reads it. An imported binding is read-only,
|
||||
// so shared mutable state has to live on the container. Same reason isPlaying does.
|
||||
//
|
||||
// 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 { host } from './host.js';
|
||||
import { _curPlaybackSpeed } from './player-controls.js';
|
||||
import { S } from './player-state.js';
|
||||
|
||||
// ── Resume last session ────────────────────────────────────────────────────
|
||||
// Leaving a song snapshots where you were — song, arrangement, position, and
|
||||
// speed — so an exit (especially an accidental one, now that Escape reliably
|
||||
// leaves regardless of focus) is recoverable instead of restarting from bar 1.
|
||||
// The snapshot is offered back through a non-blocking "Resume" pill; it never
|
||||
// gates, blocks, or auto-acts. Cleared on natural song-end and once consumed.
|
||||
// (This is the player-session slice; the broader nav/state-resume work — e.g.
|
||||
// returning to a song after wandering into Settings → Tone Builder — is a
|
||||
// separate, larger track.)
|
||||
const _RESUME_KEY = 'feedBack.resumeSession';
|
||||
const _RESUME_MAX_AGE_MS = 24 * 60 * 60 * 1000; // a day-old snapshot is stale
|
||||
const _RESUME_MIN_POSITION_S = 3; // ignore barely-started songs
|
||||
const _RESUME_END_GUARD_S = 5; // ignore basically-finished songs
|
||||
let _resumePillDismissed = false; // per-session: user waved off the current snapshot
|
||||
|
||||
// Snapshot the live session. Called from showScreen()'s teardown before
|
||||
// highway.stop()/audio unload, while getSongInfo() + position are still valid.
|
||||
export function _snapshotResumeSession(position) {
|
||||
try {
|
||||
if (!host.currentFilename()) return;
|
||||
const si = (window.highway && typeof highway.getSongInfo === 'function')
|
||||
? (highway.getSongInfo() || {}) : {};
|
||||
const dur = Number(si.duration) || 0;
|
||||
const pos = Number(position) || 0;
|
||||
// Only worth resuming a song you were genuinely mid-way through — not a
|
||||
// glance at the first seconds, and not one that already basically ended.
|
||||
if (pos < _RESUME_MIN_POSITION_S) { _clearResumeSession(); return; }
|
||||
if (dur && pos > dur - _RESUME_END_GUARD_S) { _clearResumeSession(); return; }
|
||||
const snap = {
|
||||
f: host.currentFilename(),
|
||||
a: (typeof si.arrangement_index === 'number' && si.arrangement_index >= 0)
|
||||
? si.arrangement_index : undefined,
|
||||
t: pos,
|
||||
sp: _curPlaybackSpeed(),
|
||||
title: si.title || '',
|
||||
artist: si.artist || '',
|
||||
ts: Date.now(),
|
||||
};
|
||||
localStorage.setItem(_RESUME_KEY, JSON.stringify(snap));
|
||||
// A fresh snapshot earns one offer — undo any earlier dismissal.
|
||||
_resumePillDismissed = false;
|
||||
} catch (_) { /* storage unavailable — resume is best-effort */ }
|
||||
}
|
||||
|
||||
export function _readResumeSession() {
|
||||
try {
|
||||
const raw = localStorage.getItem(_RESUME_KEY);
|
||||
if (!raw) return null;
|
||||
const snap = JSON.parse(raw);
|
||||
if (!snap || !snap.f || !(Number(snap.t) > 0)) return null;
|
||||
if (!snap.ts || Date.now() - snap.ts > _RESUME_MAX_AGE_MS) { _clearResumeSession(); return null; }
|
||||
return snap;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
export function _clearResumeSession() {
|
||||
try { localStorage.removeItem(_RESUME_KEY); } catch (_) {}
|
||||
}
|
||||
|
||||
// Re-enter the snapshotted song and restore arrangement + position + speed.
|
||||
export async function resumeLastSession() {
|
||||
const snap = _readResumeSession();
|
||||
if (!snap) { _hideResumePill(); return false; }
|
||||
_hideResumePill();
|
||||
try {
|
||||
await host.playSong(snap.f, snap.a, {
|
||||
resume: { position: Number(snap.t) || 0, speed: Number(snap.sp) || 1 },
|
||||
});
|
||||
} catch (err) {
|
||||
// A transient load/connect failure must not strand the user: keep the
|
||||
// snapshot so the pill can re-offer it on the next non-player screen,
|
||||
// rather than consuming the only copy before the song actually loaded.
|
||||
console.warn('[app] resume failed to load; keeping snapshot:', err);
|
||||
S.pendingResume = null;
|
||||
return false;
|
||||
}
|
||||
_clearResumeSession(); // consumed only after a successful load
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Resume pill (non-blocking "continue where you left off") ────────────────
|
||||
// Self-contained, inline-styled, body-appended so it works identically in the
|
||||
// classic (v2) and v3 shells with no Tailwind rebuild. It only ever appears off
|
||||
// the player screen, never blocks, and a dismiss forgets the current snapshot
|
||||
// for the session.
|
||||
export function _hideResumePill() {
|
||||
const el = document.getElementById('fb-resume-pill');
|
||||
if (el) el.remove();
|
||||
}
|
||||
|
||||
export function _maybeShowResumePill() {
|
||||
const active = document.querySelector('.screen.active');
|
||||
if (active && active.id === 'player') { _hideResumePill(); return; }
|
||||
if (_resumePillDismissed) return;
|
||||
const snap = _readResumeSession();
|
||||
if (!snap) { _hideResumePill(); return; }
|
||||
if (document.getElementById('fb-resume-pill')) return; // already shown
|
||||
|
||||
const label = (snap.title || decodeURIComponent(snap.f || 'your last song')).toString();
|
||||
const pill = document.createElement('div');
|
||||
pill.id = 'fb-resume-pill';
|
||||
pill.setAttribute('role', 'status');
|
||||
pill.style.cssText = [
|
||||
'position:fixed', 'left:16px', 'bottom:16px', 'z-index:120',
|
||||
'display:flex', 'align-items:center', 'gap:10px',
|
||||
'max-width:min(90vw,360px)', 'padding:10px 12px',
|
||||
'background:rgba(17,24,39,0.96)', 'color:#e5e7eb',
|
||||
'border:1px solid rgba(148,163,184,0.25)', 'border-radius:10px',
|
||||
'box-shadow:0 6px 24px rgba(0,0,0,0.4)',
|
||||
'font:13px/1.3 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif',
|
||||
].join(';');
|
||||
|
||||
const text = document.createElement('div');
|
||||
text.style.cssText = 'flex:1;min-width:0';
|
||||
const t1 = document.createElement('div');
|
||||
t1.textContent = 'Resume practice';
|
||||
t1.style.cssText = 'font-weight:600;color:#fff';
|
||||
const t2 = document.createElement('div');
|
||||
t2.textContent = label;
|
||||
t2.style.cssText = 'opacity:0.7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis';
|
||||
text.appendChild(t1); text.appendChild(t2);
|
||||
|
||||
const resumeBtn = document.createElement('button');
|
||||
resumeBtn.type = 'button';
|
||||
resumeBtn.textContent = 'Resume ▸';
|
||||
resumeBtn.style.cssText = 'flex:none;padding:6px 10px;border:0;border-radius:7px;background:#4080e0;color:#fff;font-weight:600;cursor:pointer';
|
||||
resumeBtn.addEventListener('click', () => { resumeLastSession(); });
|
||||
|
||||
const dismissBtn = document.createElement('button');
|
||||
dismissBtn.type = 'button';
|
||||
dismissBtn.setAttribute('aria-label', 'Dismiss');
|
||||
dismissBtn.textContent = '✕';
|
||||
dismissBtn.style.cssText = 'flex:none;padding:4px 6px;border:0;border-radius:7px;background:transparent;color:#9ca3af;cursor:pointer;font-size:14px';
|
||||
dismissBtn.addEventListener('click', () => { _resumePillDismissed = true; _hideResumePill(); });
|
||||
|
||||
pill.appendChild(text);
|
||||
pill.appendChild(resumeBtn);
|
||||
pill.appendChild(dismissBtn);
|
||||
(document.body || document.documentElement).appendChild(pill);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,155 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -14,16 +14,10 @@ const vm = require('node:vm');
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// _autoplayExitEnabled was carved out into static/js/player-controls.js (R3a); the
|
||||
// auto-exit machinery around it (_clearAutoExit, holdAutoExit, _resolvePlayerOrigin)
|
||||
// stayed in app.js.
|
||||
const CONTROLS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'player-controls.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||
// the module is ESM; these sandboxes evaluate plain script text
|
||||
const CONTROLS_SRC = fs.readFileSync(CONTROLS_JS, 'utf8').replace(/^export /gm, '');
|
||||
|
||||
function runEnabled(stored) {
|
||||
const fnSrc = extractFunction(CONTROLS_SRC, 'function _autoplayExitEnabled(');
|
||||
const fnSrc = extractFunction(SRC, 'function _autoplayExitEnabled(');
|
||||
const sandbox = {
|
||||
localStorage: {
|
||||
getItem: () => {
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
// 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',
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// Behavioral tests for the JUCE engine-reroute watcher in static/js/juce-audio.js.
|
||||
// Behavioral tests for the JUCE engine-reroute watcher in static/app.js.
|
||||
//
|
||||
// The watcher (an IIFE, `_installJuceEngineRoutingWatcher`) migrates a loaded
|
||||
// song between the HTML5 <audio> element and the native JUCE backing transport
|
||||
@@ -14,15 +14,14 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
// The JUCE audio shims were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'juce-audio.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
// Brace-balanced extraction of the watcher IIFE, starting at its `(function`
|
||||
// and ending after the matching `})();`.
|
||||
function extractWatcherIIFE(src) {
|
||||
const marker = '(function _installJuceEngineRoutingWatcher() {';
|
||||
const start = src.indexOf(marker);
|
||||
assert.ok(start !== -1, 'watcher IIFE not found in static/js/juce-audio.js');
|
||||
assert.ok(start !== -1, 'watcher IIFE not found in app.js');
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
@@ -85,11 +84,7 @@ function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows A
|
||||
json: () => Promise.resolve({ path: '/local/song.ogg' }),
|
||||
}),
|
||||
document: { hidden: false },
|
||||
// `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 },
|
||||
isPlaying: true,
|
||||
audio,
|
||||
jucePlayer,
|
||||
__calls: calls,
|
||||
@@ -101,17 +96,6 @@ function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows A
|
||||
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const iife = extractWatcherIIFE(src);
|
||||
// The shims reach back into app.js through the host seam (static/js/host.js).
|
||||
// Route it at the SAME stubs this sandbox already had — a fresh `() => {}` would
|
||||
// swallow the calls and the assertions below would pass vacuously.
|
||||
sandbox.host = {
|
||||
jucePlayer: () => sandbox.jucePlayer,
|
||||
playSong: (...a) => (sandbox.playSong ? sandbox.playSong(...a) : undefined),
|
||||
_audioSeek: (...a) => (sandbox._audioSeek ? sandbox._audioSeek(...a) : Promise.resolve({ completed: true })),
|
||||
setPlayButtonState: (...a) => (sandbox.setPlayButtonState ? sandbox.setPlayButtonState(...a) : undefined),
|
||||
_songEventPayload: (...a) => (sandbox._songEventPayload ? sandbox._songEventPayload(...a) : ({})),
|
||||
showScreen: (...a) => (sandbox.showScreen ? sandbox.showScreen(...a) : undefined),
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(iife, sandbox);
|
||||
return sandbox;
|
||||
|
||||
+12
-44
@@ -11,16 +11,11 @@ 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(rawSrc, signature) {
|
||||
// loops.js is an ES module; the vm sandbox evaluates plain script text.
|
||||
const src = rawSrc.replace(/^export /gm, '');
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in static/js/loops.js`);
|
||||
if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`);
|
||||
let scan = start + signature.length;
|
||||
if (src[scan] === '(') {
|
||||
let parenDepth = 1;
|
||||
@@ -49,18 +44,10 @@ 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.
|
||||
@@ -94,7 +81,6 @@ 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: {
|
||||
@@ -103,19 +89,6 @@ 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;
|
||||
}
|
||||
@@ -148,7 +121,7 @@ function loadFunctions(sandbox, src) {
|
||||
}
|
||||
|
||||
test('setLoop mutates loopA/loopB and seeks to A', async () => {
|
||||
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
@@ -164,7 +137,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(LOOPS_JS, 'utf8');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
sandbox._audioSeek = () => Promise.resolve({ completed: false, from: NaN, to: NaN });
|
||||
loadFunctions(sandbox, src);
|
||||
@@ -181,7 +154,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(LOOPS_JS, 'utf8');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
sandbox._audioSeek = (s) => Promise.resolve({ completed: true, from: 0, to: s + 0.5 });
|
||||
loadFunctions(sandbox, src);
|
||||
@@ -199,7 +172,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(LOOPS_JS, 'utf8');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
@@ -210,7 +183,7 @@ test('setLoop coerces string inputs (parseFloat-style)', async () => {
|
||||
});
|
||||
|
||||
test('setLoop rejects non-finite inputs', async () => {
|
||||
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
@@ -220,7 +193,7 @@ test('setLoop rejects non-finite inputs', async () => {
|
||||
});
|
||||
|
||||
test('setLoop rejects b <= a', async () => {
|
||||
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
@@ -228,8 +201,8 @@ test('setLoop rejects b <= a', async () => {
|
||||
await assert.rejects(() => sandbox.__setLoop(10, 5), /b > a/);
|
||||
});
|
||||
|
||||
test('clearLoop resets loopA/loopB to null (and asks section-practice to drop its selection)', async () => {
|
||||
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||
test('clearLoop resets loopA/loopB to null', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
@@ -238,11 +211,6 @@ test('clearLoop resets loopA/loopB to null (and asks section-practice to drop it
|
||||
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.
|
||||
@@ -250,7 +218,7 @@ test('clearLoop resets loopA/loopB to null (and asks section-practice to drop it
|
||||
});
|
||||
|
||||
test('loop helpers emit transport snapshots by default and can suppress adapter echoes', async () => {
|
||||
const src = fs.readFileSync(LOOPS_JS, 'utf8');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const sandbox = buildSandbox();
|
||||
loadFunctions(sandbox, src);
|
||||
|
||||
@@ -288,7 +256,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(LOOPS_JS, 'utf8');
|
||||
const src = fs.readFileSync(APP_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;
|
||||
|
||||
@@ -14,8 +14,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
// startCountIn was carved out of app.js into its own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'count-in.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
// Pull a function body by declaration prefix (e.g. `async function startCountIn`)
|
||||
// and brace-matching to the closing brace. Skips an optional `( ... )` param
|
||||
@@ -56,10 +55,8 @@ function buildSandbox() {
|
||||
loopA: 10,
|
||||
loopB: 20,
|
||||
_countingIn: false,
|
||||
// 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 },
|
||||
isPlaying: false,
|
||||
lastAudioTime: 0,
|
||||
|
||||
// Browser-ish globals.
|
||||
performance: { now: () => Date.now() },
|
||||
@@ -112,23 +109,12 @@ function buildSandbox() {
|
||||
__emitCalls: emitCalls,
|
||||
queueMicrotask,
|
||||
};
|
||||
// startCountIn was carved into static/js/count-in.js and now reaches back into
|
||||
// app.js through the host seam (static/js/host.js). Point the seam at the SAME
|
||||
// stubs 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),
|
||||
setPlayButtonState: () => {},
|
||||
_songEventPayload: () => ({}),
|
||||
togglePlay: () => {},
|
||||
jucePlayer: () => sandbox.jucePlayer,
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
test('loop:restart fires once when wrap path runs', async () => {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const startCountInSrc = extractFunction(src, 'async function startCountIn');
|
||||
|
||||
// Sanity check: the change under test is present at all. Catches
|
||||
@@ -149,7 +135,8 @@ test('loop:restart fires once when wrap path runs', async () => {
|
||||
var _countInGen = 0;
|
||||
var _countInTimer = null;
|
||||
var _countInRaf = 0;
|
||||
var S = { isPlaying: false, lastAudioTime: 0 };
|
||||
var isPlaying = false;
|
||||
var lastAudioTime = 0;
|
||||
${startCountInSrc}
|
||||
globalThis.__startCountIn = startCountIn;
|
||||
`;
|
||||
@@ -179,7 +166,7 @@ test('loop:restart aborts when seek lands far from loopA (JUCE rollback)', async
|
||||
// _audioSeek resolves with completed:true but r.to !== loopA. The
|
||||
// wrap handler must abort instead of running beginCount on the wrong
|
||||
// position and emitting a misleading loop:restart.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const startCountInSrc = extractFunction(src, 'async function startCountIn');
|
||||
|
||||
const sandbox = buildSandbox();
|
||||
@@ -193,7 +180,8 @@ test('loop:restart aborts when seek lands far from loopA (JUCE rollback)', async
|
||||
var _countInGen = 0;
|
||||
var _countInTimer = null;
|
||||
var _countInRaf = 0;
|
||||
var S = { isPlaying: false, lastAudioTime: 0 };
|
||||
var isPlaying = false;
|
||||
var lastAudioTime = 0;
|
||||
${startCountInSrc}
|
||||
globalThis.__startCountIn = startCountIn;
|
||||
globalThis.__getCountingIn = () => _countingIn;
|
||||
@@ -214,7 +202,7 @@ test('count-in cancellation token bails delayed callbacks (rewindStep + tick)',
|
||||
// teardown can interrupt an in-flight count-in. Behavioral simulation
|
||||
// of timer cancellation is out of scope for the static extractor; this
|
||||
// verifies the contract is wired into the source.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const fn = extractFunction(src, 'async function startCountIn');
|
||||
// Captures gen at entry
|
||||
assert.match(fn, /const gen = _countInGen/, 'startCountIn must capture _countInGen at entry');
|
||||
@@ -230,7 +218,7 @@ test('loop:restart fires after highway.setTime, before beginCount', () => {
|
||||
// Source-order assertion on the A-B wrap path only. Section-practice
|
||||
// `opts.immediate` also emits loop:restart but is a separate entry path;
|
||||
// the wrap handler lives inside the `_audioSeek(loopA, 'loop-wrap')` then.
|
||||
const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const fn = extractFunction(src, 'async function startCountIn');
|
||||
const wrapMarker = "_audioSeek(loopA, 'loop-wrap')";
|
||||
const wrapStart = fn.indexOf(wrapMarker);
|
||||
|
||||
@@ -29,11 +29,8 @@ async function runTogglePlayRejecting({ rerouteInProgress }) {
|
||||
const buttonStates = [];
|
||||
const sandbox = {
|
||||
console: { log() {}, warn() {}, error() {} },
|
||||
// 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 },
|
||||
// not-playing -> togglePlay takes the HTML5 play branch
|
||||
isPlaying: false,
|
||||
_audioSeekGen: 0,
|
||||
_playAttemptGen: 0,
|
||||
setPlayButtonState(v) { buttonStates.push(v); },
|
||||
@@ -54,7 +51,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.S.isPlaying };
|
||||
return { buttonStates, isPlaying: sandbox.isPlaying };
|
||||
}
|
||||
|
||||
test('reroute-aborted play() leaves the button on Pause (isPlaying stays true)', async () => {
|
||||
|
||||
@@ -84,8 +84,5 @@ test('playback adapter suppresses duplicate HTML5 pause events before emitting c
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
|
||||
|
||||
// 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*\}/);
|
||||
assert.match(fn, /if \(!window\._juceMode && wasPlaying\) \{\s*isPlaying = false;\s*window\.feedBack\.isPlaying = false;\s*audio\.pause\(\);\s*_markPlaybackPaused\(\);\s*\}/);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Behavioral tests for the renderer-audio bus feeder in static/js/juce-audio.js.
|
||||
// Behavioral tests for the renderer-audio bus feeder in static/app.js.
|
||||
//
|
||||
// The feeder (an IIFE, `_installRendererBusFeeder`) captures renderer-side
|
||||
// song audio (stems-plugin WebAudio master, or the core <audio> element) and
|
||||
@@ -16,13 +16,12 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
// The JUCE audio shims were carved out of app.js into their own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'juce-audio.js');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
|
||||
function extractFeederIIFE(src) {
|
||||
const marker = '(function _installRendererBusFeeder() {';
|
||||
const start = src.indexOf(marker);
|
||||
assert.ok(start !== -1, 'feeder IIFE not found in static/js/juce-audio.js');
|
||||
assert.ok(start !== -1, 'feeder IIFE not found in app.js');
|
||||
const openBrace = src.indexOf('{', start);
|
||||
let depth = 1;
|
||||
let i = openBrace + 1;
|
||||
@@ -124,17 +123,6 @@ function makeSandbox({ isAudioRunning = () => true, exclusive = () => true, disp
|
||||
sandbox.globalThis = sandbox;
|
||||
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
// The shims reach back into app.js through the host seam (static/js/host.js).
|
||||
// Route it at the SAME stubs this sandbox already had — a fresh `() => {}` would
|
||||
// swallow the calls and the assertions below would pass vacuously.
|
||||
sandbox.host = {
|
||||
jucePlayer: () => sandbox.jucePlayer,
|
||||
playSong: (...a) => (sandbox.playSong ? sandbox.playSong(...a) : undefined),
|
||||
_audioSeek: (...a) => (sandbox._audioSeek ? sandbox._audioSeek(...a) : Promise.resolve({ completed: true })),
|
||||
setPlayButtonState: (...a) => (sandbox.setPlayButtonState ? sandbox.setPlayButtonState(...a) : undefined),
|
||||
_songEventPayload: (...a) => (sandbox._songEventPayload ? sandbox._songEventPayload(...a) : ({})),
|
||||
showScreen: (...a) => (sandbox.showScreen ? sandbox.showScreen(...a) : undefined),
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(extractFeederIIFE(src), sandbox);
|
||||
assert.equal(typeof sandbox.window._reevaluateRendererBus, 'function',
|
||||
|
||||
@@ -15,10 +15,9 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
// _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 src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8');
|
||||
const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/);
|
||||
assert.ok(m, '_installSectionPracticeDismiss() not found in static/js/section-practice.js');
|
||||
assert.ok(m, '_installSectionPracticeDismiss() not found in static/app.js');
|
||||
const body = m[0];
|
||||
|
||||
test('the outside-click dismiss binds in the CAPTURE phase', () => {
|
||||
|
||||
@@ -14,9 +14,8 @@ const vm = require('node:vm');
|
||||
|
||||
const { extractFunction } = require('./test_utils');
|
||||
|
||||
// the song-credits overlay was carved out of app.js into its own module (R3a).
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'count-in.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
const SRC = fs.readFileSync(APP_JS, 'utf8');
|
||||
|
||||
// Minimal fake DOM element: records className, children, and textContent.
|
||||
// Setting textContent clears children (matching real DOM) so we can assert
|
||||
|
||||
@@ -19,9 +19,7 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
|
||||
const sandbox = {
|
||||
loopA,
|
||||
loopB,
|
||||
// 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 },
|
||||
isPlaying,
|
||||
__cancelCountInCalls: 0,
|
||||
__seekCalls: [],
|
||||
__startCountInCalls: [],
|
||||
@@ -44,7 +42,7 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
|
||||
},
|
||||
__togglePlay() {
|
||||
sandbox.__togglePlayCalls++;
|
||||
sandbox.S.isPlaying = true;
|
||||
sandbox.isPlaying = true;
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
@@ -55,7 +53,7 @@ function buildSandbox({ loopA = null, loopB = null, isPlaying = false } = {}) {
|
||||
function loadRestart(sandbox, src, { audioSeekImpl } = {}) {
|
||||
const restartSrc = extractFunction(src, 'async function restartCurrentSong(');
|
||||
const code = `
|
||||
var S = { isPlaying: ${sandbox.S.isPlaying}, lastAudioTime: 0 };
|
||||
var isPlaying = ${sandbox.isPlaying};
|
||||
function _cancelCountIn() { __cancelCountInCalls++; }
|
||||
async function _audioSeek(s, reason) {
|
||||
return (${audioSeekImpl || '__audioSeek'})(s, reason);
|
||||
|
||||
@@ -77,10 +77,7 @@ 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.
|
||||
// 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 };
|
||||
let 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.
|
||||
|
||||
@@ -5,9 +5,6 @@ const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
|
||||
// The speed controls were carved out into static/js/player-controls.js (R3a); playSong,
|
||||
// which resets them on a new song, stayed in app.js. This test spans both.
|
||||
const CONTROLS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'player-controls.js');
|
||||
|
||||
function extractFunction(src, signature) {
|
||||
const start = src.indexOf(signature);
|
||||
@@ -133,30 +130,20 @@ function extractConstLine(src, name) {
|
||||
|
||||
function loadPlaySong(sandbox) {
|
||||
const src = fs.readFileSync(APP_JS, 'utf8');
|
||||
// the module is ESM; the vm sandbox evaluates plain script text
|
||||
const controls = fs.readFileSync(CONTROLS_JS, 'utf8').replace(/^export /gm, '');
|
||||
const resetHelper = controls.includes('function _resetPlaybackSpeedForNewSong')
|
||||
? extractFunction(controls, 'function _resetPlaybackSpeedForNewSong')
|
||||
const resetHelper = src.includes('function _resetPlaybackSpeedForNewSong')
|
||||
? extractFunction(src, 'function _resetPlaybackSpeedForNewSong')
|
||||
: '';
|
||||
const speedPresetHelpers = controls.includes('function _updateSpeedPresetButtons')
|
||||
const speedPresetHelpers = src.includes('function _updateSpeedPresetButtons')
|
||||
? `
|
||||
${extractConstLine(controls, 'SPEED_PRESET_PCTS')}
|
||||
${extractConstLine(controls, 'SPEED_SNAP_THRESHOLD')}
|
||||
${extractFunction(controls, 'function _speedPresetPctFromActive')}
|
||||
${extractFunction(controls, 'function _updateSpeedPresetButtons')}
|
||||
${extractConstLine(src, 'SPEED_PRESET_PCTS')}
|
||||
${extractConstLine(src, 'SPEED_SNAP_THRESHOLD')}
|
||||
${extractFunction(src, 'function _speedPresetPctFromActive')}
|
||||
${extractFunction(src, 'function _updateSpeedPresetButtons')}
|
||||
`
|
||||
: '';
|
||||
const code = `
|
||||
var artAbortController = null;
|
||||
// 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 };
|
||||
// The speed controls reach app.js through the host seam (static/js/host.js).
|
||||
// Route it at the sandbox's EXISTING handleSliderInput spy — a fresh stub would
|
||||
// swallow the call and the assertion below (which checks the slider was actually
|
||||
// refreshed) would pass vacuously.
|
||||
var host = { handleSliderInput: (el) => handleSliderInput(el) };
|
||||
var isPlaying = true;
|
||||
var currentFilename = null;
|
||||
var _playerOriginScreen = null;
|
||||
var _pendingAutostart = false;
|
||||
@@ -176,7 +163,7 @@ function loadPlaySong(sandbox) {
|
||||
function _scheduleSectionPracticeRetries() {}
|
||||
function loadSavedLoops() {}
|
||||
function _songEventPayload() { return { time: 7, audioT: 7, chartT: 7, perfNow: 7 }; }
|
||||
${extractFunction(controls, 'function setSpeed')}
|
||||
${extractFunction(src, 'function setSpeed')}
|
||||
${speedPresetHelpers}
|
||||
${resetHelper}
|
||||
${extractFunction(src, 'async function playSong')}
|
||||
|
||||
@@ -186,16 +186,10 @@ 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
|
||||
# `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 "const hadPlayableSong = !!audio.src || !!window._juceAudioUrl || isPlaying" in source
|
||||
assert "sm.emit('song:resume', payload)" in source
|
||||
assert "window.feedBack.emit('song:resume', payload)" in source
|
||||
|
||||
# The JUCE audio-element shim — which re-emits song:resume through the session
|
||||
# manager when JUCE owns the transport — was carved out into its own module (R3a).
|
||||
juce = (ROOT / "static" / "js" / "juce-audio.js").read_text(encoding="utf-8")
|
||||
assert "sm.emit('song:resume', payload)" in juce
|
||||
|
||||
|
||||
def test_nam_and_stems_use_owner_claim_dispatch_semantics():
|
||||
nam_source = _sibling_text("feedBack-plugin-nam-tone", "screen.js", "NAM_STEM_CLAIM_ID = 'nam.amp-active'")
|
||||
|
||||
Reference in New Issue
Block a user