// 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 { _audioDuration, _audioTime, audioSeekGen } from './transport.js';
import { formatTime } from './format.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 (window.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(_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(_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 || 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;
}
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 '
';
}
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