diff --git a/static/app.js b/static/app.js
index 2cbecba..bda30e3 100644
--- a/static/app.js
+++ b/static/app.js
@@ -127,35 +127,18 @@ import {
toggleSectionPracticePopover,
} from './js/section-practice.js';
import { configureHost } from './js/host.js';
+// The playback transport. These used to BE app.js — they are imported back now, and the
+// four modules that reached for them through the host seam import them directly instead.
+import {
+ setPlayButtonState, jucePlayer, _audioTime, _audioDuration, _songEventPayload,
+ _markPlaybackPaused, _markPlaybackResumed, _emitPlaybackStopped, _emitSongPositionChanged,
+ _waitForSongReady, _resetAudioSeekState, _audioSeek, togglePlay, seekBy, audioSeekGen,
+} from './js/transport.js';
// Demo analytics — real impl set by demo.js; no-op in normal builds
window.feedBackDemoTrack = window.feedBackDemoTrack ?? null;
-// Sync the play/pause button's icon and accessible state in one place so
-// screen readers, tooltips, and aria-pressed stay aligned with playback.
-// Updates the existing child's src in place rather than rewriting
-// innerHTML, so any future children (fallback label, loading spinner, …)
-// survive state changes.
-function setPlayButtonState(isPlaying) {
- const btn = document.getElementById('btn-play');
- if (!btn) return;
- const label = isPlaying ? 'Pause' : 'Play';
- const icon = isPlaying ? 'pause' : 'play';
- let img = btn.querySelector('img.button-icon-svg');
- if (!img) {
- img = document.createElement('img');
- img.className = 'button-icon-svg';
- img.alt = '';
- img.setAttribute('aria-hidden', 'true');
- btn.appendChild(img);
- }
- img.src = `/static/svg/${icon}.svg`;
- btn.setAttribute('aria-label', label);
- btn.setAttribute('aria-pressed', isPlaying ? 'true' : 'false');
- btn.title = label;
-}
-
// ── Global keyboard shortcuts ─────────────────────────────────────────────
//
// `/` focuses the active screen's search input (Library / Favorites);
@@ -3502,20 +3485,6 @@ function retuneSong(filename, title, tuning, target) {
};
}
-// ── Player ───────────────────────────────────────────────────────────────
-// `audio` now lives in ./js/audio-el.js so carved-out modules can reach the
-// player without importing app.js back (which would close a cycle). Same
-// element, same handle, same lookup — just imported instead of declared here.
-let _lastSongPositionEventAt = 0;
-
-function _emitSongPositionChanged(time, duration) {
- const now = Date.now();
- if (now - _lastSongPositionEventAt < 250) return;
- _lastSongPositionEventAt = now;
- const payload = (typeof _songEventPayload === 'function') ? _songEventPayload() : { time };
- window.feedBack.emit('song:position-changed', Object.assign(payload, { duration }));
-}
-
function _applyPreservePitch(el) {
if (!el) return;
if ('preservesPitch' in el) el.preservesPitch = true;
@@ -3529,94 +3498,6 @@ _applyPreservePitch(audio);
// through the JUCE backing track player instead of the HTML5 element.
window._juceMode = false;
window._juceAudioUrl = null;
-const jucePlayer = {
- _timer: null,
- _pos: 0,
- _dur: 0,
- _pollAt: 0, // performance.now() when _pos was last set
- _polling: false,
- _speed: 1,
- get currentTime() {
- if (!this._polling) return this._pos;
- // Interpolate between IPC polls so highway motion is smooth at 60fps
- // Scale by _speed so at 0.7x the interpolated clock advances 0.7s/s
- const elapsed = (performance.now() - this._pollAt) / 1000;
- return Math.min(this._pos + elapsed * this._speed, this._dur > 0 ? this._dur : Infinity);
- },
- get duration() { return this._dur; },
- async play() {
- try {
- await window.feedBackDesktop.audio.startBacking();
- } catch (err) {
- console.warn('[jucePlayer] startBacking failed:', err);
- return false;
- }
- this._startPolling();
- return true;
- },
- async pause() {
- // Snapshot the interpolated position before stopping the poll so
- // _pos stays at the visible pause point rather than jumping back
- // to the last raw IPC sample (which can be up to 100ms behind).
- this._pos = this.currentTime;
- this._pollAt = performance.now();
- this._stopPolling();
- try {
- await window.feedBackDesktop.audio.stopBacking();
- } catch (err) {
- console.warn('[jucePlayer] stopBacking failed:', err);
- }
- },
- async seek(s) {
- const prev = this._pos;
- this._pos = s;
- this._pollAt = performance.now();
- try {
- await window.feedBackDesktop.audio.seekBacking(s);
- } catch (err) {
- console.warn('[jucePlayer] seekBacking failed:', err);
- this._pos = prev;
- this._pollAt = performance.now();
- }
- },
- _startPolling() {
- this._stopPolling();
- this._polling = true;
- this._pollAt = performance.now();
- const self = this;
- function scheduleNext() {
- self._timer = setTimeout(async () => {
- if (!self._polling) return;
- try {
- self._pos = await window.feedBackDesktop.audio.getBackingPosition();
- self._pollAt = performance.now();
- _emitSongPositionChanged(self.currentTime, self.duration || null);
- } catch (err) {
- console.warn('[jucePlayer] position poll failed:', err);
- } finally {
- if (self._polling) scheduleNext();
- }
- }, 100);
- }
- scheduleNext();
- },
- _stopPolling() {
- this._polling = false;
- if (this._timer) { clearTimeout(this._timer); this._timer = null; }
- },
- setRate(rate) {
- this._pos = this.currentTime;
- this._pollAt = performance.now();
- this._speed = rate;
- },
- async stop() {
- await this.pause();
- this._pos = 0;
- this._dur = 0;
- this._pollAt = 0;
- this._speed = 1;
- },
-};
window.jucePlayer = jucePlayer;
// ── Engine start/stop → re-route song audio (HTML5 ⇄ JUCE) ──────────────────
@@ -3650,141 +3531,6 @@ window.addEventListener('unhandledrejection', (e) => {
-function _audioTime() { return window._juceMode ? jucePlayer.currentTime : audio.currentTime; }
-function _audioDuration() { return window._juceMode ? jucePlayer.duration : audio.duration; }
-// Canonical payload for song:play/song:pause/song:ended. Plugins anchor
-// their own clocks against `perfNow` (a monotonic timestamp at the same
-// moment audio reports `audioT`) so they don't have to chase the chart
-// clock with a follow-up call. `time` is kept as an alias for `audioT`
-// because pre-existing plugins read e.detail.time.
-function _songEventPayload() {
- const audioT = _audioTime();
- return {
- time: audioT,
- audioT,
- chartT: highway.getTime(),
- perfNow: performance.now(),
- };
-}
-
-function _markPlaybackPaused() {
- S.isPlaying = false;
- setPlayButtonState(false);
- if (window.feedBack) {
- window.feedBack.isPlaying = false;
- window.feedBack.emit('song:pause', _songEventPayload());
- }
-}
-
-function _markPlaybackResumed() {
- S.isPlaying = true;
- setPlayButtonState(true);
- if (window.feedBack) {
- window.feedBack.isPlaying = true;
- const payload = _songEventPayload();
- window.feedBack.emit('song:play', payload);
- window.feedBack.emit('song:resume', payload);
- }
-}
-
-function _emitPlaybackStopped(time, screen = 'playback-command') {
- if (window.feedBack) window.feedBack.emit('song:stop', { time: time || 0, screen });
-}
-
-function _waitForSongReady(expectedSeekGen, timeoutMs = 10000) {
- if (!window.feedBack || typeof window.feedBack.on !== 'function') return Promise.resolve(false);
- return new Promise(resolve => {
- let timer = null;
- const done = value => {
- if (timer !== null) clearTimeout(timer);
- window.feedBack.off('song:ready', onReady);
- resolve(value);
- };
- const onReady = () => done(expectedSeekGen == null || expectedSeekGen === _audioSeekGen);
- window.feedBack.on('song:ready', onReady);
- timer = setTimeout(() => done(false), timeoutMs);
- });
-}
-// Serializes seeks so concurrent callers (e.g. user ⏪ during a loop wrap)
-// don't interleave their from/to reads — each call captures `from` only
-// once the previous seek + emit have completed. The generation token
-// lets session teardown invalidate queued seeks so they don't run against
-// the new player and emit a stale song:seek.
-let _audioSeekChain = Promise.resolve();
-let _audioSeekGen = 0;
-function _resetAudioSeekState() {
- // Bump the generation — in-flight chain callbacks see the mismatch on
- // their next guard check and short-circuit (no emit, no further state
- // mutation by us). Don't reset the chain head: new seeks must still
- // queue behind the in-flight old seek's IPC so two `jucePlayer.seek()`
- // calls can't race in the JUCE backing engine. The queue drains
- // quickly because each subsequent old-gen step bails on the first
- // guard the moment its predecessor resolves.
- _audioSeekGen++;
-}
-// Time-box the JUCE IPC so a single hung seek can't block the global
-// _audioSeekChain forever (which would freeze every subsequent reposition
-// path: seekBy, loop-wrap, jump-fix, shimmed audio.currentTime).
-const _JUCE_SEEK_TIMEOUT_MS = 2000;
-function _juceSeekWithTimeout(s) {
- let timer;
- const seekP = jucePlayer.seek(s);
- const timeoutP = new Promise((_, reject) => {
- timer = setTimeout(() => reject(new Error('JUCE seek timed out')), _JUCE_SEEK_TIMEOUT_MS);
- });
- // Clear the timer once the race settles either way; without this the
- // pending timeout keeps the event loop alive (and eventually rejects
- // an unawaited promise) even after a successful seek.
- return Promise.race([seekP, timeoutP]).finally(() => clearTimeout(timer));
-}
-// Resolves to `{ completed, from, to }`:
-// - completed: true if the seek ran to completion and emitted song:seek;
-// false if cancelled by a teardown gen bump (or threw).
-// - from: chart clock just before the seek (NaN on cancel before from-read).
-// - to: verified post-seek clock (NaN on cancel/throw).
-// Callers that fire follow-up work after the seek (count-in, arrangement
-// restore, etc.) should check `completed` so they don't act on a torn-down
-// session. Callers that need the actual landed position (because JUCE may
-// clamp or HTML5 may snap to the seekable range) should read `to` rather
-// than re-using the requested `s`.
-async function _audioSeek(s, reason) {
- // Single funnel for every audio repositioning. Emits song:seek so
- // plugins (notedetect detection-suppression during seek transients,
- // practice-journal segment tracking) can react to any chart-time
- // jump regardless of which UI path triggered it. `reason` is a
- // free-form short string ('seek-by', 'loop-wrap', 'loop-set',
- // 'arrangement-restore', 'jump-fix') so subscribers can filter.
- const gen = _audioSeekGen;
- _audioSeekChain = _audioSeekChain.then(async () => {
- if (gen !== _audioSeekGen) return { completed: false, from: NaN, to: NaN };
- const from = _audioTime();
- if (window._juceMode) await _juceSeekWithTimeout(s);
- else audio.currentTime = s;
- if (gen !== _audioSeekGen) return { completed: false, from, to: NaN };
- // Read the verified post-seek position rather than the requested `s`
- // so plugins observe the actual clock — JUCE may clamp or roll back,
- // and HTML5 may snap to the nearest seekable range.
- const to = _audioTime();
- // Sync the jump-fix tracker so the next 60Hz tick doesn't see a
- // legitimate far seek (e.g. saved-loop jump > 30s) as a browser
- // bug and revert it.
- S.lastAudioTime = to;
- // Sync the chart clock too so any song:* emit fired right after
- // _audioSeek resolves (e.g. the auto-resume song:play in
- // changeArrangement) sees an in-sync chartT via _songEventPayload.
- // Without this, chartT lags by one 60Hz tick after a seek.
- if (typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function') {
- highway.setTime(to);
- }
- window.feedBack.emit('song:seek', { from, to, reason: reason || null });
- return { completed: true, from, to };
- }).catch((err) => {
- // Don't let one failed seek poison subsequent ones.
- console.warn('[_audioSeek]', err);
- return { completed: false, from: NaN, to: NaN };
- });
- return _audioSeekChain;
-}
let currentFilename = '';
// Plugin context API — lightweight event bus for plugin integration
@@ -3934,7 +3680,7 @@ function _installPlaybackTransportAdapter() {
try { decodeURIComponent(playbackFilename); }
catch (_) { playbackFilename = encodeURIComponent(filename); }
const shouldSeekStart = Number.isFinite(Number(args && args.startTime));
- const expectedSeekGen = _audioSeekGen + 1;
+ const expectedSeekGen = audioSeekGen() + 1;
const ready = shouldSeekStart ? _waitForSongReady(expectedSeekGen) : null;
await playSong(playbackFilename, args && args.arrangement, { bridge: false });
const becameReady = ready ? await ready : true;
@@ -4792,73 +4538,6 @@ async function changeArrangement(index) {
}
}
-// Per-attempt counter for HTML5 audio.play() invocations. Bumped on
-// every play branch entry so a slow rejection from attempt N can't
-// clobber the UI of a newer attempt N+1 within the same session.
-let _playAttemptGen = 0;
-
-async function togglePlay() {
- if (window._juceMode) {
- if (S.isPlaying) {
- await jucePlayer.pause();
- S.isPlaying = false;
- setPlayButtonState(false);
- window.feedBack.isPlaying = false;
- window.feedBack.emit('song:pause', _songEventPayload());
- } else {
- const started = await jucePlayer.play();
- if (!started) return; // startBacking() failed — IPC error already logged
- S.isPlaying = true;
- setPlayButtonState(true);
- window.feedBack.isPlaying = true;
- const payload = _songEventPayload();
- window.feedBack.emit('song:play', payload);
- window.feedBack.emit('song:resume', payload);
- }
- return;
- }
- if (S.isPlaying) {
- audio.pause(); S.isPlaying = false;
- setPlayButtonState(false);
- } else {
- // Flip the UI optimistically before awaiting the play() Promise so
- // a quick second click during a slow start (buffering, device
- // wake, etc.) still enters the pause branch above. Two stale-
- // resolution guards:
- // - _audioSeekGen: bumped in showScreen() teardown and
- // playSong(), so a rejection from a torn-down session can't
- // touch new-session UI. Survives same-URL reloads.
- // - _playAttemptGen: bumped on every play branch entry, so
- // within a single session a slow rejection from attempt N
- // can't clobber a faster attempt N+1 (Play → Pause → Play).
- const sessionGen = _audioSeekGen;
- const attempt = ++_playAttemptGen;
- S.isPlaying = true;
- setPlayButtonState(true);
- try {
- await audio.play();
- } catch (err) {
- if (sessionGen !== _audioSeekGen) return;
- if (attempt !== _playAttemptGen) return;
- // An engine reroute (HTML5 -> JUCE) deliberately pauses the
- // element mid-migration, which rejects this in-flight play() with an
- // AbortError even though playback continues on the JUCE transport.
- // The reroute owns isPlaying / the button while it runs (same guard
- // the 'play'/'pause' listeners use); resetting here would
- // leave the button showing Play while the song keeps playing — the
- // "two clicks to pause on the first song after a fresh load" bug.
- if (window._juceRerouteInProgress) return;
- console.error('[app] audio.play() rejected:', err);
- S.isPlaying = false;
- setPlayButtonState(false);
- }
- }
-}
-
-async function seekBy(s) {
- await _audioSeek(Math.max(0, _audioTime() + s), 'seek-by');
-}
-
// Restart the current song from the beginning (or from loop A when an A–B
// loop is armed). Uses the canonical _audioSeek funnel only — never touches
// audio.currentTime directly and never reloads via playSong().
@@ -5021,7 +4700,7 @@ function _openExitConfirm() {
// resumes only what we paused (wasPlaying), and only if the same song is
// still live on the player — guarding a teardown/seek/end behind the prompt.
_cancelCountIn();
- const _resumeGen = _audioSeekGen;
+ const _resumeGen = audioSeekGen();
const _wasPlaying = S.isPlaying;
if (_wasPlaying) Promise.resolve(togglePlay()).catch(() => {});
const overlay = document.createElement('div');
@@ -5074,7 +4753,7 @@ function _openExitConfirm() {
// the same live song on the player (not torn down / ended / seeked away
// behind the modal). If the user was already paused, leave them paused.
if (_wasPlaying && !S.isPlaying &&
- _audioSeekGen === _resumeGen &&
+ audioSeekGen() === _resumeGen &&
document.querySelector('.screen.active')?.id === 'player') {
Promise.resolve(togglePlay()).catch(() => {});
}
@@ -6582,19 +6261,13 @@ async function checkScanAndLoad() {
// tests/js/host_contract.test.js fails CI if this list and the host.* uses under
// static/js/ ever drift apart.
configureHost({
- _audioTime,
- _audioDuration,
formatTime,
- setPlayButtonState,
- _songEventPayload,
- togglePlay,
handleSliderInput,
playSong,
// count-in is a module now, so section-practice reaches it through the seam too —
// these are simply count-in's own exports, handed across.
startCountIn,
_cancelCountIn,
- _audioSeek,
_updateEditRegionBtn,
// section-practice reaches the loop module through the seam, not by importing it:
// loops imports section-practice (clearLoop drops its selection), so the reverse
@@ -6604,14 +6277,12 @@ configureHost({
clearLoop,
// Read-only getters. The module only ever READS these reassigned scalars, so no
// state container is needed. loopA/loopB/_loopMutationGen are owned by
- // ./js/loops.js now and imported here as live bindings; _audioSeekGen and
- // currentFilename are still app.js's.
+ // ./js/loops.js now and imported here as live bindings; currentFilename is still
+ // app.js's.
loopA: () => loopA,
loopB: () => loopB,
_loopMutationGen: () => _loopMutationGen,
- _audioSeekGen: () => _audioSeekGen,
currentFilename: () => currentFilename,
- jucePlayer: () => jucePlayer,
});
Object.assign(window, {
diff --git a/static/js/count-in.js b/static/js/count-in.js
index f9391d7..8f965a3 100644
--- a/static/js/count-in.js
+++ b/static/js/count-in.js
@@ -20,7 +20,7 @@
// 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 { _audioSeek, _songEventPayload, jucePlayer, setPlayButtonState, togglePlay } from './transport.js';
import { loopA, loopB, setLoop } from './loops.js';
import { S } from './player-state.js';
@@ -182,7 +182,7 @@ export async function startCountIn(opts = {}) {
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));
+ await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in count-in:', err));
} else {
audio.pause();
}
@@ -225,7 +225,7 @@ export async function startCountIn(opts = {}) {
// 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) => {
+ _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
@@ -247,10 +247,10 @@ export async function startCountIn(opts = {}) {
_countingIn = false;
if (S.isPlaying) {
S.isPlaying = false;
- host.setPlayButtonState(false);
+ setPlayButtonState(false);
if (window.feedBack) {
window.feedBack.isPlaying = false;
- window.feedBack.emit('song:pause', host._songEventPayload());
+ window.feedBack.emit('song:pause', _songEventPayload());
}
}
return;
@@ -282,21 +282,21 @@ export async function startCountIn(opts = {}) {
hideCountOverlay();
_countingIn = false;
if (window._juceMode) {
- host.jucePlayer().play().then((started) => {
+ jucePlayer.play().then((started) => {
if (gen !== _countInGen) return; // teardown during play start
if (!started) return;
S.isPlaying = true;
- host.setPlayButtonState(true);
+ setPlayButtonState(true);
window.feedBack.isPlaying = true;
- const payload = host._songEventPayload();
+ const payload = _songEventPayload();
window.feedBack.emit('song:play', payload);
window.feedBack.emit('song:resume', payload);
- }).catch((err) => console.error('[app] host.jucePlayer().play error:', err));
+ }).catch((err) => console.error('[app] jucePlayer.play error:', err));
} else {
audio.play().then(() => {
if (gen !== _countInGen) return;
S.isPlaying = true;
- host.setPlayButtonState(true);
+ setPlayButtonState(true);
}).catch((err) => {
if (gen !== _countInGen) return;
// An engine reroute's deliberate pause aborts this play()
@@ -307,7 +307,7 @@ export async function startCountIn(opts = {}) {
// started if the Promise rejected.
console.error('[app] audio.play() rejected after count-in:', err);
S.isPlaying = false;
- host.setPlayButtonState(false);
+ setPlayButtonState(false);
});
}
return;
@@ -333,7 +333,7 @@ export async function startSongCountIn() {
// 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));
+ await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in song count-in:', err));
} else {
audio.pause();
}
@@ -352,7 +352,7 @@ export async function startSongCountIn() {
_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));
+ Promise.resolve(togglePlay()).catch((err) => console.warn('[app] play after count-in failed:', err));
return;
}
showCountOverlay(count);
diff --git a/static/js/juce-audio.js b/static/js/juce-audio.js
index e4b0495..8122089 100644
--- a/static/js/juce-audio.js
+++ b/static/js/juce-audio.js
@@ -24,7 +24,7 @@
// 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 { _audioSeek, _songEventPayload, jucePlayer, setPlayButtonState } from './transport.js';
import { setSpeed } from './player-controls.js';
import { S } from './player-state.js';
@@ -199,23 +199,23 @@ import { S } from './player-state.js';
// 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();
+ const started = await 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)');
+ throw new Error('jucePlayer.play() failed (transient transport start)');
}
}
if (_isStale(songAudio)) {
// Song changed while JUCE was spinning up — undo and bail.
- await host.jucePlayer().pause().catch(() => {});
+ await jucePlayer.pause().catch(() => {});
return 'stale';
}
if (window.jucePlayer) {
- host.jucePlayer()._dur = dur;
- host.jucePlayer()._pos = pos;
- host.jucePlayer()._pollAt = performance.now();
+ jucePlayer._dur = dur;
+ jucePlayer._pos = pos;
+ jucePlayer._pollAt = performance.now();
}
window._juceMode = true;
window._juceAudioUrl = url;
@@ -270,7 +270,7 @@ import { S } from './player-state.js';
async function _switchJuceToHtml5(songAudio) {
const url = songAudio.url;
const wasPlaying = S.isPlaying;
- const pos = (window.jucePlayer ? host.jucePlayer().currentTime : 0) || 0;
+ const pos = (window.jucePlayer ? jucePlayer.currentTime : 0) || 0;
window.feedBack?.playback?.recordRouteChange?.({
routeKind: 'browser-media',
state: 'switching',
@@ -296,7 +296,7 @@ import { S } from './player-state.js';
};
let _resumeScheduled = false;
try {
- await host.jucePlayer().pause().catch(() => {});
+ await jucePlayer.pause().catch(() => {});
if (_isStale(songAudio)) return; // song changed mid-pause
window._juceMode = false;
window._juceAudioUrl = null;
@@ -875,18 +875,18 @@ export let _resetJuceAudioShimChain = function () {};
const seekTime = batch.seekTime;
if (wantsPause && seekTime !== undefined) {
enqueue(async (gen) => {
- const r = await host._audioSeek(seekTime, 'audio-element-shim');
+ const r = await _audioSeek(seekTime, 'audio-element-shim');
if (!r.completed) return; // seek cancelled by teardown
if (gen !== _juceShimGen) return;
if (!forUpcomingPlay) {
- await host.jucePlayer().pause();
+ await jucePlayer.pause();
if (gen !== _juceShimGen) return;
S.isPlaying = false;
- host.setPlayButtonState(false);
+ setPlayButtonState(false);
const sm = window.feedBack;
if (sm) {
sm.isPlaying = false;
- sm.emit('song:pause', host._songEventPayload());
+ sm.emit('song:pause', _songEventPayload());
}
}
audio.dispatchEvent(new Event('seeked'));
@@ -895,21 +895,21 @@ export let _resetJuceAudioShimChain = function () {};
}
if (wantsPause) {
enqueue(async (gen) => {
- await host.jucePlayer().pause();
+ await jucePlayer.pause();
if (gen !== _juceShimGen) return;
S.isPlaying = false;
- host.setPlayButtonState(false);
+ setPlayButtonState(false);
const sm = window.feedBack;
if (sm) {
sm.isPlaying = false;
- sm.emit('song:pause', host._songEventPayload());
+ sm.emit('song:pause', _songEventPayload());
}
});
return;
}
if (seekTime !== undefined) {
enqueue(async (gen) => {
- const r = await host._audioSeek(seekTime, 'audio-element-shim');
+ const r = await _audioSeek(seekTime, 'audio-element-shim');
if (!r.completed) return; // seek cancelled by teardown
if (gen !== _juceShimGen) return;
audio.dispatchEvent(new Event('seeked'));
@@ -937,7 +937,7 @@ export let _resetJuceAudioShimChain = function () {};
Object.defineProperty(audio, 'currentTime', {
get() {
- if (window._juceMode) return host.jucePlayer().currentTime;
+ if (window._juceMode) return jucePlayer.currentTime;
return ctDesc.get.call(this);
},
set(v) {
@@ -975,14 +975,14 @@ export let _resetJuceAudioShimChain = function () {};
if (window._juceMode) {
if (_juceShimBatch != null) flushJuceShimBatchNow({ forUpcomingPlay: true });
const p = enqueue(async (gen) => {
- const started = await host.jucePlayer().play();
+ const started = await jucePlayer.play();
if (gen !== _juceShimGen || !started) return;
S.isPlaying = true;
- host.setPlayButtonState(true);
+ setPlayButtonState(true);
const sm = window.feedBack;
if (sm) {
sm.isPlaying = true;
- const payload = host._songEventPayload();
+ const payload = _songEventPayload();
sm.emit('song:play', payload);
sm.emit('song:resume', payload);
}
diff --git a/static/js/loops.js b/static/js/loops.js
index 2a497f8..312cd09 100644
--- a/static/js/loops.js
+++ b/static/js/loops.js
@@ -19,6 +19,7 @@
// 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 { _audioSeek, _audioTime } from './transport.js';
import { host } from './host.js';
import {
_setSectionPracticeMode,
@@ -39,14 +40,14 @@ export let loopB = null;
export let _loopMutationGen = 0;
export function setLoopStart() {
- loopA = host._audioTime();
+ loopA = _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();
+ loopB = _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();
@@ -72,7 +73,7 @@ export function clearLoop(options) {
document.getElementById('loop-label').textContent = '';
document.getElementById('saved-loops').value = '';
resetSelection();
- _updateSectionPracticeHighlight(host._audioTime());
+ _updateSectionPracticeHighlight(_audioTime());
if (hadLoop && emitTransportEvent && typeof window !== 'undefined') {
window.feedBack?.playback?.transportEvent?.('loop-cleared', {
requesterId: 'core.loop',
@@ -125,7 +126,7 @@ export async function setLoop(a, b, options) {
// 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');
+ const r = await _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
diff --git a/static/js/section-practice.js b/static/js/section-practice.js
index e7fc00e..741ca58 100644
--- a/static/js/section-practice.js
+++ b/static/js/section-practice.js
@@ -27,6 +27,7 @@
// layer that catches it on the paths a smoke test never runs.
import { audio } from './audio-el.js';
import { esc } from './dom.js';
+import { _audioDuration, _audioTime, audioSeekGen } from './transport.js';
import { host } from './host.js';
export function _sectionPracticeBarContains(el) {
@@ -85,7 +86,7 @@ export function _setSectionPracticeMode(on, opts = {}) {
if (opts.defaultWholeOn) {
_sectionPracticeWholeSection = true;
}
- _updateSectionPracticeHighlight(host._audioTime());
+ _updateSectionPracticeHighlight(_audioTime());
if (opts.defaultWholeOn) {
_syncSectionPracticePieceUi();
}
@@ -104,7 +105,7 @@ export function _setSectionPracticeMode(on, opts = {}) {
_sectionPracticeSelected = -1;
_sectionPracticeWholeSection = false;
_sectionPracticeSavedPartIndex = 0;
- _updateSectionPracticeHighlight(host._audioTime());
+ _updateSectionPracticeHighlight(_audioTime());
if (!opts.skipClearLoop && (host.loopA() !== null || host.loopB() !== null)) {
host.clearLoop();
}
@@ -129,7 +130,7 @@ function _sectionPracticeHighway() {
}
function _sectionPracticeDuration() {
- const d = host._audioDuration();
+ const d = _audioDuration();
if (d && Number.isFinite(d) && d > 0) return d;
const cd = window.feedBack?.currentSong?.duration;
return (cd && Number.isFinite(cd) && cd > 0) ? cd : 0;
@@ -914,7 +915,7 @@ export function renderSectionPracticeBar() {
// matching one; run it before the piece UI so that reflects the result.
_syncSectionPracticeFromLoop();
_syncSectionPracticePieceUi();
- _updateSectionPracticeHighlight(host._audioTime());
+ _updateSectionPracticeHighlight(_audioTime());
}
export async function onSectionParentClick(parentIdx) {
@@ -927,7 +928,7 @@ export async function onSectionParentClick(parentIdx) {
_sectionPracticeSavedPartIndex = 0;
_sectionPracticeWholeSection = true;
_syncSectionPracticePieceUi();
- _updateSectionPracticeHighlight(host._audioTime());
+ _updateSectionPracticeHighlight(_audioTime());
if (_sectionPracticeActiveParentRange() || _sectionPracticeRanges.length) {
await practiceSection(0, { whole: true });
}
@@ -1019,7 +1020,7 @@ function _blurSectionPracticeFocusIfNeeded() {
export async function practiceSection(index, opts = {}) {
const requestGen = ++_sectionPracticeRequestGen;
- const seekGen = host._audioSeekGen();
+ const seekGen = audioSeekGen();
const loopGen = host._loopMutationGen();
const whole = !!opts.whole;
const r = _sectionPracticeResolveLoopTarget(index, opts);
@@ -1045,7 +1046,7 @@ export async function practiceSection(index, opts = {}) {
let ok = false;
for (let attempt = 0; attempt < 5; attempt++) {
// A newer click or a song/arrangement change supersedes this retry.
- if (requestGen !== _sectionPracticeRequestGen || seekGen !== host._audioSeekGen() || loopGen !== host._loopMutationGen()) return;
+ if (requestGen !== _sectionPracticeRequestGen || seekGen !== audioSeekGen() || loopGen !== host._loopMutationGen()) return;
try {
// skipSectionSync: this function owns the section-practice state and
// applies it below under the request-gen guard, so a stale retry
@@ -1055,7 +1056,7 @@ export async function practiceSection(index, opts = {}) {
// after its internal seek await, so a stale loop is never armed.
ok = await host.setLoop(start, end, {
skipSectionSync: true,
- commitGuard: () => requestGen === _sectionPracticeRequestGen && seekGen === host._audioSeekGen() && loopGen === host._loopMutationGen(),
+ commitGuard: () => requestGen === _sectionPracticeRequestGen && seekGen === audioSeekGen() && loopGen === host._loopMutationGen(),
});
} catch (err) {
ok = false;
@@ -1064,7 +1065,7 @@ export async function practiceSection(index, opts = {}) {
await new Promise(res => setTimeout(res, 60 + attempt * 90));
}
// Re-check after the awaited retries before applying any loop/count-in state.
- if (requestGen !== _sectionPracticeRequestGen || seekGen !== host._audioSeekGen() || loopGen !== host._loopMutationGen()) return;
+ if (requestGen !== _sectionPracticeRequestGen || seekGen !== audioSeekGen() || loopGen !== host._loopMutationGen()) return;
if (ok) {
_sectionPracticeWholeSection = whole;
@@ -1073,7 +1074,7 @@ export async function practiceSection(index, opts = {}) {
_sectionPracticeSavedPartIndex = index;
}
_blurSectionPracticeFocusIfNeeded();
- _updateSectionPracticeHighlight(host._audioTime());
+ _updateSectionPracticeHighlight(_audioTime());
host.startCountIn({ immediate: true });
} else {
_setSectionPracticeMode(false, { skipClearLoop: true });
@@ -1120,7 +1121,7 @@ export function _syncSectionPracticeFromLoop() {
} else if (_sectionPracticeMode) {
_setSectionPracticeMode(false, { skipClearLoop: true });
}
- _updateSectionPracticeHighlight(host._audioTime());
+ _updateSectionPracticeHighlight(_audioTime());
}
function _sectionPracticeIndexAtTime(t) {
diff --git a/static/js/transport.js b/static/js/transport.js
new file mode 100644
index 0000000..25c76bb
--- /dev/null
+++ b/static/js/transport.js
@@ -0,0 +1,377 @@
+// The playback transport — the play/pause/seek core, and the two clocks it reads.
+//
+// WHY THIS IS A MODULE AND NOT A HOOK BUNDLE. Every carve before this one ADDED host
+// hooks: a module pulled out of app.js still had to call back into it. This one SUBTRACTS
+// them. count-in, juce-audio, loops, and section-practice were all reaching through the
+// seam for the same handful of names — _audioSeek, _audioTime, setPlayButtonState,
+// _songEventPayload, jucePlayer. Those names have an owner, and it isn't app.js. Give
+// them one and the four consumers import them directly:
+//
+// count-in.js 5 hooks -> 0 juce-audio.js 4 hooks -> 0
+// loops.js 6 hooks -> 4 section-practice.js 10 hooks -> 7
+//
+// A hook is a cycle you agreed to live with. An import is a dependency you actually have.
+// Prefer the import whenever the name has a real owner.
+//
+// TWO THINGS DELIBERATELY LEFT IN app.js, both for the same reason — they would close a
+// cycle, and app.js is the root, so it can import from both sides for free:
+//
+// * _currentPlaybackSnapshot reads loopA/loopB from ./loops.js, and loops.js imports
+// this module. The dependency scan MISSED this at first: it
+// only walked app.js's own top-level decls, and loopA stopped
+// being one the moment loops.js was carved out. Any scan of a
+// partly-carved monolith has to resolve the imports too.
+// * restartCurrentSong calls _cancelCountIn() from ./count-in.js, which imports
+// this module.
+//
+// The seek generation (_audioSeekGen) stays PRIVATE. It has exactly one writer —
+// _resetAudioSeekState(), right here — so readers get audioSeekGen() and nobody outside
+// can desync it. That is strictly better than the host hook it replaces, which handed out
+// a getter and left the writer in app.js.
+import { audio } from './audio-el.js';
+import { S } from './player-state.js';
+
+// Sync the play/pause button's icon and accessible state in one place so
+// screen readers, tooltips, and aria-pressed stay aligned with playback.
+// Updates the existing child's src in place rather than rewriting
+// innerHTML, so any future children (fallback label, loading spinner, …)
+// survive state changes.
+export function setPlayButtonState(isPlaying) {
+ const btn = document.getElementById('btn-play');
+ if (!btn) return;
+ const label = isPlaying ? 'Pause' : 'Play';
+ const icon = isPlaying ? 'pause' : 'play';
+ let img = btn.querySelector('img.button-icon-svg');
+ if (!img) {
+ img = document.createElement('img');
+ img.className = 'button-icon-svg';
+ img.alt = '';
+ img.setAttribute('aria-hidden', 'true');
+ btn.appendChild(img);
+ }
+ img.src = `/static/svg/${icon}.svg`;
+ btn.setAttribute('aria-label', label);
+ btn.setAttribute('aria-pressed', isPlaying ? 'true' : 'false');
+ btn.title = label;
+}
+
+// ── Player ───────────────────────────────────────────────────────────────
+// `audio` now lives in ./js/audio-el.js so carved-out modules can reach the
+// player without importing app.js back (which would close a cycle). Same
+// element, same handle, same lookup — just imported instead of declared here.
+let _lastSongPositionEventAt = 0;
+
+export function _emitSongPositionChanged(time, duration) {
+ const now = Date.now();
+ if (now - _lastSongPositionEventAt < 250) return;
+ _lastSongPositionEventAt = now;
+ const payload = (typeof _songEventPayload === 'function') ? _songEventPayload() : { time };
+ window.feedBack.emit('song:position-changed', Object.assign(payload, { duration }));
+}
+
+export const jucePlayer = {
+ _timer: null,
+ _pos: 0,
+ _dur: 0,
+ _pollAt: 0, // performance.now() when _pos was last set
+ _polling: false,
+ _speed: 1,
+ get currentTime() {
+ if (!this._polling) return this._pos;
+ // Interpolate between IPC polls so highway motion is smooth at 60fps
+ // Scale by _speed so at 0.7x the interpolated clock advances 0.7s/s
+ const elapsed = (performance.now() - this._pollAt) / 1000;
+ return Math.min(this._pos + elapsed * this._speed, this._dur > 0 ? this._dur : Infinity);
+ },
+ get duration() { return this._dur; },
+ async play() {
+ try {
+ await window.feedBackDesktop.audio.startBacking();
+ } catch (err) {
+ console.warn('[jucePlayer] startBacking failed:', err);
+ return false;
+ }
+ this._startPolling();
+ return true;
+ },
+ async pause() {
+ // Snapshot the interpolated position before stopping the poll so
+ // _pos stays at the visible pause point rather than jumping back
+ // to the last raw IPC sample (which can be up to 100ms behind).
+ this._pos = this.currentTime;
+ this._pollAt = performance.now();
+ this._stopPolling();
+ try {
+ await window.feedBackDesktop.audio.stopBacking();
+ } catch (err) {
+ console.warn('[jucePlayer] stopBacking failed:', err);
+ }
+ },
+ async seek(s) {
+ const prev = this._pos;
+ this._pos = s;
+ this._pollAt = performance.now();
+ try {
+ await window.feedBackDesktop.audio.seekBacking(s);
+ } catch (err) {
+ console.warn('[jucePlayer] seekBacking failed:', err);
+ this._pos = prev;
+ this._pollAt = performance.now();
+ }
+ },
+ _startPolling() {
+ this._stopPolling();
+ this._polling = true;
+ this._pollAt = performance.now();
+ const self = this;
+ function scheduleNext() {
+ self._timer = setTimeout(async () => {
+ if (!self._polling) return;
+ try {
+ self._pos = await window.feedBackDesktop.audio.getBackingPosition();
+ self._pollAt = performance.now();
+ _emitSongPositionChanged(self.currentTime, self.duration || null);
+ } catch (err) {
+ console.warn('[jucePlayer] position poll failed:', err);
+ } finally {
+ if (self._polling) scheduleNext();
+ }
+ }, 100);
+ }
+ scheduleNext();
+ },
+ _stopPolling() {
+ this._polling = false;
+ if (this._timer) { clearTimeout(this._timer); this._timer = null; }
+ },
+ setRate(rate) {
+ this._pos = this.currentTime;
+ this._pollAt = performance.now();
+ this._speed = rate;
+ },
+ async stop() {
+ await this.pause();
+ this._pos = 0;
+ this._dur = 0;
+ this._pollAt = 0;
+ this._speed = 1;
+ },
+};
+
+export function _audioTime() { return window._juceMode ? jucePlayer.currentTime : audio.currentTime; }
+
+export function _audioDuration() { return window._juceMode ? jucePlayer.duration : audio.duration; }
+
+// Canonical payload for song:play/song:pause/song:ended. Plugins anchor
+// their own clocks against `perfNow` (a monotonic timestamp at the same
+// moment audio reports `audioT`) so they don't have to chase the chart
+// clock with a follow-up call. `time` is kept as an alias for `audioT`
+// because pre-existing plugins read e.detail.time.
+export function _songEventPayload() {
+ const audioT = _audioTime();
+ return {
+ time: audioT,
+ audioT,
+ chartT: highway.getTime(),
+ perfNow: performance.now(),
+ };
+}
+
+export function _markPlaybackPaused() {
+ S.isPlaying = false;
+ setPlayButtonState(false);
+ if (window.feedBack) {
+ window.feedBack.isPlaying = false;
+ window.feedBack.emit('song:pause', _songEventPayload());
+ }
+}
+
+export function _markPlaybackResumed() {
+ S.isPlaying = true;
+ setPlayButtonState(true);
+ if (window.feedBack) {
+ window.feedBack.isPlaying = true;
+ const payload = _songEventPayload();
+ window.feedBack.emit('song:play', payload);
+ window.feedBack.emit('song:resume', payload);
+ }
+}
+
+export function _emitPlaybackStopped(time, screen = 'playback-command') {
+ if (window.feedBack) window.feedBack.emit('song:stop', { time: time || 0, screen });
+}
+
+export function _waitForSongReady(expectedSeekGen, timeoutMs = 10000) {
+ if (!window.feedBack || typeof window.feedBack.on !== 'function') return Promise.resolve(false);
+ return new Promise(resolve => {
+ let timer = null;
+ const done = value => {
+ if (timer !== null) clearTimeout(timer);
+ window.feedBack.off('song:ready', onReady);
+ resolve(value);
+ };
+ const onReady = () => done(expectedSeekGen == null || expectedSeekGen === _audioSeekGen);
+ window.feedBack.on('song:ready', onReady);
+ timer = setTimeout(() => done(false), timeoutMs);
+ });
+}
+
+// Serializes seeks so concurrent callers (e.g. user ⏪ during a loop wrap)
+// don't interleave their from/to reads — each call captures `from` only
+// once the previous seek + emit have completed. The generation token
+// lets session teardown invalidate queued seeks so they don't run against
+// the new player and emit a stale song:seek.
+let _audioSeekChain = Promise.resolve();
+
+let _audioSeekGen = 0;
+
+export function _resetAudioSeekState() {
+ // Bump the generation — in-flight chain callbacks see the mismatch on
+ // their next guard check and short-circuit (no emit, no further state
+ // mutation by us). Don't reset the chain head: new seeks must still
+ // queue behind the in-flight old seek's IPC so two `jucePlayer.seek()`
+ // calls can't race in the JUCE backing engine. The queue drains
+ // quickly because each subsequent old-gen step bails on the first
+ // guard the moment its predecessor resolves.
+ _audioSeekGen++;
+}
+
+// Time-box the JUCE IPC so a single hung seek can't block the global
+// _audioSeekChain forever (which would freeze every subsequent reposition
+// path: seekBy, loop-wrap, jump-fix, shimmed audio.currentTime).
+const _JUCE_SEEK_TIMEOUT_MS = 2000;
+
+function _juceSeekWithTimeout(s) {
+ let timer;
+ const seekP = jucePlayer.seek(s);
+ const timeoutP = new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error('JUCE seek timed out')), _JUCE_SEEK_TIMEOUT_MS);
+ });
+ // Clear the timer once the race settles either way; without this the
+ // pending timeout keeps the event loop alive (and eventually rejects
+ // an unawaited promise) even after a successful seek.
+ return Promise.race([seekP, timeoutP]).finally(() => clearTimeout(timer));
+}
+
+// Resolves to `{ completed, from, to }`:
+// - completed: true if the seek ran to completion and emitted song:seek;
+// false if cancelled by a teardown gen bump (or threw).
+// - from: chart clock just before the seek (NaN on cancel before from-read).
+// - to: verified post-seek clock (NaN on cancel/throw).
+// Callers that fire follow-up work after the seek (count-in, arrangement
+// restore, etc.) should check `completed` so they don't act on a torn-down
+// session. Callers that need the actual landed position (because JUCE may
+// clamp or HTML5 may snap to the seekable range) should read `to` rather
+// than re-using the requested `s`.
+export async function _audioSeek(s, reason) {
+ // Single funnel for every audio repositioning. Emits song:seek so
+ // plugins (notedetect detection-suppression during seek transients,
+ // practice-journal segment tracking) can react to any chart-time
+ // jump regardless of which UI path triggered it. `reason` is a
+ // free-form short string ('seek-by', 'loop-wrap', 'loop-set',
+ // 'arrangement-restore', 'jump-fix') so subscribers can filter.
+ const gen = _audioSeekGen;
+ _audioSeekChain = _audioSeekChain.then(async () => {
+ if (gen !== _audioSeekGen) return { completed: false, from: NaN, to: NaN };
+ const from = _audioTime();
+ if (window._juceMode) await _juceSeekWithTimeout(s);
+ else audio.currentTime = s;
+ if (gen !== _audioSeekGen) return { completed: false, from, to: NaN };
+ // Read the verified post-seek position rather than the requested `s`
+ // so plugins observe the actual clock — JUCE may clamp or roll back,
+ // and HTML5 may snap to the nearest seekable range.
+ const to = _audioTime();
+ // Sync the jump-fix tracker so the next 60Hz tick doesn't see a
+ // legitimate far seek (e.g. saved-loop jump > 30s) as a browser
+ // bug and revert it.
+ S.lastAudioTime = to;
+ // Sync the chart clock too so any song:* emit fired right after
+ // _audioSeek resolves (e.g. the auto-resume song:play in
+ // changeArrangement) sees an in-sync chartT via _songEventPayload.
+ // Without this, chartT lags by one 60Hz tick after a seek.
+ if (typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function') {
+ highway.setTime(to);
+ }
+ window.feedBack.emit('song:seek', { from, to, reason: reason || null });
+ return { completed: true, from, to };
+ }).catch((err) => {
+ // Don't let one failed seek poison subsequent ones.
+ console.warn('[_audioSeek]', err);
+ return { completed: false, from: NaN, to: NaN };
+ });
+ return _audioSeekChain;
+}
+
+// Per-attempt counter for HTML5 audio.play() invocations. Bumped on
+// every play branch entry so a slow rejection from attempt N can't
+// clobber the UI of a newer attempt N+1 within the same session.
+let _playAttemptGen = 0;
+
+export async function togglePlay() {
+ if (window._juceMode) {
+ if (S.isPlaying) {
+ await jucePlayer.pause();
+ S.isPlaying = false;
+ setPlayButtonState(false);
+ window.feedBack.isPlaying = false;
+ window.feedBack.emit('song:pause', _songEventPayload());
+ } else {
+ const started = await jucePlayer.play();
+ if (!started) return; // startBacking() failed — IPC error already logged
+ S.isPlaying = true;
+ setPlayButtonState(true);
+ window.feedBack.isPlaying = true;
+ const payload = _songEventPayload();
+ window.feedBack.emit('song:play', payload);
+ window.feedBack.emit('song:resume', payload);
+ }
+ return;
+ }
+ if (S.isPlaying) {
+ audio.pause(); S.isPlaying = false;
+ setPlayButtonState(false);
+ } else {
+ // Flip the UI optimistically before awaiting the play() Promise so
+ // a quick second click during a slow start (buffering, device
+ // wake, etc.) still enters the pause branch above. Two stale-
+ // resolution guards:
+ // - _audioSeekGen: bumped in showScreen() teardown and
+ // playSong(), so a rejection from a torn-down session can't
+ // touch new-session UI. Survives same-URL reloads.
+ // - _playAttemptGen: bumped on every play branch entry, so
+ // within a single session a slow rejection from attempt N
+ // can't clobber a faster attempt N+1 (Play → Pause → Play).
+ const sessionGen = _audioSeekGen;
+ const attempt = ++_playAttemptGen;
+ S.isPlaying = true;
+ setPlayButtonState(true);
+ try {
+ await audio.play();
+ } catch (err) {
+ if (sessionGen !== _audioSeekGen) return;
+ if (attempt !== _playAttemptGen) return;
+ // An engine reroute (HTML5 -> JUCE) deliberately pauses the
+ // element mid-migration, which rejects this in-flight play() with an
+ // AbortError even though playback continues on the JUCE transport.
+ // The reroute owns isPlaying / the button while it runs (same guard
+ // the 'play'/'pause' listeners use); resetting here would
+ // leave the button showing Play while the song keeps playing — the
+ // "two clicks to pause on the first song after a fresh load" bug.
+ if (window._juceRerouteInProgress) return;
+ console.error('[app] audio.play() rejected:', err);
+ S.isPlaying = false;
+ setPlayButtonState(false);
+ }
+ }
+}
+
+export async function seekBy(s) {
+ await _audioSeek(Math.max(0, _audioTime() + s), 'seek-by');
+}
+
+/**
+ * Read-only view of the seek generation. Bumped by _resetAudioSeekState() on session
+ * teardown; callers capture it before an await and compare after, so a resolution from a
+ * torn-down session can't touch new-session state.
+ */
+export function audioSeekGen() { return _audioSeekGen; }
diff --git a/tests/js/play_button_reroute_guard.test.js b/tests/js/play_button_reroute_guard.test.js
index a43541c..aff2c6a 100644
--- a/tests/js/play_button_reroute_guard.test.js
+++ b/tests/js/play_button_reroute_guard.test.js
@@ -18,7 +18,7 @@ const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
-const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
+const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
const SRC = fs.readFileSync(APP_JS, 'utf8');
const TOGGLE_PLAY_SRC = extractFunction(SRC, 'async function togglePlay(');
diff --git a/tests/js/playback_app_adapter.test.js b/tests/js/playback_app_adapter.test.js
index 15aeccc..461cada 100644
--- a/tests/js/playback_app_adapter.test.js
+++ b/tests/js/playback_app_adapter.test.js
@@ -5,6 +5,10 @@ const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
+// SPLIT. _installPlaybackTransportAdapter stayed in app.js — it reads loopA/loopB from
+// ./js/loops.js, and loops.js imports transport, so moving it would close a cycle.
+// _waitForSongReady went with the rest of the seek machinery.
+const TRANSPORT_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
@@ -54,7 +58,7 @@ function loadReadyHelper(sandbox, src) {
}
test('_waitForSongReady rejects a ready event from a different audio generation', async () => {
- const src = fs.readFileSync(APP_JS, 'utf8');
+ const src = fs.readFileSync(TRANSPORT_JS, 'utf8');
const sandbox = buildReadySandbox();
loadReadyHelper(sandbox, src);
@@ -72,7 +76,7 @@ test('playback adapter scopes startTime readiness and validates seek targets', (
const src = fs.readFileSync(APP_JS, 'utf8');
const fn = extractFunction(src, 'function _installPlaybackTransportAdapter()');
- assert.match(fn, /const expectedSeekGen\s*=\s*_audioSeekGen\s*\+\s*1;/);
+ assert.match(fn, /const expectedSeekGen\s*=\s*audioSeekGen\(\)\s*\+\s*1;/);
assert.match(fn, /_waitForSongReady\(expectedSeekGen\)/);
assert.match(fn, /const seconds\s*=\s*Number\(time\);/);
assert.match(fn, /!Number\.isFinite\(seconds\)\s*\|\|\s*seconds\s*<\s*0/);
diff --git a/tests/js/song_event_payload.test.js b/tests/js/song_event_payload.test.js
index 627d5a1..5ce2484 100644
--- a/tests/js/song_event_payload.test.js
+++ b/tests/js/song_event_payload.test.js
@@ -12,7 +12,7 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
-const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
+const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
@@ -129,11 +129,24 @@ test('every song:play/pause/ended emit uses _songEventPayload', () => {
);
});
+// CENSUS over the WHOLE frontend, not one file. This test counts call/emit sites, and the
+// carve keeps moving them between app.js and static/js/*.js — point it at a single file
+// and the count silently shrinks as code leaves, which reads as "someone deleted an emit"
+// (or, worse, passes while genuinely missing sites). Read every source that can hold one.
+function allFrontendSources() {
+ const jsDir = path.join(__dirname, '..', '..', 'static', 'js');
+ const parts = [fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8')];
+ for (const f of fs.readdirSync(jsDir).sort()) {
+ if (f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
+ }
+ return parts.join('\n');
+}
+
test('there are at least 8 song:* emit sites threaded through the helper', () => {
// Sanity-check that the helper actually got wired everywhere. If the
// count drops, someone removed an emit (regression) or refactored an
// event away (intentional — this test then needs updating).
- const src = fs.readFileSync(APP_JS, 'utf8');
+ const src = allFrontendSources();
const matches = src.match(/(?:window\.feedBack|\w+)\.emit\(\s*['"]song:(play|pause|ended)['"][^)]*\)/g) || [];
assert.ok(
matches.length >= 8,
diff --git a/tests/js/song_seek.test.js b/tests/js/song_seek.test.js
index dbdcf17..e09bb6c 100644
--- a/tests/js/song_seek.test.js
+++ b/tests/js/song_seek.test.js
@@ -1,4 +1,4 @@
-// Verify static/app.js emits `song:seek` for every audio repositioning,
+// Verify static/js/transport.js emits `song:seek` for every audio repositioning,
// with `{ from, to, reason }` payload. Plugins (notedetect detection-
// suppression during seek transients) consume this contract.
//
@@ -11,7 +11,7 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
-const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
+const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'transport.js');
function extractFunction(src, signature) {
const start = src.indexOf(signature);
@@ -287,13 +287,26 @@ test('seekBy floors at zero (does not seek to negative time)', async () => {
assert.equal(seek.detail.to, 0);
});
+// CENSUS over the WHOLE frontend, not one file. This test counts call/emit sites, and the
+// carve keeps moving them between app.js and static/js/*.js — point it at a single file
+// and the count silently shrinks as code leaves, which reads as "someone deleted an emit"
+// (or, worse, passes while genuinely missing sites). Read every source that can hold one.
+function allFrontendSources() {
+ const jsDir = path.join(__dirname, '..', '..', 'static', 'js');
+ const parts = [fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8')];
+ for (const f of fs.readdirSync(jsDir).sort()) {
+ if (f.endsWith('.js')) parts.push(fs.readFileSync(path.join(jsDir, f), 'utf8'));
+ }
+ return parts.join('\n');
+}
+
test('every documented seek callsite passes a reason', () => {
// Source-order assertion: every _audioSeek call outside the
// implementation must pass a kebab-case reason string. Catches a
// future contributor adding a new seek path without threading the
// reason. Line-based — regex argument capture can't balance parens
// through Math.max/_audioTime calls.
- const src = fs.readFileSync(APP_JS, 'utf8');
+ const src = allFrontendSources();
const fnSrc = extractFunction(src, 'async function _audioSeek(');
const withoutImpl = src.replace(fnSrc, '');
const callLines = withoutImpl.split('\n').filter((l) => /_audioSeek\(/.test(l));