From 64f04565e210bac11516c73afb31ba8cf755bac5 Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sat, 11 Jul 2026 20:58:41 +0200 Subject: [PATCH] refactor(app): the host seam + carve section practice out of app.js (R3a) (#887) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit static/js/host.js (99) + static/js/section-practice.js (1,214). app.js 9,461 → 8,409. THE FIRST SLICE OUT OF THE STRONGLY-CONNECTED CORE. What is left in app.js is not a tree, it is a cycle: seeding a dependency closure from section-practice, from loops, from count-in, or from the JUCE seek shim all return the SAME 178-function set, and setLoop() and practiceSection() call each other directly. No closure-based carve can cut it at any seed. So it is cut BY NAME, and the calls back into app.js go through a host seam. 61 functions + its own 24 _sectionPractice*/_sectionParents* scalars (read nowhere else) move out. 11 hooks come back in. 4 of those are read-only GETTERS — loopA/loopB/_audioSeekGen/_loopMutationGen are only ever READ here, never written, so app.js keeps owning them and NO state container is needed (a 977-site lift avoided). app.js used to reach IN and reset the module's state by hand (clearLoop() zeroed the selection; changeArrangement() invalidated the parent count). It cannot now — an imported binding is read-only — so those are exported as resetSelection() and invalidateParentCount(). Strictly better: the module owns its own invariants instead of trusting two callers on the far side of the file to zero the right three fields. ═══ THE SILENT-NO-OP PROBLEM, SOLVED ═══ The obvious host seam is an object of no-op defaults. That is a TRAP and we walked into it once: the plugin loader's seam defaulted populateVizPicker to `() => {}`, so a dropped wiring line would have left the viz picker quietly not refreshing with NO test, boot check, or bot noticing. Two layers stop it here: 1. RUNTIME — host.js is a Proxy with NO defaults and NO stubs. Reading an unwired hook THROWS. An unwired hook cannot degrade into a no-op because there is nothing to degrade INTO. configureHost() also rejects a non-function at WIRE time, and refuses to run twice. 2. STATIC — tests/js/host_contract.test.js asserts the hooks the modules USE are exactly the hooks app.js WIRES. This is the layer that matters: a runtime throw only fires if the broken path executes, and the whole danger of a seam is the paths that never run in a smoke test. VERIFIED TO BITE in all three drift directions: drop a hook from configureHost -> fails; rename host.setLoop in the module -> fails; wire a hook nobody uses -> fails. Writing that guard took three tries and each failure is instructive: (a) the configureHost regex anchored `});` at column 0, ran past the indented close, and swallowed app.js's 66-name window contract — 77 "hooks"; (b) an import-stripping regex with `[\s\S]*?` ate 14,000 characters INCLUDING the drift the bite test was meant to catch — a guard with a hole is worse than no guard, because you trust it; (c) `host.js'` in the import path backtracked from `js` to a "hook" called `j`. The bite tests are what surfaced all three. CODEX FOUND A REAL RACE [P2]. configureHost() was inside the async boot function, after several awaits — but the window handlers (onPhraseNext, …) go live during app.js's SYNCHRONOUS module evaluation. A user clicking one in that window would hit "[host] … was read before configureHost() ran". It is now a bare top-level statement sitting immediately before the window contract, so the seam is always wired before a handler can be reached. Verified live: invoking a handler 1.2s in — well before the boot awaits settle — works. VERIFIED. A/B against origin/main in two browsers with a REAL song loaded: popover toggle, practice-mode change, phrase-next, and clearLoop (all of which cross the seam — setLoop/clearLoop/_audioTime/loopA/loopB) — IDENTICAL, zero page errors. Since an unwired hook throws, a live app is itself proof the seam is wired. Harnesses: section_practice_dismiss retargeted; loop_api's clearLoop sandbox gains a resetSelection SPY (not a stub) and ASSERTS it fires — the guarantee is still tested, just through the seam. pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean, Codex 0. Co-authored-by: Claude Opus 4.8 (1M context) --- static/app.js | 1166 ++------------------ static/js/host.js | 99 ++ static/js/section-practice.js | 1214 +++++++++++++++++++++ tests/js/host_contract.test.js | 113 ++ tests/js/loop_api.test.js | 15 +- tests/js/section_practice_dismiss.test.js | 5 +- 6 files changed, 1506 insertions(+), 1106 deletions(-) create mode 100644 static/js/host.js create mode 100644 static/js/section-practice.js create mode 100644 tests/js/host_contract.test.js diff --git a/static/app.js b/static/app.js index 16595a7..a49ea5a 100644 --- a/static/app.js +++ b/static/app.js @@ -40,6 +40,36 @@ import { importSettings, } from './js/settings-io.js'; import { audio } from './js/audio-el.js'; +import { + _buildSectionParents, + _ensureSectionPracticeBar, + _hideSectionPracticeBar, + _installSectionPracticeDrawHook, + _maybeRefreshSectionPracticeDuration, + _placeSectionPracticeControlForChrome, + _resetSectionPracticeLog, + _scheduleSectionPracticeRetries, + _sectionPracticeBarContains, + _sectionPracticeBarIsReady, + _sectionPracticePopoverOpen, + _sectionPracticeSourceSections, + _sectionPracticeStartTime, + _setSectionPracticeMode, + _syncSectionPracticeFromLoop, + _updateSectionPracticeHighlight, + invalidateParentCount, + onPhraseNext, + onPhrasePrev, + onSectionParentClick, + onSectionPracticeModeChange, + onSectionPracticeWholeChange, + practiceSection, + renderSectionPracticeBar, + resetSelection, + toggleSectionPracticePopover, +} from './js/section-practice.js'; +import { configureHost } from './js/host.js'; + // Demo analytics — real impl set by demo.js; no-op in normal builds window.feedBackDemoTrack = window.feedBackDemoTrack ?? null; @@ -460,11 +490,6 @@ function _isSpaceKey(e) { return e.key === ' ' || e.key === 'Spacebar'; } -function _sectionPracticeBarContains(el) { - if (!el) return false; - const bar = document.getElementById('section-practice-bar'); - return !!(bar && bar.contains(el)); -} function _shortcutDispatchBlocked(e) { if (_isTextInput(e.target)) return true; @@ -5832,7 +5857,7 @@ async function changeArrangement(index) { // count. The A-B loop itself is left intact (time-based, song-global). _hideSectionPracticeBar(); _resetSectionPracticeLog(); - _sectionPracticeLastParentCount = -1; + invalidateParentCount(); highway.reconnect(currentFilename, index); window.feedBack.emit('arrangement:changed', { index, filename: currentFilename }); @@ -6405,9 +6430,7 @@ function clearLoop(options) { document.getElementById('btn-loop-save').classList.add('hidden'); document.getElementById('loop-label').textContent = ''; document.getElementById('saved-loops').value = ''; - _sectionPracticeSelected = -1; - _sectionPracticeWholeSection = false; - _sectionPracticeSavedPartIndex = 0; + resetSelection(); _updateSectionPracticeHighlight(_audioTime()); if (hadLoop && emitTransportEvent && typeof window !== 'undefined') { window.feedBack?.playback?.transportEvent?.('loop-cleared', { @@ -6620,1168 +6643,74 @@ function returnToEditorFromHighway() { } window.returnToEditorFromHighway = returnToEditorFromHighway; -// ── Section Practice Bar ──────────────────────────────────────────────── -// One-click looping over song section markers (highway.getSections — -// same array as 3D highway bundle.sections / "Now / Up Next"). -// Reuses setLoop() so manual A/B controls and saved loops stay canonical. -let _sectionPracticeRanges = []; -let _sectionPracticeSelected = -1; -let _sectionPracticeFollowParent = -1; -let _sectionPracticeDurSynced = false; -let _sectionPracticeLogged = false; -let _sectionPracticeHooked = false; -let _sectionPracticeRetryTimer = null; -let _sectionPracticeLastPlayableCount = 0; -let _sectionPracticePlayablePopulateRerendered = false; -// Last-rendered parent count, so the bar can re-render when the parent layout -// changes after the initial render — notably when the synthetic "Start" section -// appears as notes-before-the-first-marker stream in late. -let _sectionPracticeLastParentCount = -1; -// Start-time identity of the active parent, tracked so it can be remapped to the -// correct index when the parent layout shifts (a late "Start" prepend moves every -// real parent by one) instead of leaving the raw index pointing at the wrong one. -let _sectionPracticeActiveParentStart = NaN; -let _sectionPracticeMode = false; -let _sectionPracticeActiveParent = -1; -let _sectionPracticeWholeSection = false; -let _sectionPracticeSavedPartIndex = 0; -// Monotonic token to cancel stale practiceSection() retries: a newer click -// (or a song/arrangement change, which also bumps _audioSeekGen) supersedes -// any in-flight retry loop so it can't re-arm the wrong loop/count-in. -let _sectionPracticeRequestGen = 0; -// >0 while a practiceSection() request is awaiting its loop. While set, -// _syncSectionPracticeFromLoop() (e.g. from a mid-await bar re-render) must not -// reconcile against the half-applied / previous loop — practiceSection owns the -// section state and applies it once its own gen check passes. -let _sectionPracticeRequestInFlight = 0; -function _setSectionPracticeMode(on, opts = {}) { - const next = !!on; - if (next === _sectionPracticeMode && !opts.force) return; - _sectionPracticeMode = next; - const cb = document.getElementById('section-practice-mode'); - if (cb) cb.checked = _sectionPracticeMode; - // Surface the "looping" state on the collapsed pill so the user can tell - // Section Practice is armed without opening the popover. - const pill = document.getElementById('section-practice-pill'); - if (pill) pill.classList.toggle('section-practice-pill--active', _sectionPracticeMode); - _sectionPracticeFollowParent = -1; - if (_sectionPracticeMode) { - if (opts.defaultWholeOn) { - _sectionPracticeWholeSection = true; - } - _updateSectionPracticeHighlight(_audioTime()); - if (opts.defaultWholeOn) { - _syncSectionPracticePieceUi(); - } - } else { - // Turning the feature off must cancel any in-flight practiceSection() - // retry: otherwise a stale setLoop() that lands after the user unchecks - // Section Practice would re-arm the loop, flip the mode back on via - // _syncSectionPracticeFromLoop(), and restart playback through - // startCountIn(). Bumping the request gen makes the pending retry bail. - _sectionPracticeRequestGen++; - // Cancel any pending count-in: every section-practice teardown routes - // through here (mode toggle off, clearLoop, and _hideSectionPracticeBar - // on song/arrangement change), so a countdown started by a prior section - // click must not resume playback after the user has turned practice off. - _cancelCountIn(); - _sectionPracticeSelected = -1; - _sectionPracticeWholeSection = false; - _sectionPracticeSavedPartIndex = 0; - _updateSectionPracticeHighlight(_audioTime()); - if (!opts.skipClearLoop && (loopA !== null || loopB !== null)) { - clearLoop(); - } - } -} -function onSectionPracticeModeChange() { - const cb = document.getElementById('section-practice-mode'); - if (!cb) return; - const turningOn = cb.checked && !_sectionPracticeMode; - _setSectionPracticeMode(cb.checked, { defaultWholeOn: turningOn }); -} -function _resetSectionPracticeLog() { - _sectionPracticeLogged = false; - _sectionPracticeLastPlayableCount = 0; - _sectionPracticePlayablePopulateRerendered = false; -} -function _sectionPracticeHighway() { - return window.highway || (typeof highway !== 'undefined' ? highway : null); -} -function _sectionPracticeDuration() { - 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; -} -function _sectionPracticeSourceSections() { - const hw = _sectionPracticeHighway(); - if (!hw || typeof hw.getSections !== 'function') return []; - const raw = hw.getSections(); - return Array.isArray(raw) ? raw : []; -} -function _sectionPracticeStartTime(s) { - const t = s.time ?? s.startTime ?? s.start_time ?? s.start; - const n = Number(t); - return Number.isFinite(n) ? n : NaN; -} -function _sectionPracticeBaseName(rawName, fallbackIndex) { - let s = (typeof rawName === 'string' ? rawName : '').trim(); - if (!s) s = `Section ${fallbackIndex + 1}`; - // Normalise separators and strip common trailing digits like "Chorus 2" - s = s.replace(/_/g, ' '); - s = s.replace(/\s*\d+$/u, ''); - const lower = s.toLowerCase(); - const canonical = { - intro: 'Intro', - verse: 'Verse', - chorus: 'Chorus', - bridge: 'Bridge', - solo: 'Solo', - riff: 'Riff', - outro: 'Outro', - }[lower]; - if (canonical) return canonical; - // Fallback: title-case words - return lower.split(/\s+/).filter(Boolean).map(w => w[0].toUpperCase() + w.slice(1)).join(' ') || `Section ${fallbackIndex + 1}`; -} -const _SECTION_PRACTICE_START_GAP_SEC = 0.05; -function _sectionPracticeNoteTime(note) { - const t = note?.t ?? note?.time ?? note?.start_time ?? note?.start; - const n = Number(t); - return Number.isFinite(n) ? n : NaN; -} -function _sectionPracticePlayableCount() { - const hw = _sectionPracticeHighway(); - if (!hw) return 0; - let count = 0; - if (typeof hw.getNotes === 'function') { - const notes = hw.getNotes(); - if (notes?.length) count += notes.length; - } - if (typeof hw.getChords === 'function') { - const chords = hw.getChords(); - if (chords?.length) count += chords.length; - } - return count; -} -function _sectionPracticeHasNotesBefore(beforeTime) { - const hw = _sectionPracticeHighway(); - if (!hw) return false; - const cutoff = Number(beforeTime); - if (!Number.isFinite(cutoff)) return false; - const sources = []; - if (typeof hw.getNotes === 'function') { - const notes = hw.getNotes(); - if (notes?.length) sources.push(notes); - } - if (typeof hw.getChords === 'function') { - const chords = hw.getChords(); - if (chords?.length) sources.push(chords); - } - for (let s = 0; s < sources.length; s++) { - const items = sources[s]; - for (let i = 0; i < items.length; i++) { - const t = _sectionPracticeNoteTime(items[i]); - if (Number.isFinite(t) && t < cutoff) return true; - } - } - return false; -} -function _maybeRerenderSectionPracticeOnPlayableLoad() { - const count = _sectionPracticePlayableCount(); - const prev = _sectionPracticeLastPlayableCount; - _sectionPracticeLastPlayableCount = count; - if (!_sectionPracticeSourceSections().length || !_sectionPracticeBarIsReady()) return; - // Re-render whenever the parent layout changes after the bar is up — the - // synthetic "Start" section can appear (±1 parent) once a note before the - // first marker streams in, which would otherwise leave the DOM chip indices - // out of sync with _buildSectionParents() (clicks/highlights hitting the - // wrong section). _buildSectionParents() is memoized, so this is cheap. - const parents = _buildSectionParents(); - const parentCount = parents.length; - if (parentCount !== _sectionPracticeLastParentCount) { - // Remap the active parent by start-time identity before re-rendering: a - // late "Start" prepend shifts every real parent's index, so the raw - // index would otherwise point at the wrong section (mis-highlighting and - // breaking whole/prev/next). Selected/part indices are within-parent and - // unaffected. Skip when no active parent or no prior snapshot. - if (_sectionPracticeActiveParent >= 0 && Number.isFinite(_sectionPracticeActiveParentStart)) { - const remapped = parents.findIndex( - (p) => Math.abs(p.start - _sectionPracticeActiveParentStart) < 0.001, - ); - if (remapped >= 0) _sectionPracticeActiveParent = remapped; - } - _sectionPracticeLastParentCount = parentCount; - renderSectionPracticeBar(); - _sectionPracticeActiveParentStart = - (_sectionPracticeActiveParent >= 0 && parents[_sectionPracticeActiveParent]) - ? parents[_sectionPracticeActiveParent].start : NaN; - return; - } - // Keep the active-parent start snapshot fresh while the layout is stable, so - // it holds the correct pre-change value when the layout next shifts. - _sectionPracticeActiveParentStart = - (_sectionPracticeActiveParent >= 0 && parents[_sectionPracticeActiveParent]) - ? parents[_sectionPracticeActiveParent].start : NaN; - if (_sectionPracticePlayablePopulateRerendered) return; - if (prev !== 0 || count === 0) return; - _sectionPracticePlayablePopulateRerendered = true; - renderSectionPracticeBar(); -} -// _buildSectionParents() runs on the 60 Hz highlight path, so memoize it. -// The parent layout is a pure function of the highway's section list (a -// stable array reference per song), the song duration, and whether any -// notes/chords precede the first marker (the synthetic "Start" section). -// That last input can flip while WS note chunks are still streaming in, so -// the note/chord counts are part of the key; once a song is fully loaded -// all four inputs stabilize and the per-frame call becomes a cache hit. -// Every call site uses the result read-only, so returning the cached array -// reference is safe. -let _sectionParentsCache = null; -let _sectionParentsCacheRaw = null; -let _sectionParentsCacheDur = -1; -let _sectionParentsCacheNoteLen = -1; -let _sectionParentsCacheChordLen = -1; -function _buildSectionParents() { - const raw = _sectionPracticeSourceSections(); - if (!raw.length) return []; - const dur = _sectionPracticeDuration(); - const hw = _sectionPracticeHighway(); - const noteLen = (hw && typeof hw.getNotes === 'function' && hw.getNotes()?.length) || 0; - const chordLen = (hw && typeof hw.getChords === 'function' && hw.getChords()?.length) || 0; - if (_sectionParentsCache !== null - && _sectionParentsCacheRaw === raw - && _sectionParentsCacheDur === dur - && _sectionParentsCacheNoteLen === noteLen - && _sectionParentsCacheChordLen === chordLen) { - return _sectionParentsCache; - } - const sorted = [...raw].sort((a, b) => _sectionPracticeStartTime(a) - _sectionPracticeStartTime(b)); - // Step 1: collapse consecutive same-name markers into logical groups. - const groups = []; - for (let i = 0; i < sorted.length; i++) { - const start = _sectionPracticeStartTime(sorted[i]); - if (!Number.isFinite(start)) continue; - const baseName = _sectionPracticeBaseName(sorted[i].name, groups.length); - const prev = groups[groups.length - 1]; - if (prev && prev.baseName === baseName) { - prev.lastIndex = i; - } else { - groups.push({ baseName, firstIndex: i, lastIndex: i }); - } - } - if (!groups.length) return []; - // Step 2: assign musician-friendly labels with counters (Verse 1, Verse 2, …). - const counters = Object.create(null); - const ranges = []; - for (let gi = 0; gi < groups.length; gi++) { - const g = groups[gi]; - const base = g.baseName; - const count = (counters[base] || 0) + 1; - counters[base] = count; - const label = `${base} ${count}`; - const firstSec = sorted[g.firstIndex]; - const start = _sectionPracticeStartTime(firstSec); - if (!Number.isFinite(start)) continue; - let end; - if (gi + 1 < groups.length) { - const nextFirst = sorted[groups[gi + 1].firstIndex]; - end = _sectionPracticeStartTime(nextFirst); - } else { - end = dur; - } - if (!Number.isFinite(end) || end <= start) { - end = dur > start ? dur : start + 4; - } - ranges.push({ name: label, start, end }); - } - if (ranges.length > 0) { - const firstStart = Number(ranges[0].start); - if (Number.isFinite(firstStart) && firstStart > _SECTION_PRACTICE_START_GAP_SEC - && _sectionPracticeHasNotesBefore(firstStart)) { - ranges.unshift({ name: 'Start', start: 0, end: firstStart }); - } - } - _sectionParentsCache = ranges; - _sectionParentsCacheRaw = raw; - _sectionParentsCacheDur = dur; - _sectionParentsCacheNoteLen = noteLen; - _sectionParentsCacheChordLen = chordLen; - return ranges; -} -function _sectionPracticeResetSelectionUi() { - _sectionPracticeActiveParent = -1; - _sectionPracticeSelected = -1; - _sectionPracticeWholeSection = false; - _sectionPracticeSavedPartIndex = 0; - _sectionPracticeRanges = []; -} -function _sectionPracticeSourcePhrases() { - const hw = _sectionPracticeHighway(); - if (!hw || typeof hw.getPracticePhrases !== 'function') return null; - const raw = hw.getPracticePhrases(); - return (raw && raw.length) ? raw : null; -} -function _buildPhrasePartsForParent(parent) { - if (!parent) return []; - const dur = _sectionPracticeDuration(); - const windowStart = parent.start; - const windowEnd = parent.end; - const phrases = _sectionPracticeSourcePhrases(); - const parts = []; - if (phrases) { - const inWindow = phrases.filter( - (ph) => ph.start_time >= windowStart - 0.001 && ph.start_time < windowEnd - 0.001, - ); - if (inWindow.length) { - for (let i = 0; i < inWindow.length; i++) { - const ph = inWindow[i]; - let start = ph.start_time; - let end = ph.end_time; - if (!Number.isFinite(end) || end > windowEnd) end = windowEnd; - if (!Number.isFinite(start) || end <= start) continue; - if (dur && Number.isFinite(dur) && end > dur) end = dur; - parts.push({ name: parent.name, start, end }); - } - // Snap first part to section start so the loop aligns with the selected marker - // when the first in-window phrase iteration begins later (e.g. Chorus 2). - if (parts.length > 0 && parts[0].start > windowStart) { - parts[0].start = windowStart; - } - return parts; - } - } - let start = windowStart; - let end = windowEnd; - if (dur && Number.isFinite(dur) && end > dur) end = dur; - if (Number.isFinite(start) && Number.isFinite(end) && end > start) { - parts.push({ name: parent.name, start, end }); - } - return parts; -} -function _buildSectionPracticeRanges() { - if (_sectionPracticeActiveParent < 0) return []; - const parents = _buildSectionParents(); - const parent = parents[_sectionPracticeActiveParent]; - if (!parent) return []; - return _buildPhrasePartsForParent(parent); -} -function _sectionPracticeActiveParentRange() { - if (_sectionPracticeActiveParent < 0) return null; - const parents = _buildSectionParents(); - const parent = parents[_sectionPracticeActiveParent]; - if (!parent) return null; - const dur = _sectionPracticeDuration(); - let end = Number(parent.end); - const start = Number(parent.start); - if (dur && Number.isFinite(dur) && end > dur) end = dur; - if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null; - return { name: parent.name, start, end }; -} -function _sectionPracticeResolveLoopTarget(index, opts = {}) { - if (opts.whole) { - return _sectionPracticeActiveParentRange(); - } - return _sectionPracticeRanges[index] ?? null; -} -function _formatSectionPracticeName(name) { - return name.replace(/_/g, ' '); -} -const _SECTION_PRACTICE_CHIP_KINDS = new Set([ - 'intro', 'verse', 'chorus', 'bridge', 'solo', 'riff', 'outro', -]); -function _sectionPracticeChipKindClass(name, index) { - const base = _sectionPracticeBaseName(name, index); - const kind = base.toLowerCase(); - if (!_SECTION_PRACTICE_CHIP_KINDS.has(kind)) return ''; - return ` section-practice-chip--${kind}`; -} -function _sectionPracticeWholeCheckboxHtml() { - return ''; -} -function _sectionPracticePieceRowHtml() { - return '
' - + 'Part — of —' - + '' - + '' - + '
'; -} -function _sectionPracticeMainRow() { - const bar = document.getElementById('section-practice-bar'); - if (!bar) return null; - return bar.querySelector('.section-practice-controls-row') - || bar.querySelector('.section-practice-primary-row') - || bar.querySelector('.section-practice-row:not(.section-practice-piece-row):not(.section-practice-chips-row)'); -} -function _migrateSectionPracticeDomLayout(bar) { - if (!bar || bar.querySelector('.section-practice-controls-row')) return; - const pieceRow = document.getElementById('section-practice-piece-row'); - const scroll = document.getElementById('section-practice-scroll'); - const modeWrap = bar.querySelector('.section-practice-mode-wrap'); - const wholeWrap = bar.querySelector('.section-practice-whole-wrap'); - let label = bar.querySelector('.section-practice-label'); - const controlsRow = document.createElement('div'); - controlsRow.className = 'section-practice-row section-practice-controls-row'; - if (modeWrap) controlsRow.appendChild(modeWrap); - if (wholeWrap) controlsRow.appendChild(wholeWrap); - if (pieceRow) controlsRow.appendChild(pieceRow); - const chipsRow = document.createElement('div'); - chipsRow.className = 'section-practice-row section-practice-chips-row'; - if (label) { - chipsRow.appendChild(label); - } else { - label = document.createElement('span'); - label.className = 'section-practice-label'; - label.textContent = 'Sections:'; - chipsRow.appendChild(label); - } - if (scroll) chipsRow.appendChild(scroll); - bar.replaceChildren(controlsRow, chipsRow); -} -function _sectionPracticeBarInnerHtml() { - return '
' - + '' - + _sectionPracticeWholeCheckboxHtml() - + _sectionPracticePieceRowHtml() - + '
' - + '
' - + 'Sections:' - + '' - + '
'; -} -function _ensureSectionPracticeWholeCheckbox() { - const existing = document.getElementById('section-practice-whole'); - const mainRow = _sectionPracticeMainRow(); - if (!mainRow) return; - if (existing) { - const wrap = existing.closest('.section-practice-whole-wrap'); - if (wrap && !mainRow.contains(wrap)) { - const modeWrap = mainRow.querySelector('.section-practice-mode-wrap'); - if (modeWrap) modeWrap.insertAdjacentElement('afterend', wrap); - else mainRow.insertBefore(wrap, mainRow.firstChild); - } - return; - } - const modeWrap = mainRow.querySelector('.section-practice-mode-wrap'); - if (modeWrap) { - modeWrap.insertAdjacentHTML('afterend', _sectionPracticeWholeCheckboxHtml()); - } else { - mainRow.insertAdjacentHTML('afterbegin', _sectionPracticeWholeCheckboxHtml()); - } -} -function _sectionPracticeCurrentPartIndex() { - const total = _sectionPracticeRanges.length; - if (!total) return 0; - if (!_sectionPracticeWholeSection && _sectionPracticeSelected >= 0) { - return Math.min(_sectionPracticeSelected, total - 1); - } - if (_sectionPracticeSavedPartIndex >= 0) { - return Math.min(_sectionPracticeSavedPartIndex, total - 1); - } - return 0; -} -function _sectionPracticePillHtml() { - return ''; -} -function _syncSectionPracticePillV3Chrome(isV3) { - const pill = document.getElementById('section-practice-pill'); - if (!pill) return; - pill.classList.toggle('v3-rail-icon', isV3); - let ring = pill.querySelector('.v3-rail-border'); - if (isV3) { - if (!ring) { - ring = document.createElement('span'); - ring.className = 'v3-rail-border'; - ring.setAttribute('aria-hidden', 'true'); - pill.insertBefore(ring, pill.firstChild); - } - pill.setAttribute('title', 'Practice'); - pill.setAttribute('aria-label', 'Practice'); - } else { - if (ring) ring.remove(); - pill.setAttribute('title', 'Section practice'); - pill.setAttribute('aria-label', 'Section practice'); - } -} -// Wrap an existing #section-practice-bar in the pill control (creating the -// wrapper + pill if missing). Defensive: works whether the bar came from the -// static markup (already wrapped) or a chrome whose index.html predates the -// pill (e.g. a not-yet-rebased v3 build) — the bar is always reachable as a -// closed popover behind the pill afterward. -function _ensureSectionPracticeControlWrap(bar) { - if (!bar) return null; - let ctrl = (bar.closest && bar.closest('.section-practice-control')) - || document.getElementById('section-practice-control'); - if (ctrl) { - if (!ctrl.contains(bar)) ctrl.appendChild(bar); - } else { - ctrl = document.createElement('div'); - ctrl.id = 'section-practice-control'; - ctrl.className = 'section-practice-control section-practice-control--hidden'; - if (bar.parentNode) bar.parentNode.insertBefore(ctrl, bar); - ctrl.appendChild(bar); - } - if (!ctrl.querySelector('#section-practice-pill')) { - ctrl.insertAdjacentHTML('afterbegin', _sectionPracticePillHtml()); - } - // Popover visibility is driven by --open now; clear any legacy hidden class. - bar.classList.remove('section-practice-bar--hidden'); - _mountSectionPracticeControlSafe(ctrl); - return ctrl; -} -// Mount the pill control so its popover — whose chip `; - }).join(''); - _sectionPracticeRanges = _buildSectionPracticeRanges(); - // Reconcile any active A-B loop with the (re)rendered section bar. Called - // unconditionally so a loop that arrived before the section markers — e.g. - // a Saved Loop or window.feedBack.setLoop() during song load, when no - // parent was active yet — still re-selects its chip once markers appear. - // _syncSectionPracticeFromLoop() scans all parents, so it can activate the - // matching one; run it before the piece UI so that reflects the result. - _syncSectionPracticeFromLoop(); - _syncSectionPracticePieceUi(); - _updateSectionPracticeHighlight(_audioTime()); -} - -async function onSectionParentClick(parentIdx) { - const parents = _buildSectionParents(); - const idx = Number(parentIdx); - if (!Number.isFinite(idx) || idx < 0 || idx >= parents.length) return; - _sectionPracticeActiveParent = idx; - _sectionPracticeRanges = _buildSectionPracticeRanges(); - _sectionPracticeSelected = -1; - _sectionPracticeSavedPartIndex = 0; - _sectionPracticeWholeSection = true; - _syncSectionPracticePieceUi(); - _updateSectionPracticeHighlight(_audioTime()); - if (_sectionPracticeActiveParentRange() || _sectionPracticeRanges.length) { - await practiceSection(0, { whole: true }); - } -} - -async function onSectionPracticeWholeChange() { - const cb = document.getElementById('section-practice-whole'); - if (!cb || _sectionPracticeActiveParent < 0) return; - const total = _sectionPracticeRanges.length; - if (!total) return; - if (cb.checked === _sectionPracticeWholeSection) return; - _sectionPracticeWholeSection = cb.checked; - if (cb.checked) { - await practiceSection(_sectionPracticeCurrentPartIndex(), { whole: true }); - return; - } - await practiceSection(0); -} - -async function onPhrasePrev() { - const total = _sectionPracticeRanges.length; - if (!total || _sectionPracticeActiveParent < 0) return; - if (_sectionPracticeWholeSection) { - _sectionPracticeWholeSection = false; - _syncSectionPracticePieceUi(); - await practiceSection(0); - return; - } - const cur = _sectionPracticeSelected >= 0 ? _sectionPracticeSelected : 0; - if (cur <= 0) return; - await practiceSection(cur - 1); -} - -async function onPhraseNext() { - const total = _sectionPracticeRanges.length; - if (!total || _sectionPracticeActiveParent < 0) return; - if (_sectionPracticeWholeSection) { - _sectionPracticeWholeSection = false; - _syncSectionPracticePieceUi(); - await practiceSection(0); - return; - } - const cur = _sectionPracticeSelected >= 0 ? _sectionPracticeSelected : 0; - if (cur >= total - 1) return; - await practiceSection(cur + 1); -} window.onSectionParentClick = onSectionParentClick; window.onSectionPracticeWholeChange = onSectionPracticeWholeChange; window.onPhrasePrev = onPhrasePrev; window.onPhraseNext = onPhraseNext; -// Find which section parent / phrase part the active A-B loop corresponds to. -// Scans ALL parents (not just the active one) so a loop arriving from Saved -// Loops or window.feedBack.setLoop() can re-select the right chip even when -// its parent isn't the currently-active one. Returns { parentIdx, whole } or -// { parentIdx, whole:false, index } (the matching phrase part), or null. -function _sectionPracticeLoopMatch() { - if (loopA === null || loopB === null) return null; - const parents = _buildSectionParents(); - for (let parentIdx = 0; parentIdx < parents.length; parentIdx++) { - const parent = parents[parentIdx]; - let partMatch = -1; - const parts = _buildPhrasePartsForParent(parent); - for (let i = 0; i < parts.length; i++) { - if (Math.abs(parts[i].start - loopA) < 0.05 && Math.abs(parts[i].end - loopB) < 0.05) { - partMatch = i; - break; - } - } - const wholeMatch = Math.abs(parent.start - loopA) < 0.05 && Math.abs(parent.end - loopB) < 0.05; - if (wholeMatch && partMatch >= 0) { - // A single-part section's part range coincides with the whole - // section. Preserve the user's whole/part intent when this is the - // already-active parent; otherwise default to whole-section. - if (parentIdx === _sectionPracticeActiveParent && !_sectionPracticeWholeSection) { - return { parentIdx, whole: false, index: partMatch }; - } - return { parentIdx, whole: true }; - } - if (wholeMatch) return { parentIdx, whole: true }; - if (partMatch >= 0) return { parentIdx, whole: false, index: partMatch }; - } - return null; -} -function _blurSectionPracticeFocusIfNeeded() { - const ae = document.activeElement; - const bar = document.getElementById('section-practice-bar'); - if (ae && bar && bar.contains(ae) && typeof ae.blur === 'function') { - ae.blur(); - } -} -async function practiceSection(index, opts = {}) { - const requestGen = ++_sectionPracticeRequestGen; - const seekGen = _audioSeekGen; - const loopGen = _loopMutationGen; - const whole = !!opts.whole; - const r = _sectionPracticeResolveLoopTarget(index, opts); - if (!r) return; - const dur = _sectionPracticeDuration(); - const start = Number(r.start); - let end = Number(r.end); - if (dur && Number.isFinite(dur) && end > dur) end = dur; - if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return; - // Mark the request in-flight so a bar re-render that fires during the awaited - // setLoop below doesn't reconcile section state against the old/half-applied - // loop. Cleared in finally so every exit path (bail, success, failure) resets. - _sectionPracticeRequestInFlight++; - try { - _cancelCountIn(); - _setSectionPracticeMode(true, { skipClearLoop: true }); - // setLoop() is seek-gated: it returns false when the seek is cancelled - // during arrangement switches / teardown-gen bumps, or when the backend - // clock clamps off-target. Retry briefly to land after the transport - // becomes ready without forking the loop system. - 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 !== _audioSeekGen || loopGen !== _loopMutationGen) return; - try { - // skipSectionSync: this function owns the section-practice state and - // applies it below under the request-gen guard, so a stale retry - // landing here can't re-sync/re-arm via setLoop's shared path. - // commitGuard: also prevent a superseded retry from committing - // loopA/loopB at all — setLoop re-checks this right before arming, - // after its internal seek await, so a stale loop is never armed. - ok = await setLoop(start, end, { - skipSectionSync: true, - commitGuard: () => requestGen === _sectionPracticeRequestGen && seekGen === _audioSeekGen && loopGen === _loopMutationGen, - }); - } catch (err) { - ok = false; - } - if (ok) break; - 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 !== _audioSeekGen || loopGen !== _loopMutationGen) return; - if (ok) { - _sectionPracticeWholeSection = whole; - if (!whole) { - _sectionPracticeSelected = index; - _sectionPracticeSavedPartIndex = index; - } - _blurSectionPracticeFocusIfNeeded(); - _updateSectionPracticeHighlight(_audioTime()); - startCountIn({ immediate: true }); - } else { - _setSectionPracticeMode(false, { skipClearLoop: true }); - } - } finally { - _sectionPracticeRequestInFlight--; - } -} -function _syncSectionPracticeFromLoop() { - // A practiceSection() request owns the section state while it awaits its - // loop; reconciling here against the prior/half-applied loop would fight it - // (snapping the active parent back or toggling the mode off mid-request). - if (_sectionPracticeRequestInFlight > 0) return; - if (!_buildSectionParents().length) return; - const match = _sectionPracticeLoopMatch(); - if (match) { - // The loop may belong to a parent that isn't currently active (e.g. - // restored from Saved Loops); switch to it and rebuild its parts so - // the part-level UI reflects the matched section. - if (match.parentIdx !== _sectionPracticeActiveParent) { - _sectionPracticeActiveParent = match.parentIdx; - _sectionPracticeRanges = _buildSectionPracticeRanges(); - } - _sectionPracticeWholeSection = match.whole; - if (!match.whole) { - _sectionPracticeSelected = match.index; - _sectionPracticeSavedPartIndex = match.index; - } else { - _sectionPracticeSelected = -1; - } - } else { - _sectionPracticeWholeSection = false; - _sectionPracticeSelected = -1; - } - if (loopA !== null && loopB !== null) { - if (match) { - if (!_sectionPracticeMode) { - _setSectionPracticeMode(true, { skipClearLoop: true }); - } - } else if (_sectionPracticeMode) { - _setSectionPracticeMode(false, { skipClearLoop: true }); - } - } else if (_sectionPracticeMode) { - _setSectionPracticeMode(false, { skipClearLoop: true }); - } - _updateSectionPracticeHighlight(_audioTime()); -} -function _sectionPracticeIndexAtTime(t) { - if (!Number.isFinite(t) || _sectionPracticeRanges.length === 0) return -1; - for (let i = _sectionPracticeRanges.length - 1; i >= 0; i--) { - if (t >= _sectionPracticeRanges[i].start) return i; - } - return -1; -} -function _sectionPracticeParentIndexAtTime(t) { - const parents = _buildSectionParents(); - if (!Number.isFinite(t) || parents.length === 0) return -1; - for (let i = parents.length - 1; i >= 0; i--) { - if (t >= parents[i].start) return i; - } - return -1; -} -function _scrollSectionPracticeChipIntoView(chip) { - if (!chip) return; - chip.scrollIntoView({ block: 'nearest', inline: 'nearest' }); -} - -function _updateSectionPracticeHighlight(ct) { - const scroll = document.getElementById('section-practice-scroll'); - if (!scroll) return; - const chips = scroll.querySelectorAll('.section-practice-chip[data-parent-idx]'); - if (!chips.length) return; - - const followEnabled = !_sectionPracticeMode && _sectionPracticeBarIsReady(); - const followParent = followEnabled ? _sectionPracticeParentIndexAtTime(ct) : -1; - - chips.forEach((chip) => { - const idx = Number(chip.dataset.parentIdx); - chip.classList.toggle('is-selected', idx === _sectionPracticeActiveParent); - chip.classList.toggle('is-playing', followEnabled && idx === followParent); - }); - - if (followEnabled && followParent >= 0 && followParent !== _sectionPracticeFollowParent) { - _sectionPracticeFollowParent = followParent; - const chip = scroll.querySelector(`.section-practice-chip[data-parent-idx="${followParent}"]`); - _scrollSectionPracticeChipIntoView(chip); - } else if (!followEnabled) { - _sectionPracticeFollowParent = -1; - } - - _syncSectionPracticePieceUi(); -} - -function _maybeRefreshSectionPracticeDuration(dur) { - if (_sectionPracticeDurSynced || !dur || _sectionPracticeRanges.length === 0) return; - const rebuilt = _buildSectionPracticeRanges(); - if (!rebuilt.length) return; - const prevEnd = _sectionPracticeRanges[_sectionPracticeRanges.length - 1].end; - const nextEnd = rebuilt[rebuilt.length - 1].end; - if (Math.abs(prevEnd - nextEnd) > 0.05) { - _sectionPracticeDurSynced = true; - renderSectionPracticeBar(); - } else { - _sectionPracticeDurSynced = true; - } -} - -// Re-render when section metadata appears (before audio duration is known). -function _ensureSectionPracticeBar() { - if (_sectionPracticeSourceSections().length === 0) return; - if (!_sectionPracticeBarIsReady()) { - renderSectionPracticeBar(); - } -} async function loadSavedLoops() { @@ -9431,6 +8360,37 @@ async function checkScanAndLoad() { // // Guarded by tests/js/window_contract.test.js. Add a name here the moment // anything outside app.js calls it. +// ── The host seam ─────────────────────────────────────────────────────────── +// Hand app.js's own functions DOWN to the carved modules. +// +// This runs at TOP LEVEL, during app.js's synchronous module evaluation, and +// deliberately sits immediately before the window contract below — because that +// contract is what makes the carved handlers (onPhraseNext, practiceSection, …) +// clickable. Wiring the seam inside the async boot function instead would leave a +// real window: app.js's body finishes, the handlers go live on `window`, and a user +// clicking one before the awaits resolve would hit +// `[host] … was read before configureHost() ran`. Synchronous, and ordered ahead of +// the handlers, closes that. +// +// ./js/host.js THROWS on an unwired hook rather than quietly returning undefined, and +// tests/js/host_contract.test.js fails CI if this list and the host.* uses under +// static/js/ ever drift apart. +configureHost({ + _audioTime, + _audioDuration, + setLoop, + clearLoop, + startCountIn, + _cancelCountIn, + formatTime, + // Read-only getters. app.js still OWNS these reassigned scalars — the module only + // ever reads them, so no state container is needed. + loopA: () => loopA, + loopB: () => loopB, + _audioSeekGen: () => _audioSeekGen, + _loopMutationGen: () => _loopMutationGen, +}); + Object.assign(window, { _confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl, _librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal, diff --git a/static/js/host.js b/static/js/host.js new file mode 100644 index 0000000..2bf1bdc --- /dev/null +++ b/static/js/host.js @@ -0,0 +1,99 @@ +// The host seam — how a carved-out module calls back into app.js. +// +// WHY THIS EXISTS. What is left in app.js is not a tree, it is a cycle: seeding a +// dependency closure from count-in, from loops, from section-practice, or from the +// JUCE seek shim all return the SAME 178-function set, and setLoop() and +// practiceSection() call each other directly. So a module carved out of that +// component will always need to call back into app.js — and it cannot `import` +// app.js to do it, because app.js imports the module, and that closes a cycle the +// import-x/no-cycle gate (rightly) rejects. +// +// So app.js hands its functions DOWN, once, at boot: `configureHost({ playSong, … })`. +// +// ─── THE FAILURE MODE THIS IS BUILT TO PREVENT ─────────────────────────────── +// +// The obvious way to write this is a plain object with no-op defaults. That is a +// TRAP, and we walked into it once already: the plugin loader's host seam defaulted +// `populateVizPicker` to `() => {}`, which means that if the wiring call in app.js +// is ever dropped, renamed, or drifts, the loader keeps running, the viz picker +// silently stops refreshing, and NOTHING — no test, no boot check, no bot — says a +// word. A feature just quietly stops existing. +// +// Two layers stop that here, and the second is the one that actually closes it: +// +// 1. RUNTIME — reading an unwired hook THROWS. There are no defaults and no +// stubs. `host.playSong` either is the real function or it is a loud error. +// An unwired hook cannot degrade into a no-op, because there is nothing for +// it to degrade INTO. +// +// 2. STATIC — tests/js/host_contract.test.js asserts that the set of hooks the +// modules USE is exactly the set app.js WIRES. This is the important one: +// layer 1 only fires if the broken path actually executes, and the whole +// danger of this seam is paths that don't run in a smoke test. The static +// check catches a drifted or misspelled hook in CI, on a path nobody ran. +// +// Consequence for anyone adding a hook: add it to the configureHost({…}) call in +// app.js *and* use it as `host.`. The contract test fails on either alone — +// deliberately. A hook wired but never used is dead weight; a hook used but never +// wired is a bug that would otherwise hide. + +const _hooks = Object.create(null); +let _configured = false; + +/** + * Called ONCE by app.js at boot, before any carved module runs. Every value must + * be a function — a hook that is accidentally `undefined` (a typo, a renamed + * export, a dropped line) fails HERE, at startup, rather than silently much later. + */ +export function configureHost(hooks) { + if (_configured) { + throw new Error('[host] configureHost() called twice — it must be wired exactly once, at boot.'); + } + const bad = Object.entries(hooks || {}) + .filter(([, v]) => typeof v !== 'function') + .map(([k]) => k); + if (bad.length) { + throw new Error( + `[host] these hooks are not functions: ${bad.join(', ')}. ` + + 'A hook is usually undefined because it was renamed or its line was dropped.', + ); + } + Object.assign(_hooks, hooks); + _configured = true; +} + +/** + * The seam itself. Reading a hook that was never wired THROWS — it never returns + * undefined and never returns a silent no-op. See the note at the top: a no-op + * default is precisely the bug this module exists to make impossible. + */ +export const host = new Proxy(Object.create(null), { + get(_target, name) { + if (typeof name === 'symbol') return undefined; // let JS probe it freely + if (!_configured) { + throw new Error( + `[host] host.${name} was read before configureHost() ran. ` + + 'app.js must call configureHost() at boot, before any carved module executes.', + ); + } + const fn = _hooks[name]; + if (typeof fn !== 'function') { + throw new Error( + `[host] host.${name} is not wired. Add it to the configureHost({ … }) ` + + 'call in app.js. (tests/js/host_contract.test.js should have caught this in CI.)', + ); + } + return fn; + }, + // Keep the object honest for anything that introspects it. + has(_target, name) { return name in _hooks; }, + ownKeys() { return Object.keys(_hooks); }, + getOwnPropertyDescriptor(_target, name) { + return name in _hooks + ? { value: _hooks[name], enumerable: true, configurable: true, writable: false } + : undefined; + }, + set(_target, name) { + throw new Error(`[host] host.${String(name)} is read-only — hooks are wired only via configureHost().`); + }, +}); diff --git a/static/js/section-practice.js b/static/js/section-practice.js new file mode 100644 index 0000000..e7fc00e --- /dev/null +++ b/static/js/section-practice.js @@ -0,0 +1,1214 @@ +// Section practice — the practice-a-section bar, its phrase parts, and the popover +// that drives them. +// +// THE FIRST SLICE OUT OF app.js's STRONGLY-CONNECTED CORE. It could not be cut by +// dependency closure: seeding a closure from section-practice, from loops, from +// count-in, or from the JUCE seek shim all return the SAME 178-function set, because +// setLoop() and practiceSection() call each other. So it is cut BY NAME, and +// everything it calls back into app.js goes through the host seam. +// +// It owns its own state — the _sectionPractice* / _sectionParents* scalars are read +// nowhere else and move in with it. It needs 11 hooks from app.js, and four of those +// are read-only GETTERS: loopA / loopB / _audioSeekGen / _loopMutationGen are only +// ever READ here, never written, so app.js keeps owning them and no state container +// is needed. +// +// app.js used to reach IN and reset this module's state directly (clearLoop() zeroed +// the selection; changeArrangement() invalidated the parent count). It cannot now — an +// imported binding is read-only — so those are exported as resetSelection() and +// invalidateParentCount(). That is strictly better: the module owns its own invariants +// instead of trusting two callers on the far side of the file to zero the right fields. +// +// ON THE SEAM: see ./host.js. Reading an unwired hook THROWS — there are no no-op +// defaults, because a host seam that can silently no-op is a trap. The plugin loader's +// seam had exactly that shape, and a dropped wiring line would have left the viz picker +// quietly not refreshing, with nothing to notice. tests/js/host_contract.test.js then +// fails CI if the hooks used here and the hooks wired in app.js ever drift apart — the +// layer that catches it on the paths a smoke test never runs. +import { audio } from './audio-el.js'; +import { esc } from './dom.js'; +import { host } from './host.js'; + +export function _sectionPracticeBarContains(el) { + if (!el) return false; + const bar = document.getElementById('section-practice-bar'); + return !!(bar && bar.contains(el)); +} + +// ── Section Practice Bar ──────────────────────────────────────────────── +// One-click looping over song section markers (highway.getSections — +// same array as 3D highway bundle.sections / "Now / Up Next"). +// Reuses setLoop() so manual A/B controls and saved loops stay canonical. +let _sectionPracticeRanges = []; +let _sectionPracticeSelected = -1; +let _sectionPracticeFollowParent = -1; +let _sectionPracticeDurSynced = false; +let _sectionPracticeLogged = false; +let _sectionPracticeHooked = false; +let _sectionPracticeRetryTimer = null; +let _sectionPracticeLastPlayableCount = 0; +let _sectionPracticePlayablePopulateRerendered = false; +// Last-rendered parent count, so the bar can re-render when the parent layout +// changes after the initial render — notably when the synthetic "Start" section +// appears as notes-before-the-first-marker stream in late. +let _sectionPracticeLastParentCount = -1; +// Start-time identity of the active parent, tracked so it can be remapped to the +// correct index when the parent layout shifts (a late "Start" prepend moves every +// real parent by one) instead of leaving the raw index pointing at the wrong one. +let _sectionPracticeActiveParentStart = NaN; +let _sectionPracticeMode = false; +let _sectionPracticeActiveParent = -1; +let _sectionPracticeWholeSection = false; +let _sectionPracticeSavedPartIndex = 0; +// Monotonic token to cancel stale practiceSection() retries: a newer click +// (or a song/arrangement change, which also bumps _audioSeekGen) supersedes +// any in-flight retry loop so it can't re-arm the wrong loop/count-in. +let _sectionPracticeRequestGen = 0; +// >0 while a practiceSection() request is awaiting its loop. While set, +// _syncSectionPracticeFromLoop() (e.g. from a mid-await bar re-render) must not +// reconcile against the half-applied / previous loop — practiceSection owns the +// section state and applies it once its own gen check passes. +let _sectionPracticeRequestInFlight = 0; + +export function _setSectionPracticeMode(on, opts = {}) { + const next = !!on; + if (next === _sectionPracticeMode && !opts.force) return; + _sectionPracticeMode = next; + const cb = document.getElementById('section-practice-mode'); + if (cb) cb.checked = _sectionPracticeMode; + // Surface the "looping" state on the collapsed pill so the user can tell + // Section Practice is armed without opening the popover. + const pill = document.getElementById('section-practice-pill'); + if (pill) pill.classList.toggle('section-practice-pill--active', _sectionPracticeMode); + _sectionPracticeFollowParent = -1; + if (_sectionPracticeMode) { + if (opts.defaultWholeOn) { + _sectionPracticeWholeSection = true; + } + _updateSectionPracticeHighlight(host._audioTime()); + if (opts.defaultWholeOn) { + _syncSectionPracticePieceUi(); + } + } else { + // Turning the feature off must cancel any in-flight practiceSection() + // retry: otherwise a stale setLoop() that lands after the user unchecks + // Section Practice would re-arm the loop, flip the mode back on via + // _syncSectionPracticeFromLoop(), and restart playback through + // startCountIn(). Bumping the request gen makes the pending retry bail. + _sectionPracticeRequestGen++; + // Cancel any pending count-in: every section-practice teardown routes + // through here (mode toggle off, clearLoop, and _hideSectionPracticeBar + // on song/arrangement change), so a countdown started by a prior section + // click must not resume playback after the user has turned practice off. + host._cancelCountIn(); + _sectionPracticeSelected = -1; + _sectionPracticeWholeSection = false; + _sectionPracticeSavedPartIndex = 0; + _updateSectionPracticeHighlight(host._audioTime()); + if (!opts.skipClearLoop && (host.loopA() !== null || host.loopB() !== null)) { + host.clearLoop(); + } + } +} + +export function onSectionPracticeModeChange() { + const cb = document.getElementById('section-practice-mode'); + if (!cb) return; + const turningOn = cb.checked && !_sectionPracticeMode; + _setSectionPracticeMode(cb.checked, { defaultWholeOn: turningOn }); +} + +export function _resetSectionPracticeLog() { + _sectionPracticeLogged = false; + _sectionPracticeLastPlayableCount = 0; + _sectionPracticePlayablePopulateRerendered = false; +} + +function _sectionPracticeHighway() { + return window.highway || (typeof highway !== 'undefined' ? highway : null); +} + +function _sectionPracticeDuration() { + const d = host._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; +} + +export function _sectionPracticeSourceSections() { + const hw = _sectionPracticeHighway(); + if (!hw || typeof hw.getSections !== 'function') return []; + const raw = hw.getSections(); + return Array.isArray(raw) ? raw : []; +} + +export function _sectionPracticeStartTime(s) { + const t = s.time ?? s.startTime ?? s.start_time ?? s.start; + const n = Number(t); + return Number.isFinite(n) ? n : NaN; +} + +function _sectionPracticeBaseName(rawName, fallbackIndex) { + let s = (typeof rawName === 'string' ? rawName : '').trim(); + if (!s) s = `Section ${fallbackIndex + 1}`; + // Normalise separators and strip common trailing digits like "Chorus 2" + s = s.replace(/_/g, ' '); + s = s.replace(/\s*\d+$/u, ''); + const lower = s.toLowerCase(); + const canonical = { + intro: 'Intro', + verse: 'Verse', + chorus: 'Chorus', + bridge: 'Bridge', + solo: 'Solo', + riff: 'Riff', + outro: 'Outro', + }[lower]; + if (canonical) return canonical; + // Fallback: title-case words + return lower.split(/\s+/).filter(Boolean).map(w => w[0].toUpperCase() + w.slice(1)).join(' ') || `Section ${fallbackIndex + 1}`; +} + +const _SECTION_PRACTICE_START_GAP_SEC = 0.05; + +function _sectionPracticeNoteTime(note) { + const t = note?.t ?? note?.time ?? note?.start_time ?? note?.start; + const n = Number(t); + return Number.isFinite(n) ? n : NaN; +} + +function _sectionPracticePlayableCount() { + const hw = _sectionPracticeHighway(); + if (!hw) return 0; + let count = 0; + if (typeof hw.getNotes === 'function') { + const notes = hw.getNotes(); + if (notes?.length) count += notes.length; + } + if (typeof hw.getChords === 'function') { + const chords = hw.getChords(); + if (chords?.length) count += chords.length; + } + return count; +} + +function _sectionPracticeHasNotesBefore(beforeTime) { + const hw = _sectionPracticeHighway(); + if (!hw) return false; + const cutoff = Number(beforeTime); + if (!Number.isFinite(cutoff)) return false; + const sources = []; + if (typeof hw.getNotes === 'function') { + const notes = hw.getNotes(); + if (notes?.length) sources.push(notes); + } + if (typeof hw.getChords === 'function') { + const chords = hw.getChords(); + if (chords?.length) sources.push(chords); + } + for (let s = 0; s < sources.length; s++) { + const items = sources[s]; + for (let i = 0; i < items.length; i++) { + const t = _sectionPracticeNoteTime(items[i]); + if (Number.isFinite(t) && t < cutoff) return true; + } + } + return false; +} + +function _maybeRerenderSectionPracticeOnPlayableLoad() { + const count = _sectionPracticePlayableCount(); + const prev = _sectionPracticeLastPlayableCount; + _sectionPracticeLastPlayableCount = count; + if (!_sectionPracticeSourceSections().length || !_sectionPracticeBarIsReady()) return; + // Re-render whenever the parent layout changes after the bar is up — the + // synthetic "Start" section can appear (±1 parent) once a note before the + // first marker streams in, which would otherwise leave the DOM chip indices + // out of sync with _buildSectionParents() (clicks/highlights hitting the + // wrong section). _buildSectionParents() is memoized, so this is cheap. + const parents = _buildSectionParents(); + const parentCount = parents.length; + if (parentCount !== _sectionPracticeLastParentCount) { + // Remap the active parent by start-time identity before re-rendering: a + // late "Start" prepend shifts every real parent's index, so the raw + // index would otherwise point at the wrong section (mis-highlighting and + // breaking whole/prev/next). Selected/part indices are within-parent and + // unaffected. Skip when no active parent or no prior snapshot. + if (_sectionPracticeActiveParent >= 0 && Number.isFinite(_sectionPracticeActiveParentStart)) { + const remapped = parents.findIndex( + (p) => Math.abs(p.start - _sectionPracticeActiveParentStart) < 0.001, + ); + if (remapped >= 0) _sectionPracticeActiveParent = remapped; + } + _sectionPracticeLastParentCount = parentCount; + renderSectionPracticeBar(); + _sectionPracticeActiveParentStart = + (_sectionPracticeActiveParent >= 0 && parents[_sectionPracticeActiveParent]) + ? parents[_sectionPracticeActiveParent].start : NaN; + return; + } + // Keep the active-parent start snapshot fresh while the layout is stable, so + // it holds the correct pre-change value when the layout next shifts. + _sectionPracticeActiveParentStart = + (_sectionPracticeActiveParent >= 0 && parents[_sectionPracticeActiveParent]) + ? parents[_sectionPracticeActiveParent].start : NaN; + if (_sectionPracticePlayablePopulateRerendered) return; + if (prev !== 0 || count === 0) return; + _sectionPracticePlayablePopulateRerendered = true; + renderSectionPracticeBar(); +} + +// _buildSectionParents() runs on the 60 Hz highlight path, so memoize it. +// The parent layout is a pure function of the highway's section list (a +// stable array reference per song), the song duration, and whether any +// notes/chords precede the first marker (the synthetic "Start" section). +// That last input can flip while WS note chunks are still streaming in, so +// the note/chord counts are part of the key; once a song is fully loaded +// all four inputs stabilize and the per-frame call becomes a cache hit. +// Every call site uses the result read-only, so returning the cached array +// reference is safe. +let _sectionParentsCache = null; +let _sectionParentsCacheRaw = null; +let _sectionParentsCacheDur = -1; +let _sectionParentsCacheNoteLen = -1; +let _sectionParentsCacheChordLen = -1; + +export function _buildSectionParents() { + const raw = _sectionPracticeSourceSections(); + if (!raw.length) return []; + const dur = _sectionPracticeDuration(); + const hw = _sectionPracticeHighway(); + const noteLen = (hw && typeof hw.getNotes === 'function' && hw.getNotes()?.length) || 0; + const chordLen = (hw && typeof hw.getChords === 'function' && hw.getChords()?.length) || 0; + if (_sectionParentsCache !== null + && _sectionParentsCacheRaw === raw + && _sectionParentsCacheDur === dur + && _sectionParentsCacheNoteLen === noteLen + && _sectionParentsCacheChordLen === chordLen) { + return _sectionParentsCache; + } + const sorted = [...raw].sort((a, b) => _sectionPracticeStartTime(a) - _sectionPracticeStartTime(b)); + // Step 1: collapse consecutive same-name markers into logical groups. + const groups = []; + for (let i = 0; i < sorted.length; i++) { + const start = _sectionPracticeStartTime(sorted[i]); + if (!Number.isFinite(start)) continue; + const baseName = _sectionPracticeBaseName(sorted[i].name, groups.length); + const prev = groups[groups.length - 1]; + if (prev && prev.baseName === baseName) { + prev.lastIndex = i; + } else { + groups.push({ baseName, firstIndex: i, lastIndex: i }); + } + } + if (!groups.length) return []; + // Step 2: assign musician-friendly labels with counters (Verse 1, Verse 2, …). + const counters = Object.create(null); + const ranges = []; + for (let gi = 0; gi < groups.length; gi++) { + const g = groups[gi]; + const base = g.baseName; + const count = (counters[base] || 0) + 1; + counters[base] = count; + const label = `${base} ${count}`; + const firstSec = sorted[g.firstIndex]; + const start = _sectionPracticeStartTime(firstSec); + if (!Number.isFinite(start)) continue; + let end; + if (gi + 1 < groups.length) { + const nextFirst = sorted[groups[gi + 1].firstIndex]; + end = _sectionPracticeStartTime(nextFirst); + } else { + end = dur; + } + if (!Number.isFinite(end) || end <= start) { + end = dur > start ? dur : start + 4; + } + ranges.push({ name: label, start, end }); + } + if (ranges.length > 0) { + const firstStart = Number(ranges[0].start); + if (Number.isFinite(firstStart) && firstStart > _SECTION_PRACTICE_START_GAP_SEC + && _sectionPracticeHasNotesBefore(firstStart)) { + ranges.unshift({ name: 'Start', start: 0, end: firstStart }); + } + } + _sectionParentsCache = ranges; + _sectionParentsCacheRaw = raw; + _sectionParentsCacheDur = dur; + _sectionParentsCacheNoteLen = noteLen; + _sectionParentsCacheChordLen = chordLen; + return ranges; +} + +function _sectionPracticeResetSelectionUi() { + _sectionPracticeActiveParent = -1; + _sectionPracticeSelected = -1; + _sectionPracticeWholeSection = false; + _sectionPracticeSavedPartIndex = 0; + _sectionPracticeRanges = []; +} + +function _sectionPracticeSourcePhrases() { + const hw = _sectionPracticeHighway(); + if (!hw || typeof hw.getPracticePhrases !== 'function') return null; + const raw = hw.getPracticePhrases(); + return (raw && raw.length) ? raw : null; +} + +function _buildPhrasePartsForParent(parent) { + if (!parent) return []; + const dur = _sectionPracticeDuration(); + const windowStart = parent.start; + const windowEnd = parent.end; + const phrases = _sectionPracticeSourcePhrases(); + const parts = []; + + if (phrases) { + const inWindow = phrases.filter( + (ph) => ph.start_time >= windowStart - 0.001 && ph.start_time < windowEnd - 0.001, + ); + if (inWindow.length) { + for (let i = 0; i < inWindow.length; i++) { + const ph = inWindow[i]; + let start = ph.start_time; + let end = ph.end_time; + if (!Number.isFinite(end) || end > windowEnd) end = windowEnd; + if (!Number.isFinite(start) || end <= start) continue; + if (dur && Number.isFinite(dur) && end > dur) end = dur; + parts.push({ name: parent.name, start, end }); + } + // Snap first part to section start so the loop aligns with the selected marker + // when the first in-window phrase iteration begins later (e.g. Chorus 2). + if (parts.length > 0 && parts[0].start > windowStart) { + parts[0].start = windowStart; + } + return parts; + } + } + + let start = windowStart; + let end = windowEnd; + if (dur && Number.isFinite(dur) && end > dur) end = dur; + if (Number.isFinite(start) && Number.isFinite(end) && end > start) { + parts.push({ name: parent.name, start, end }); + } + return parts; +} + +function _buildSectionPracticeRanges() { + if (_sectionPracticeActiveParent < 0) return []; + const parents = _buildSectionParents(); + const parent = parents[_sectionPracticeActiveParent]; + if (!parent) return []; + return _buildPhrasePartsForParent(parent); +} + +function _sectionPracticeActiveParentRange() { + if (_sectionPracticeActiveParent < 0) return null; + const parents = _buildSectionParents(); + const parent = parents[_sectionPracticeActiveParent]; + if (!parent) return null; + const dur = _sectionPracticeDuration(); + let end = Number(parent.end); + const start = Number(parent.start); + if (dur && Number.isFinite(dur) && end > dur) end = dur; + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null; + return { name: parent.name, start, end }; +} + +function _sectionPracticeResolveLoopTarget(index, opts = {}) { + if (opts.whole) { + return _sectionPracticeActiveParentRange(); + } + return _sectionPracticeRanges[index] ?? null; +} + +function _formatSectionPracticeName(name) { + return name.replace(/_/g, ' '); +} + +const _SECTION_PRACTICE_CHIP_KINDS = new Set([ + 'intro', 'verse', 'chorus', 'bridge', 'solo', 'riff', 'outro', +]); + +function _sectionPracticeChipKindClass(name, index) { + const base = _sectionPracticeBaseName(name, index); + const kind = base.toLowerCase(); + if (!_SECTION_PRACTICE_CHIP_KINDS.has(kind)) return ''; + return ` section-practice-chip--${kind}`; +} + +function _sectionPracticeWholeCheckboxHtml() { + return ''; +} + +function _sectionPracticePieceRowHtml() { + return '
' + + 'Part — of —' + + '' + + '' + + '
'; +} + +function _sectionPracticeMainRow() { + const bar = document.getElementById('section-practice-bar'); + if (!bar) return null; + return bar.querySelector('.section-practice-controls-row') + || bar.querySelector('.section-practice-primary-row') + || bar.querySelector('.section-practice-row:not(.section-practice-piece-row):not(.section-practice-chips-row)'); +} + +function _migrateSectionPracticeDomLayout(bar) { + if (!bar || bar.querySelector('.section-practice-controls-row')) return; + + const pieceRow = document.getElementById('section-practice-piece-row'); + const scroll = document.getElementById('section-practice-scroll'); + const modeWrap = bar.querySelector('.section-practice-mode-wrap'); + const wholeWrap = bar.querySelector('.section-practice-whole-wrap'); + let label = bar.querySelector('.section-practice-label'); + + const controlsRow = document.createElement('div'); + controlsRow.className = 'section-practice-row section-practice-controls-row'; + if (modeWrap) controlsRow.appendChild(modeWrap); + if (wholeWrap) controlsRow.appendChild(wholeWrap); + if (pieceRow) controlsRow.appendChild(pieceRow); + + const chipsRow = document.createElement('div'); + chipsRow.className = 'section-practice-row section-practice-chips-row'; + if (label) { + chipsRow.appendChild(label); + } else { + label = document.createElement('span'); + label.className = 'section-practice-label'; + label.textContent = 'Sections:'; + chipsRow.appendChild(label); + } + if (scroll) chipsRow.appendChild(scroll); + + bar.replaceChildren(controlsRow, chipsRow); +} + +function _sectionPracticeBarInnerHtml() { + return '
' + + '' + + _sectionPracticeWholeCheckboxHtml() + + _sectionPracticePieceRowHtml() + + '
' + + '
' + + 'Sections:' + + '' + + '
'; +} + +function _ensureSectionPracticeWholeCheckbox() { + const existing = document.getElementById('section-practice-whole'); + const mainRow = _sectionPracticeMainRow(); + if (!mainRow) return; + if (existing) { + const wrap = existing.closest('.section-practice-whole-wrap'); + if (wrap && !mainRow.contains(wrap)) { + const modeWrap = mainRow.querySelector('.section-practice-mode-wrap'); + if (modeWrap) modeWrap.insertAdjacentElement('afterend', wrap); + else mainRow.insertBefore(wrap, mainRow.firstChild); + } + return; + } + const modeWrap = mainRow.querySelector('.section-practice-mode-wrap'); + if (modeWrap) { + modeWrap.insertAdjacentHTML('afterend', _sectionPracticeWholeCheckboxHtml()); + } else { + mainRow.insertAdjacentHTML('afterbegin', _sectionPracticeWholeCheckboxHtml()); + } +} + +function _sectionPracticeCurrentPartIndex() { + const total = _sectionPracticeRanges.length; + if (!total) return 0; + if (!_sectionPracticeWholeSection && _sectionPracticeSelected >= 0) { + return Math.min(_sectionPracticeSelected, total - 1); + } + if (_sectionPracticeSavedPartIndex >= 0) { + return Math.min(_sectionPracticeSavedPartIndex, total - 1); + } + return 0; +} + +function _sectionPracticePillHtml() { + return ''; +} + +function _syncSectionPracticePillV3Chrome(isV3) { + const pill = document.getElementById('section-practice-pill'); + if (!pill) return; + pill.classList.toggle('v3-rail-icon', isV3); + let ring = pill.querySelector('.v3-rail-border'); + if (isV3) { + if (!ring) { + ring = document.createElement('span'); + ring.className = 'v3-rail-border'; + ring.setAttribute('aria-hidden', 'true'); + pill.insertBefore(ring, pill.firstChild); + } + pill.setAttribute('title', 'Practice'); + pill.setAttribute('aria-label', 'Practice'); + } else { + if (ring) ring.remove(); + pill.setAttribute('title', 'Section practice'); + pill.setAttribute('aria-label', 'Section practice'); + } +} + +// Wrap an existing #section-practice-bar in the pill control (creating the +// wrapper + pill if missing). Defensive: works whether the bar came from the +// static markup (already wrapped) or a chrome whose index.html predates the +// pill (e.g. a not-yet-rebased v3 build) — the bar is always reachable as a +// closed popover behind the pill afterward. +function _ensureSectionPracticeControlWrap(bar) { + if (!bar) return null; + let ctrl = (bar.closest && bar.closest('.section-practice-control')) + || document.getElementById('section-practice-control'); + if (ctrl) { + if (!ctrl.contains(bar)) ctrl.appendChild(bar); + } else { + ctrl = document.createElement('div'); + ctrl.id = 'section-practice-control'; + ctrl.className = 'section-practice-control section-practice-control--hidden'; + if (bar.parentNode) bar.parentNode.insertBefore(ctrl, bar); + ctrl.appendChild(bar); + } + if (!ctrl.querySelector('#section-practice-pill')) { + ctrl.insertAdjacentHTML('afterbegin', _sectionPracticePillHtml()); + } + // Popover visibility is driven by --open now; clear any legacy hidden class. + bar.classList.remove('section-practice-bar--hidden'); + _mountSectionPracticeControlSafe(ctrl); + return ctrl; +} + +// Mount the pill control so its popover — whose chip `; + }).join(''); + _sectionPracticeRanges = _buildSectionPracticeRanges(); + // Reconcile any active A-B loop with the (re)rendered section bar. Called + // unconditionally so a loop that arrived before the section markers — e.g. + // a Saved Loop or window.feedBack.setLoop() during song load, when no + // parent was active yet — still re-selects its chip once markers appear. + // _syncSectionPracticeFromLoop() scans all parents, so it can activate the + // matching one; run it before the piece UI so that reflects the result. + _syncSectionPracticeFromLoop(); + _syncSectionPracticePieceUi(); + _updateSectionPracticeHighlight(host._audioTime()); +} + +export async function onSectionParentClick(parentIdx) { + const parents = _buildSectionParents(); + const idx = Number(parentIdx); + if (!Number.isFinite(idx) || idx < 0 || idx >= parents.length) return; + _sectionPracticeActiveParent = idx; + _sectionPracticeRanges = _buildSectionPracticeRanges(); + _sectionPracticeSelected = -1; + _sectionPracticeSavedPartIndex = 0; + _sectionPracticeWholeSection = true; + _syncSectionPracticePieceUi(); + _updateSectionPracticeHighlight(host._audioTime()); + if (_sectionPracticeActiveParentRange() || _sectionPracticeRanges.length) { + await practiceSection(0, { whole: true }); + } +} + +export async function onSectionPracticeWholeChange() { + const cb = document.getElementById('section-practice-whole'); + if (!cb || _sectionPracticeActiveParent < 0) return; + const total = _sectionPracticeRanges.length; + if (!total) return; + if (cb.checked === _sectionPracticeWholeSection) return; + _sectionPracticeWholeSection = cb.checked; + if (cb.checked) { + await practiceSection(_sectionPracticeCurrentPartIndex(), { whole: true }); + return; + } + await practiceSection(0); +} + +export async function onPhrasePrev() { + const total = _sectionPracticeRanges.length; + if (!total || _sectionPracticeActiveParent < 0) return; + if (_sectionPracticeWholeSection) { + _sectionPracticeWholeSection = false; + _syncSectionPracticePieceUi(); + await practiceSection(0); + return; + } + const cur = _sectionPracticeSelected >= 0 ? _sectionPracticeSelected : 0; + if (cur <= 0) return; + await practiceSection(cur - 1); +} + +export async function onPhraseNext() { + const total = _sectionPracticeRanges.length; + if (!total || _sectionPracticeActiveParent < 0) return; + if (_sectionPracticeWholeSection) { + _sectionPracticeWholeSection = false; + _syncSectionPracticePieceUi(); + await practiceSection(0); + return; + } + const cur = _sectionPracticeSelected >= 0 ? _sectionPracticeSelected : 0; + if (cur >= total - 1) return; + await practiceSection(cur + 1); +} + +// Find which section parent / phrase part the active A-B loop corresponds to. +// Scans ALL parents (not just the active one) so a loop arriving from Saved +// Loops or window.feedBack.setLoop() can re-select the right chip even when +// its parent isn't the currently-active one. Returns { parentIdx, whole } or +// { parentIdx, whole:false, index } (the matching phrase part), or null. +function _sectionPracticeLoopMatch() { + if (host.loopA() === null || host.loopB() === null) return null; + const parents = _buildSectionParents(); + for (let parentIdx = 0; parentIdx < parents.length; parentIdx++) { + const parent = parents[parentIdx]; + let partMatch = -1; + const parts = _buildPhrasePartsForParent(parent); + for (let i = 0; i < parts.length; i++) { + if (Math.abs(parts[i].start - host.loopA()) < 0.05 && Math.abs(parts[i].end - host.loopB()) < 0.05) { + partMatch = i; + break; + } + } + const wholeMatch = Math.abs(parent.start - host.loopA()) < 0.05 && Math.abs(parent.end - host.loopB()) < 0.05; + if (wholeMatch && partMatch >= 0) { + // A single-part section's part range coincides with the whole + // section. Preserve the user's whole/part intent when this is the + // already-active parent; otherwise default to whole-section. + if (parentIdx === _sectionPracticeActiveParent && !_sectionPracticeWholeSection) { + return { parentIdx, whole: false, index: partMatch }; + } + return { parentIdx, whole: true }; + } + if (wholeMatch) return { parentIdx, whole: true }; + if (partMatch >= 0) return { parentIdx, whole: false, index: partMatch }; + } + return null; +} + +function _blurSectionPracticeFocusIfNeeded() { + const ae = document.activeElement; + const bar = document.getElementById('section-practice-bar'); + if (ae && bar && bar.contains(ae) && typeof ae.blur === 'function') { + ae.blur(); + } +} + +export async function practiceSection(index, opts = {}) { + const requestGen = ++_sectionPracticeRequestGen; + const seekGen = host._audioSeekGen(); + const loopGen = host._loopMutationGen(); + const whole = !!opts.whole; + const r = _sectionPracticeResolveLoopTarget(index, opts); + if (!r) return; + const dur = _sectionPracticeDuration(); + const start = Number(r.start); + let end = Number(r.end); + if (dur && Number.isFinite(dur) && end > dur) end = dur; + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return; + + // Mark the request in-flight so a bar re-render that fires during the awaited + // setLoop below doesn't reconcile section state against the old/half-applied + // loop. Cleared in finally so every exit path (bail, success, failure) resets. + _sectionPracticeRequestInFlight++; + try { + host._cancelCountIn(); + _setSectionPracticeMode(true, { skipClearLoop: true }); + + // setLoop() is seek-gated: it returns false when the seek is cancelled + // during arrangement switches / teardown-gen bumps, or when the backend + // clock clamps off-target. Retry briefly to land after the transport + // becomes ready without forking the loop system. + 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; + try { + // skipSectionSync: this function owns the section-practice state and + // applies it below under the request-gen guard, so a stale retry + // landing here can't re-sync/re-arm via setLoop's shared path. + // commitGuard: also prevent a superseded retry from committing + // loopA/loopB at all — setLoop re-checks this right before arming, + // 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(), + }); + } catch (err) { + ok = false; + } + if (ok) break; + 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 (ok) { + _sectionPracticeWholeSection = whole; + if (!whole) { + _sectionPracticeSelected = index; + _sectionPracticeSavedPartIndex = index; + } + _blurSectionPracticeFocusIfNeeded(); + _updateSectionPracticeHighlight(host._audioTime()); + host.startCountIn({ immediate: true }); + } else { + _setSectionPracticeMode(false, { skipClearLoop: true }); + } + } finally { + _sectionPracticeRequestInFlight--; + } +} + +export function _syncSectionPracticeFromLoop() { + // A practiceSection() request owns the section state while it awaits its + // loop; reconciling here against the prior/half-applied loop would fight it + // (snapping the active parent back or toggling the mode off mid-request). + if (_sectionPracticeRequestInFlight > 0) return; + if (!_buildSectionParents().length) return; + const match = _sectionPracticeLoopMatch(); + if (match) { + // The loop may belong to a parent that isn't currently active (e.g. + // restored from Saved Loops); switch to it and rebuild its parts so + // the part-level UI reflects the matched section. + if (match.parentIdx !== _sectionPracticeActiveParent) { + _sectionPracticeActiveParent = match.parentIdx; + _sectionPracticeRanges = _buildSectionPracticeRanges(); + } + _sectionPracticeWholeSection = match.whole; + if (!match.whole) { + _sectionPracticeSelected = match.index; + _sectionPracticeSavedPartIndex = match.index; + } else { + _sectionPracticeSelected = -1; + } + } else { + _sectionPracticeWholeSection = false; + _sectionPracticeSelected = -1; + } + if (host.loopA() !== null && host.loopB() !== null) { + if (match) { + if (!_sectionPracticeMode) { + _setSectionPracticeMode(true, { skipClearLoop: true }); + } + } else if (_sectionPracticeMode) { + _setSectionPracticeMode(false, { skipClearLoop: true }); + } + } else if (_sectionPracticeMode) { + _setSectionPracticeMode(false, { skipClearLoop: true }); + } + _updateSectionPracticeHighlight(host._audioTime()); +} + +function _sectionPracticeIndexAtTime(t) { + if (!Number.isFinite(t) || _sectionPracticeRanges.length === 0) return -1; + for (let i = _sectionPracticeRanges.length - 1; i >= 0; i--) { + if (t >= _sectionPracticeRanges[i].start) return i; + } + return -1; +} + +function _sectionPracticeParentIndexAtTime(t) { + const parents = _buildSectionParents(); + if (!Number.isFinite(t) || parents.length === 0) return -1; + for (let i = parents.length - 1; i >= 0; i--) { + if (t >= parents[i].start) return i; + } + return -1; +} + +function _scrollSectionPracticeChipIntoView(chip) { + if (!chip) return; + chip.scrollIntoView({ block: 'nearest', inline: 'nearest' }); +} + +export function _updateSectionPracticeHighlight(ct) { + const scroll = document.getElementById('section-practice-scroll'); + if (!scroll) return; + const chips = scroll.querySelectorAll('.section-practice-chip[data-parent-idx]'); + if (!chips.length) return; + + const followEnabled = !_sectionPracticeMode && _sectionPracticeBarIsReady(); + const followParent = followEnabled ? _sectionPracticeParentIndexAtTime(ct) : -1; + + chips.forEach((chip) => { + const idx = Number(chip.dataset.parentIdx); + chip.classList.toggle('is-selected', idx === _sectionPracticeActiveParent); + chip.classList.toggle('is-playing', followEnabled && idx === followParent); + }); + + if (followEnabled && followParent >= 0 && followParent !== _sectionPracticeFollowParent) { + _sectionPracticeFollowParent = followParent; + const chip = scroll.querySelector(`.section-practice-chip[data-parent-idx="${followParent}"]`); + _scrollSectionPracticeChipIntoView(chip); + } else if (!followEnabled) { + _sectionPracticeFollowParent = -1; + } + + _syncSectionPracticePieceUi(); +} + +export function _maybeRefreshSectionPracticeDuration(dur) { + if (_sectionPracticeDurSynced || !dur || _sectionPracticeRanges.length === 0) return; + const rebuilt = _buildSectionPracticeRanges(); + if (!rebuilt.length) return; + const prevEnd = _sectionPracticeRanges[_sectionPracticeRanges.length - 1].end; + const nextEnd = rebuilt[rebuilt.length - 1].end; + if (Math.abs(prevEnd - nextEnd) > 0.05) { + _sectionPracticeDurSynced = true; + renderSectionPracticeBar(); + } else { + _sectionPracticeDurSynced = true; + } +} + +// Re-render when section metadata appears (before audio duration is known). +export function _ensureSectionPracticeBar() { + if (_sectionPracticeSourceSections().length === 0) return; + if (!_sectionPracticeBarIsReady()) { + renderSectionPracticeBar(); + } +} + +// ── Resets app.js used to perform by hand ─────────────────────────────────── +// clearLoop() and changeArrangement() used to reach in and zero these scalars +// directly. They cannot now (an imported binding is read-only), and they should not +// have to: the module owns its own invariants. + +/** Drop the current section selection. Called by app.js's clearLoop(). */ +export function resetSelection() { + _sectionPracticeSelected = -1; + _sectionPracticeWholeSection = false; + _sectionPracticeSavedPartIndex = 0; +} + +/** + * Force the next bar render to rebuild its parents, even when the new arrangement + * happens to have the same parent count. Called by app.js's changeArrangement(). + */ +export function invalidateParentCount() { + _sectionPracticeLastParentCount = -1; +} diff --git a/tests/js/host_contract.test.js b/tests/js/host_contract.test.js new file mode 100644 index 0000000..e5a47f0 --- /dev/null +++ b/tests/js/host_contract.test.js @@ -0,0 +1,113 @@ +// The host-seam contract: the hooks the modules USE must be exactly the hooks +// app.js WIRES. +// +// This is the test that makes the seam safe. static/js/host.js already throws at +// runtime when an unwired hook is read — but a runtime throw only fires if the +// broken path actually executes, and the entire danger of a host seam is the paths +// that DON'T run in a smoke test. That is not hypothetical: the plugin loader's +// seam defaulted a hook to `() => {}`, and a dropped wiring line would have left +// the viz picker silently not refreshing with no test, boot check, or bot noticing. +// +// So this closes it statically. Rename a hook in app.js, drop a line from the +// configureHost({…}) call, or typo a `host.foo` in a module, and CI fails — on a +// path nobody ever ran. +// +// It is deliberately symmetric: +// * used but not wired -> a latent crash (host.js would throw at runtime) +// * wired but not used -> dead weight, and usually the fossil of a rename +// Both fail. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..', '..'); +const APP_JS = path.join(ROOT, 'static', 'app.js'); +const JS_DIR = path.join(ROOT, 'static', 'js'); + +// Strip comments, so prose about `host.foo` in a header block is not read as a call +// site. +// +// NOTHING ELSE. An earlier version also tried to strip import statements (to stop +// `from './host.js'` reading as a hook called `js`) and its `[\s\S]*?` spanned lines +// and silently ate 14,000 characters of the file — including, in the bite test, the +// very drift it was supposed to catch. A guard with a hole in it is worse than no +// guard, because you trust it. The `host.js` path is excluded far more cheaply, +// below, by refusing a match followed by a quote. +function scrub(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/^\s*\/\/[^\n]*$/gm, ''); +} + +// `host.` — but not `host.js'` from the `from './host.js'` import path, which is +// the one string in these files that looks like a hook and isn't. +// +// The trailing class must forbid a WORD character as well as a quote. With only +// `(?!['"])`, `host.js'` fails on `js` (a quote follows), then BACKTRACKS to `j` — +// where the next char is `s`, not a quote — and happily reports a hook called `j`. +// Forbidding `[\w$]` too leaves it nowhere to backtrack to. +const HOOK_RE = /(?` referenced by a carved module. */ +function hooksUsed() { + const used = new Map(); // name -> [files] + for (const file of fs.readdirSync(JS_DIR)) { + if (!file.endsWith('.js') || file === 'host.js') continue; + const raw = fs.readFileSync(path.join(JS_DIR, file), 'utf8'); + if (!/from\s+'\.\/host\.js'/.test(raw)) continue; + for (const m of scrub(raw).matchAll(HOOK_RE)) { + if (!used.has(m[1])) used.set(m[1], []); + used.get(m[1]).push(file); + } + } + return used; +} + +/** Every hook app.js passes to configureHost({ … }). */ +function hooksWired() { + const src = scrub(fs.readFileSync(APP_JS, 'utf8')); + // NB the closing brace is INDENTED (the call sits inside the boot function), so + // anchoring on `\n});` at column 0 runs straight past it and swallows the next + // object literal in the file — which is how this first read 77 "hooks", most of + // them app.js's window contract. + const call = src.match(/configureHost\(\{([\s\S]*?)\n\s*\}\);/); + if (!call) return null; // no seam wired yet — fine until there is one + const wired = new Set(); + for (const m of call[1].matchAll(/(?:^|,)\s*([A-Za-z_$][\w$]*)\s*(?=[,:}]|$)/gm)) { + wired.add(m[1]); + } + return wired; +} + +test('every host. a module uses is wired by app.js', () => { + const used = hooksUsed(); + if (used.size === 0) return; // no consumers yet + const wired = hooksWired(); + assert.ok(wired, 'modules import ./host.js but app.js never calls configureHost({ … })'); + + const missing = [...used.keys()] + .filter((h) => !wired.has(h)) + .map((h) => `${h} (used in ${used.get(h).join(', ')})`); + + assert.deepEqual( + missing, [], + 'these hooks are read by a module but never wired by app.js — they would throw at runtime, ' + + 'on whatever path happens to reach them', + ); +}); + +test('every hook app.js wires is actually used by a module', () => { + const wired = hooksWired(); + if (!wired || wired.size === 0) return; + const used = hooksUsed(); + + const unused = [...wired].filter((h) => !used.has(h)); + + assert.deepEqual( + unused, [], + 'these hooks are wired by app.js but no module reads them — dead weight, and usually ' + + 'the fossil of a rename that left the other half behind', + ); +}); diff --git a/tests/js/loop_api.test.js b/tests/js/loop_api.test.js index 5548ff5..6d5b10f 100644 --- a/tests/js/loop_api.test.js +++ b/tests/js/loop_api.test.js @@ -44,10 +44,18 @@ function buildSandbox() { const seekCalls = []; const sectionPracticeModeCalls = []; const transportEvents = []; + // clearLoop() used to zero section-practice's three selection scalars by hand. + // They now live in static/js/section-practice.js, which owns them, so clearLoop + // calls its exported resetSelection() instead. This is a SPY, not a stub — the + // test below still asserts the reset happens, it just asserts it through the + // seam rather than by reaching into someone else's state. + const resetSelectionCalls = []; const sandbox = { seekCalls, sectionPracticeModeCalls, transportEvents, + resetSelectionCalls, + resetSelection: () => resetSelectionCalls.push(true), // Mutable state (declared as `var` in eval prelude so it lives on // the sandbox global and the extracted functions can read/write). // The actual values are set below. @@ -201,7 +209,7 @@ test('setLoop rejects b <= a', async () => { await assert.rejects(() => sandbox.__setLoop(10, 5), /b > a/); }); -test('clearLoop resets loopA/loopB to null', async () => { +test('clearLoop resets loopA/loopB to null (and asks section-practice to drop its selection)', async () => { const src = fs.readFileSync(APP_JS, 'utf8'); const sandbox = buildSandbox(); loadFunctions(sandbox, src); @@ -211,6 +219,11 @@ test('clearLoop resets loopA/loopB to null', async () => { const { loopA, loopB } = sandbox.__getLoop(); assert.equal(loopA, null); assert.equal(loopB, null); + assert.equal( + sandbox.resetSelectionCalls.length, 1, + 'clearLoop must ask section-practice to drop its selection (it used to zero the ' + + 'scalars by hand; the module owns them now)', + ); assert.equal(sandbox.sectionPracticeModeCalls.length, 1); assert.equal(sandbox.sectionPracticeModeCalls[0].on, false); // Field-wise: vm-context objects break deepStrictEqual across realms. diff --git a/tests/js/section_practice_dismiss.test.js b/tests/js/section_practice_dismiss.test.js index 8a0912c..fa74532 100644 --- a/tests/js/section_practice_dismiss.test.js +++ b/tests/js/section_practice_dismiss.test.js @@ -15,9 +15,10 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'app.js'), 'utf8'); +// _installSectionPracticeDismiss was carved out of app.js into its own module (R3a). +const src = fs.readFileSync(path.join(__dirname, '..', '..', 'static', 'js', 'section-practice.js'), 'utf8'); const m = src.match(/function _installSectionPracticeDismiss\s*\(\)\s*\{[\s\S]*?\n\}/); -assert.ok(m, '_installSectionPracticeDismiss() not found in static/app.js'); +assert.ok(m, '_installSectionPracticeDismiss() not found in static/js/section-practice.js'); const body = m[0]; test('the outside-click dismiss binds in the CAPTURE phase', () => {