// Count-in — the one-bar 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 { _audioSeek, _songEventPayload, jucePlayer, setPlayButtonState, togglePlay } from './transport.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); } // ── How many clicks lead into `startT` ────────────────────────────────── // One bar, derived from the song_timeline beats: `window.highway.getBeats()` // is the only meter data the frontend holds (the `time_signatures` map is // streamed to plugins, not stored here). Beats carry `measure >= 0` on // downbeats, so the gap between consecutive downbeats IS the bar length — // which is why a 3/4 song no longer gets four clicks. // // A first bar shorter than that is a pickup (anacrusis), and the count is // shortened by its length so the music enters on its real beat: a 1-beat // pickup in 4/4 counts "1 2 3" and the pickup lands on 4. Counting a full // four there puts the pickup where the downbeat belongs, and the player comes // in a beat late for the whole song. export function countInBeats(startT) { const DEFAULT = 4; // pre-chart, synthetic highway (minigames), or no beats let beats = null; try { if (window.highway && typeof window.highway.getBeats === 'function') { beats = window.highway.getBeats(); } } catch (_) { /* fall through to the default */ } if (!Array.isArray(beats) || beats.length < 2) return DEFAULT; const downbeats = []; for (let i = 0; i < beats.length; i++) { if (beats[i] && beats[i].measure >= 0) downbeats.push(i); } if (downbeats.length < 2) return DEFAULT; // Bar length = the most common gap between downbeats. The mode rather than // the first gap: it ignores a short pickup bar and a short final bar, and // survives an isolated meter change mid-song. The beats trailing the last // downbeat count as a candidate too — otherwise a song of pickup + one bar // offers only the pickup's own gap and the count collapses to it. const gapCounts = new Map(); const addGap = (gap) => gapCounts.set(gap, (gapCounts.get(gap) || 0) + 1); for (let k = 1; k < downbeats.length; k++) { addGap(downbeats[k] - downbeats[k - 1]); } addGap(beats.length - downbeats[downbeats.length - 1]); let barLen = DEFAULT; let bestCount = 0; for (const [gap, n] of gapCounts) { // Tie → the longer bar: a pickup's short gap must not outvote the // real meter when the song is too short to repeat it. if (n > bestCount || (n === bestCount && gap > barLen)) { barLen = gap; bestCount = n; } } // The beat playback resumes on. The 50 ms tolerance matches the seek // precision the loop-wrap path already assumes. const startIdx = beats.findIndex(b => b && b.time >= startT - 0.05); if (startIdx === -1) return barLen; // past the last beat if (!(beats[startIdx].measure >= 0)) return barLen; // resuming mid-bar const nextDownbeat = downbeats.find(d => d > startIdx); if (nextDownbeat === undefined) return barLen; // the last downbeat const thisBar = nextDownbeat - startIdx; if (thisBar <= 0) return barLen; // Only the song's FIRST bar can be a pickup. A short bar anywhere else is // a meter change (or a truncated final bar), and counting it as a pickup // would leave almost no count-in at all — so elsewhere we simply count // that bar's own length, which is also what a mid-song meter change wants. if (startIdx === downbeats[0] && thisBar < barLen) return barLen - thisBar; return thisBar; } 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 window.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 window.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 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; window.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; window.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; window.highway.setTime(r.to); window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA }); beginCount(); }); } } _countInRaf = requestAnimationFrame(rewindStep); function beginCount() { const bpm = window.highway.getBPM(loopA); const beatInterval = 60 / bpm; // One bar of the meter at loop A (a short bar there is counted short, // same as the song-start pickup). const clicks = countInBeats(loopA); let count = 0; function tick() { if (gen !== _countInGen) return; // teardown mid-count count++; if (count > clicks) { 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 one-bar 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 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 = window.highway.getBPM(startT); // Pre-chart / malformed-tempo fallback: 120 BPM (500 ms per beat). if (!Number.isFinite(bpm) || bpm <= 0) bpm = 120; const beatInterval = 60 / bpm; const clicks = countInBeats(startT); let count = 0; function tick() { if (gen !== _countInGen) return; // teardown mid-count count++; if (count > clicks) { 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); } // ── 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); }