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