diff --git a/static/app.js b/static/app.js
index 942d1ad..6a3e87b 100644
--- a/static/app.js
+++ b/static/app.js
@@ -41,6 +41,21 @@ import {
} from './js/settings-io.js';
import { audio } from './js/audio-el.js';
import { S } from './js/player-state.js';
+import {
+ _cancelCountIn,
+ armCreditsHideOnPlay,
+ hideCountOverlay,
+ hideSongCreditsOverlay,
+ holdCreditsThen,
+ isCountingIn,
+ playClick,
+ scheduleCreditsHide,
+ showCountOverlay,
+ showSongCreditsOverlay,
+ startCountIn,
+ startSongCountIn,
+} from './js/count-in.js';
+
import {
_loopMutationGen,
clearLoop,
@@ -5312,14 +5327,13 @@ window.feedBack.on('song:ready', () => {
const authors = (window.feedBack.currentSong && window.feedBack.currentSong.authors) || [];
if (authors.length) {
showSongCreditsOverlay(authors);
- _creditsHideOnPlay = () => { _creditsHideOnPlay = null; hideSongCreditsOverlay(); };
- window.feedBack.on('song:play', _creditsHideOnPlay, { once: true });
+ armCreditsHideOnPlay();
}
// Autoplay-exit disabled: don't auto-start. Still let the credits dwell a
// couple seconds on the freshly-loaded song, then clear them (they also
// clear early if the user manually presses Play, via _creditsHideOnPlay).
if (!_autoplayExitEnabled()) {
- if (authors.length) _creditsTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_HOLD_MS);
+ if (authors.length) scheduleCreditsHide();
return;
}
// The actual auto-start: a count-in (which handles HTML5 + _juceMode) or the
@@ -5345,7 +5359,7 @@ window.feedBack.on('song:ready', () => {
// Not gated: a count-in starts now (it owns its on-screen dwell); otherwise
// let the credits dwell a couple seconds first, then start.
if (_countdownBeforeSongEnabled() || !authors.length) start();
- else _creditsTimer = setTimeout(() => { _creditsTimer = null; start(); }, _CREDITS_HOLD_MS);
+ else holdCreditsThen(start);
});
// ── Resume last session ────────────────────────────────────────────────────
@@ -6589,344 +6603,15 @@ window.onPhraseNext = onPhraseNext;
-// ── Count-in click sound (Web Audio API) ────────────────────────────────
-let _audioCtx = null;
-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;
-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; }
-}
-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 = `${n}`;
-}
-function hideCountOverlay() {
- if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; }
-}
-// Map a feedpak author `role` to a friendly " 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.
-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);
-}
-
-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; }
-}
-
-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 jucePlayer.pause().catch((err) => console.error('[app] 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).
- _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;
- setPlayButtonState(false);
- if (window.feedBack) {
- window.feedBack.isPlaying = false;
- window.feedBack.emit('song:pause', _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) {
- jucePlayer.play().then((started) => {
- if (gen !== _countInGen) return; // teardown during play start
- if (!started) return;
- S.isPlaying = true;
- setPlayButtonState(true);
- window.feedBack.isPlaying = true;
- const payload = _songEventPayload();
- window.feedBack.emit('song:play', payload);
- window.feedBack.emit('song:resume', payload);
- }).catch((err) => console.error('[app] jucePlayer.play error:', err));
- } else {
- audio.play().then(() => {
- if (gen !== _countInGen) return;
- S.isPlaying = true;
- 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;
- 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.
-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 jucePlayer.pause().catch((err) => console.error('[app] 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(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);
-}
// Time display + highway sync
// hud-time write cache: the 60 Hz tick below used to rewrite textContent
@@ -6938,7 +6623,7 @@ let _hudTimeLast = '';
setInterval(() => {
let ct = _audioTime();
const dur = _audioDuration();
- if (dur && !_countingIn) {
+ if (dur && !isCountingIn()) {
// JUCE end-of-track: HTML5 fires 'ended'; JUCE needs a manual check
if (window._juceMode && S.isPlaying && ct >= dur) {
S.isPlaying = false;
@@ -6976,7 +6661,7 @@ setInterval(() => {
if (_sectionPracticeBarIsReady() && _sectionPracticeSourceSections().length) {
_updateSectionPracticeHighlight(ct);
}
- if (!_countingIn) highway.setTime(ct);
+ if (!isCountingIn()) highway.setTime(ct);
}, 1000 / 60);
_installSectionPracticeDrawHook();
@@ -8171,9 +7856,14 @@ async function checkScanAndLoad() {
configureHost({
_audioTime,
_audioDuration,
+ formatTime,
+ setPlayButtonState,
+ _songEventPayload,
+ togglePlay,
+ // 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,
- formatTime,
_audioSeek,
_updateEditRegionBtn,
// section-practice reaches the loop module through the seam, not by importing it:
@@ -8191,6 +7881,7 @@ configureHost({
_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
new file mode 100644
index 0000000..f9391d7
--- /dev/null
+++ b/static/js/count-in.js
@@ -0,0 +1,389 @@
+// 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 = `${n}`;
+}
+
+export function hideCountOverlay() {
+ if (_countOverlay) { _countOverlay.remove(); _countOverlay = null; }
+}
+
+// Map a feedpak author `role` to a friendly " 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);
+}
diff --git a/tests/js/loop_restart.test.js b/tests/js/loop_restart.test.js
index f03f91a..d729fbe 100644
--- a/tests/js/loop_restart.test.js
+++ b/tests/js/loop_restart.test.js
@@ -14,7 +14,8 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
-const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
+// startCountIn was carved out of app.js into its own module (R3a).
+const APP_JS = path.join(__dirname, '..', '..', 'static', 'js', 'count-in.js');
// Pull a function body by declaration prefix (e.g. `async function startCountIn`)
// and brace-matching to the closing brace. Skips an optional `( ... )` param
@@ -111,12 +112,23 @@ 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');
+ const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
const startCountInSrc = extractFunction(src, 'async function startCountIn');
// Sanity check: the change under test is present at all. Catches
@@ -167,7 +179,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');
+ const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
const startCountInSrc = extractFunction(src, 'async function startCountIn');
const sandbox = buildSandbox();
@@ -202,7 +214,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');
+ const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
const fn = extractFunction(src, 'async function startCountIn');
// Captures gen at entry
assert.match(fn, /const gen = _countInGen/, 'startCountIn must capture _countInGen at entry');
@@ -218,7 +230,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');
+ const src = fs.readFileSync(APP_JS, 'utf8').replace(/^export /gm, '');
const fn = extractFunction(src, 'async function startCountIn');
const wrapMarker = "_audioSeek(loopA, 'loop-wrap')";
const wrapStart = fn.indexOf(wrapMarker);
diff --git a/tests/js/song_credits_overlay.test.js b/tests/js/song_credits_overlay.test.js
index e08909d..b6d5d9e 100644
--- a/tests/js/song_credits_overlay.test.js
+++ b/tests/js/song_credits_overlay.test.js
@@ -14,8 +14,9 @@ const vm = require('node:vm');
const { extractFunction } = require('./test_utils');
-const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
-const SRC = fs.readFileSync(APP_JS, 'utf8');
+// 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, '');
// Minimal fake DOM element: records className, children, and textContent.
// Setting textContent clears children (matching real DOM) so we can assert