From 98090557dfc700bcc3d4c38306aadb6e9bd1831a Mon Sep 17 00:00:00 2001 From: Viktor Olausson Date: Sun, 19 Jul 2026 23:20:39 +0200 Subject: [PATCH 1/3] feat: unify loop practice experience Signed-off-by: Viktor Olausson --- static/app.js | 110 +++- static/capabilities/playback.js | 16 + static/highway.js | 15 + static/js/count-in.js | 72 ++- static/js/juce-audio.js | 8 +- static/js/loop-preferences.js | 62 ++ static/js/loops.js | 655 ++++++++++++++++----- static/js/section-practice.js | 177 ++++-- static/js/transport.js | 126 +++- static/style.css | 108 ++++ static/v3/index.html | 39 +- static/v3/v3.css | 205 ++++++- tests/js/highway_monotonic_clock.test.js | 31 +- tests/js/loop_api.test.js | 19 + tests/js/loop_controller.test.js | 386 ++++++++++++ tests/js/loop_preferences.test.js | 84 +++ tests/js/loop_restart.test.js | 42 +- tests/js/loop_ui.test.js | 159 +++++ tests/js/play_button_reroute_guard.test.js | 1 + tests/js/playback_app_adapter.test.js | 5 +- tests/js/playback_domain.test.js | 30 + tests/js/song_restart.test.js | 19 +- tests/js/song_seek.test.js | 190 +++++- 23 files changed, 2248 insertions(+), 311 deletions(-) create mode 100644 static/js/loop-preferences.js create mode 100644 tests/js/loop_controller.test.js create mode 100644 tests/js/loop_preferences.test.js create mode 100644 tests/js/loop_ui.test.js diff --git a/static/app.js b/static/app.js index d3062590..3e7a8fa0 100644 --- a/static/app.js +++ b/static/app.js @@ -79,22 +79,27 @@ import { scheduleCreditsHide, showCountOverlay, showSongCreditsOverlay, - startCountIn, startSongCountIn, } from './js/count-in.js'; import { _loopMutationGen, + cancelLoopOperations, clearLoop, deleteSelectedLoop, + getLoopState, + handleLoopBoundary, loadSavedLoop, loadSavedLoops, loopA, loopB, + pulseLoopIndicator, saveCurrentLoop, setLoop, setLoopEnd, setLoopStart, + startLoop, + updateLoopPreference, updateLoopUI, } from './js/loops.js'; @@ -286,8 +291,40 @@ import { setPlayButtonState, jucePlayer, _audioTime, _audioDuration, _songEventPayload, _markPlaybackPaused, _markPlaybackResumed, _emitPlaybackStopped, _emitSongPositionChanged, _waitForSongReady, _resetAudioSeekState, _audioSeek, togglePlay, seekBy, audioSeekGen, + setLoopPlayStartTargetResolver, setLoopRestartHandler, } from './js/transport.js'; +// Timeline navigation stays free while paused. When Play is pressed with an +// active loop, the transport consults this hook and starts from A. +setLoopPlayStartTargetResolver((requestedTime) => { + const state = getLoopState(); + if (!state.active + || !Number.isFinite(state.loopA) + || !Number.isFinite(state.loopB) + || !Number.isFinite(requestedTime)) { + return requestedTime; + } + return requestedTime >= state.loopA && requestedTime < state.loopB + ? requestedTime + : state.loopA; +}); + +// Returning to A because the user tried to play/seek outside an active loop +// is a real loop restart. Route it through the controller so the selected +// first-pass policy is honored: count-in restarts with four beats, while +// immediate starts without one. +setLoopRestartHandler(async ({ trigger }) => { + const from = _audioTime(); + const completed = await startLoop({ + source: trigger === 'play' ? 'outside-play' : 'outside-seek', + }); + return { + completed: !!completed, + from, + to: completed ? _audioTime() : NaN, + }; +}); + // Demo analytics — real impl set by demo.js; no-op in normal builds window.feedBackDemoTrack = window.feedBackDemoTrack ?? null; @@ -779,7 +816,10 @@ window.feedBack = Object.assign(_feedBackBus, { // loopA/loopB bindings at call time. seek(seconds, reason, options) { _recordPlaybackBridge('playback.window-feedBack-transport', 'window.feedBack.seek', reason || 'plugin-command'); - return _audioSeek(seconds, reason || 'plugin-command'); + return _audioSeek(seconds, reason || 'plugin-command', { + ...(options || {}), + restartActiveLoopWhilePlaying: true, + }); }, setLoop(a, b, options) { _recordPlaybackBridge('playback.loop-api', 'window.feedBack.setLoop', options && options.reason || 'plugin-command'); @@ -789,9 +829,13 @@ window.feedBack = Object.assign(_feedBackBus, { _recordPlaybackBridge('playback.loop-api', 'window.feedBack.clearLoop', options && options.reason || 'plugin-command'); clearLoop(options); }, + startLoop(options) { + _recordPlaybackBridge('playback.loop-api', 'window.feedBack.startLoop', options && options.reason || 'plugin-command'); + return startLoop(options); + }, getLoop(options) { _recordPlaybackBridge('playback.loop-api', 'window.feedBack.getLoop', options && options.reason || 'plugin-command'); - return { loopA, loopB }; + return getLoopState(); }, }); if (_feedBackExisting && _feedBackExisting !== window.feedBack) { @@ -803,10 +847,12 @@ if (_feedBackExisting && _feedBackExisting !== window.feedBack) { } window.feedback = window.feedBack; window.slopsmith = window.feedback; +window.feedBack.on('loop:restart', pulseLoopIndicator); function _currentPlaybackSnapshot() { const song = window.feedBack && window.feedBack.currentSong || null; const time = _audioTime(); + const appLoop = getLoopState(); return { currentTime: Number.isFinite(time) ? time : null, mediaTime: Number.isFinite(time) ? time : null, @@ -819,7 +865,12 @@ function _currentPlaybackSnapshot() { routeState: song || audio.src || window._juceAudioUrl ? 'active' : 'unavailable', loopA, loopB, - loop: loopA !== null && loopB !== null ? { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } : { enabled: false, state: 'inactive' }, + loop: { + startTime: appLoop.configured ? loopA : null, + endTime: appLoop.configured ? loopB : null, + enabled: appLoop.active, + state: appLoop.state, + }, currentSong: song ? { targetId: song.filename ? `target-${String(song.filename).length}-${String(song.arrangementIndex ?? song.arrangement ?? '').length}` : undefined, sourceKind: song.format || 'local', @@ -920,7 +971,9 @@ function _installPlaybackTransportAdapter() { if (!Number.isFinite(seconds) || seconds < 0) { throw new Error(`Invalid seek time: ${time}`); } - return _audioSeek(seconds, reason || 'playback-command'); + return _audioSeek(seconds, reason || 'playback-command', { + restartActiveLoopWhilePlaying: true, + }); }, setLoop({ startTime, endTime }) { return setLoop(startTime, endTime, { emitTransportEvent: false }); @@ -1125,8 +1178,8 @@ if (document.readyState !== 'complete') { // Editor → Highway handoff (Editor ⇄ 3D Highway region round-trip). The // editor's "Loop in 3D" button stashes a pending loop + return context, then // calls playSong(). Once the chart is ready (playSong's own clearLoop() has -// already run, so the loop won't be wiped), arm the loop over the selected -// region and start playback so the user lands inside the loop directly. +// already run, so the loop won't be wiped), configure the selected region +// through the same preference-aware controller used by Practice & Loops. window.feedBack.on('song:ready', () => { _updateEditRegionBtn(); const pend = window._pendingHighwayLoop; @@ -1137,8 +1190,10 @@ window.feedBack.on('song:ready', () => { if (want && currentFilename && want !== currentFilename) return; window._pendingHighwayLoop = null; window._highwayReturnCtx = pend.returnCtx || null; - Promise.resolve(setLoop(pend.a, pend.b)) - .then((ok) => { if (ok && !S.isPlaying) return togglePlay(); }) + Promise.resolve(setLoop(pend.a, pend.b, { + activation: 'preference', + source: 'editor', + })) .catch((err) => console.warn('[app] loop-in-3d apply failed:', err)); _updateEditRegionBtn(); }); @@ -1152,6 +1207,10 @@ let _arrBusyTimeout = null; async function changeArrangement(index) { if (currentFilename) { + // Invalidate a pending loop start/wrap before the arrangement's own + // pause/reconnect/restore sequence begins. An already-active loop keeps + // its time-based bounds, matching the existing arrangement contract. + cancelLoopOperations(); // Tear down any pending fresh-load credits before switching: the // no-count-in hold timer would otherwise fire togglePlay() against the // incoming (still-loading) arrangement. hideSongCreditsOverlay() clears @@ -1298,21 +1357,11 @@ async function restartCurrentSong() { } catch (_) { /* host misbehaviour — treat as no loop */ } } const hasLoop = loopA != null && loopB != null; - const target = hasLoop ? loopA : 0; - const r = await _audioSeek(target, 'song-restart'); - if (!r.completed) return false; if (hasLoop) { - // Verify the seek actually landed at loop A (JUCE may clamp / HTML5 may - // snap) before the count-in fixes the visuals there — otherwise the - // count-in would start from loopA while the audio backend sits - // elsewhere. ~50 ms tolerance, matching the loop paths. - if (Number.isFinite(r.to) && Math.abs(r.to - target) > 0.05) { - console.warn('[restart] seek landed at', r.to, 'but loop A is', target, '— skipping count-in'); - return false; - } - await startCountIn({ immediate: true }); - return true; + return startLoop({ source: 'song-restart' }); } + const r = await _audioSeek(0, 'song-restart'); + if (!r.completed) return false; if (!S.isPlaying) await togglePlay(); return true; } @@ -1794,10 +1843,11 @@ setInterval(() => { window.feedBack.emit('song:ended', _songEventPayload()); jucePlayer.pause().catch((err) => console.warn('[app] end-of-track pause error:', err)); } - // A-B loop: count-in then seek back to A - else if (loopA !== null && loopB !== null && ct >= loopB) { + // The unified controller distinguishes configured/armed bounds from an + // active loop and applies the selected repeat policy. + else if (getLoopState().active && S.isPlaying && ct >= loopB) { S.lastAudioTime = loopB; - startCountIn(); + handleLoopBoundary(ct).catch((err) => console.warn('[loop] wrap failed:', err)); } // Detect and fix audio time jumps (browser seeking bug; skip for JUCE — position is polled) else if (!window._juceMode && S.isPlaying && Math.abs(ct - S.lastAudioTime) > 30 && S.lastAudioTime > 0) { @@ -2298,11 +2348,11 @@ configureHost({ syncLibrarySong, handleSliderInput, playSong, - // count-in is a module now, so section-practice reaches it through the seam too — - // these are simply count-in's own exports, handed across. - startCountIn, + // Section-practice teardown cancels an in-flight controller count-in + // through the seam without importing the controller back into itself. _cancelCountIn, _updateEditRegionBtn, + updateLoopUI, // section-practice reaches the loop module through the seam, not by importing it: // loops imports section-practice (clearLoop drops its selection), so the reverse // edge has to be indirection or the graph cycles. These are simply the loop @@ -2335,10 +2385,10 @@ Object.assign(window, { retuneSong, saveCurrentLoop, saveSettings, seekBy, setAvOffsetMs, setFavView, setInstrumentPathway, setLibView, setLibraryProvider, setLoopEnd, setLoopStart, setMastery, - setSpeed, setViz, showScreen, sortFavorites, + setSpeed, setViz, showScreen, sortFavorites, startLoop, sortLibrary, syncLibrarySong, toggleAllArtists, toggleAllFavoriteArtists, toggleLibFilters, togglePlay, toggleSectionPracticePopover, uiPrompt, - updatePlugin, uploadSongs, + updateLoopPreference, updatePlugin, uploadSongs, // These four are invisible to every static scan. app.js:2156-2157 picks the // handler NAME at runtime — diff --git a/static/capabilities/playback.js b/static/capabilities/playback.js index 4d828b80..2593f82f 100644 --- a/static/capabilities/playback.js +++ b/static/capabilities/playback.js @@ -1032,6 +1032,22 @@ _setState(shouldPlay ? 'playing' : 'paused', { requesterId: source.requesterId, readiness: 'ready', reason: source.reason }); } else if (name === 'loop-restarted') { + const startTime = _number(source.loopA, null); + const endTime = _number(source.loopB, null); + if (startTime != null && endTime != null) { + // The core loop bridge emits one legacy loop:restart event for + // both the initial pass and later wraps. Promote a previously + // armed snapshot to active here without requiring a duplicate + // loop-set event. + _updateLoopFromSnapshot({ + ...currentSession.loop, + startTime, + endTime, + enabled: true, + state: 'active', + requesterId: source.requesterId, + }); + } if (currentSession.loop) currentSession.loop.lastRestartAt = _now(); } else if (name === 'loop-stale') { if (currentSession.loop) currentSession.loop.state = 'stale'; diff --git a/static/highway.js b/static/highway.js index 19130780..f29b2392 100644 --- a/static/highway.js +++ b/static/highway.js @@ -2475,6 +2475,21 @@ function createHighway() { hwState._chartLastAdvanceAt = newPerfNow; } }, + // Count-ins deliberately hold the chart at their start position while + // the backing transport is stopped. A normal setTime(t) creates a live + // interpolation anchor, which makes smooth renderers creep forward for + // up to _CHART_MAX_INTERP_MS before stall detection catches up. Keep + // the raw position but clear the live anchor so the bundle reports + // isPlaying=false immediately. The first genuinely advancing audio + // sample re-anchors through setTime() and resumes smooth motion. + freezeTime(t) { + hwState.chartTime = t + hwState.songOffset; + hwState.currentTime = hwState.chartTime + hwState.avOffsetSec; + hwState._chartAnchorAudioT = t; + hwState._chartAnchorPerfNow = NaN; + hwState._chartLastAdvanceAt = 0; + hwState._chartObservedRate = 1; + }, setAvOffset(ms) { hwState.avOffsetSec = (Number(ms) || 0) / 1000; hwState.currentTime = hwState.chartTime + hwState.avOffsetSec; }, getAvOffset() { return hwState.avOffsetSec * 1000; }, diff --git a/static/js/count-in.js b/static/js/count-in.js index b78aafa2..0f1a51ee 100644 --- a/static/js/count-in.js +++ b/static/js/count-in.js @@ -8,9 +8,10 @@ // ./player-state.js. Every earlier slice only READ what it shared, so a getter hook // sufficed; this one could not. // -// It imports the loop module directly (setLoop / loopA / loopB — a count-in that starts -// inside an A-B loop must begin at A). Nothing imports count-in back: app.js and -// section-practice both reach it through the host seam, so the graph stays acyclic. +// Loop bounds are supplied as an immutable snapshot by the loop controller. A +// legacy direct caller may fall back to window.feedBack.getLoop(), but this +// module neither owns nor imports loop state, so the dependency graph stays +// acyclic. // // app.js's autoplay path used to reach IN and set the credits timers itself. It cannot // now, and it should not have to — so the module exports the OPERATIONS instead @@ -21,7 +22,6 @@ // fails CI if the hooks used here and the hooks app.js wires ever drift apart. import { audio } from './audio-el.js'; import { _audioSeek, _songEventPayload, jucePlayer, setPlayButtonState, togglePlay } from './transport.js'; -import { loopA, loopB, setLoop } from './loops.js'; import { S } from './player-state.js'; // ── Count-in click sound (Web Audio API) ──────────────────────────────── @@ -173,35 +173,58 @@ export function hideSongCreditsOverlay() { if (_creditsOverlay) { _creditsOverlay.remove(); _creditsOverlay = null; } } +// Stop the physical backing transport before an initial loop seek. Keeping +// this next to startCountIn makes the JUCE and HTML5 paths use the same pause +// semantics while allowing the loop controller to establish the important +// pause -> seek -> count-in order. +export async function pauseBackingForCountIn() { + if (window._juceMode) { + await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in count-in:', err)); + } else { + audio.pause(); + } +} + export async function startCountIn(opts = {}) { - if (_countingIn) return; + if (_countingIn) return false; + let requestedBounds = opts.bounds && typeof opts.bounds === 'object' + ? opts.bounds + : null; + if (!requestedBounds && window.feedBack && typeof window.feedBack.getLoop === 'function') { + try { + requestedBounds = window.feedBack.getLoop(); + } catch (_) { + return false; + } + } + const loopA = Number(requestedBounds && (requestedBounds.a ?? requestedBounds.loopA)); + const loopB = Number(requestedBounds && (requestedBounds.b ?? requestedBounds.loopB)); + if (!Number.isFinite(loopA) || !Number.isFinite(loopB) || loopB <= loopA) { + return false; + } _countingIn = true; // Snapshot the current gen so every delayed callback (rewind frames, // post-seek then, count-in ticks, post-count play) can bail if a // teardown bumped the gen mid-flight via _cancelCountIn(). const gen = _countInGen; const immediate = !!opts.immediate; - if (window._juceMode) { - await jucePlayer.pause().catch((err) => console.error('[app] jucePlayer.pause error in count-in:', err)); - } else { - audio.pause(); - } + if (!opts.backingAlreadyPaused) await pauseBackingForCountIn(); if (gen !== _countInGen) return; // teardown during pause - // Section-practice entry: already at loop A after setLoop(); skip the - // B→A rewind animation used on loop wrap and go straight to clicks. + // New loop session: the controller already sought safely to A. Skip the + // B→A rewind animation used on a repeat and go straight to the clicks. if (immediate) { - if (loopA === null || loopB === null) { - _countingIn = false; - return; - } S.lastAudioTime = loopA; - window.highway.setTime(loopA); + if (typeof window.highway.freezeTime === 'function') { + window.highway.freezeTime(loopA); + } else { + window.highway.setTime(loopA); + } if (window.feedBack) { window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA }); } beginCount(); - return; + return true; } // Rewind animation: sweep highway time from B to A @@ -262,13 +285,18 @@ export async function startCountIn(opts = {}) { // marker for "new iteration starts at A", not the actual // audio position. S.lastAudioTime = r.to; - window.highway.setTime(r.to); + if (typeof window.highway.freezeTime === 'function') { + window.highway.freezeTime(r.to); + } else { + window.highway.setTime(r.to); + } window.feedBack.emit('loop:restart', { loopA, loopB, time: loopA }); beginCount(); }); } } _countInRaf = requestAnimationFrame(rewindStep); + return true; function beginCount() { const bpm = window.highway.getBPM(loopA); @@ -323,9 +351,9 @@ export async function startCountIn(opts = {}) { // Start-of-song count-in: a 4-beat click before playback begins, gated by the // "Countdown before song" setting (Gameplay tab). Mirrors the loop count-in's // overlay + click + gen-token cancellation, but counts from the song's current -// position (0 at song start) with no loop A/B rewind. startCountIn() is loop- -// coupled (early-returns when loopA/loopB are null), so this is a sibling -// rather than an overload. Hands off to togglePlay() once the count completes. +// position (0 at song start) with no loop A/B rewind. startCountIn() requires +// a valid loop-bounds snapshot, so this is a sibling rather than an overload. +// Hands off to togglePlay() once the count completes. export async function startSongCountIn() { if (_countingIn) return; _countingIn = true; diff --git a/static/js/juce-audio.js b/static/js/juce-audio.js index f05f8a7c..c9161955 100644 --- a/static/js/juce-audio.js +++ b/static/js/juce-audio.js @@ -899,7 +899,9 @@ export let _resetJuceAudioShimChain = function () {}; const seekTime = batch.seekTime; if (wantsPause && seekTime !== undefined) { enqueue(async (gen) => { - const r = await _audioSeek(seekTime, 'audio-element-shim'); + const r = await _audioSeek(seekTime, 'audio-element-shim', { + restartActiveLoopWhilePlaying: true, + }); if (!r.completed) return; // seek cancelled by teardown if (gen !== _juceShimGen) return; if (!forUpcomingPlay) { @@ -933,7 +935,9 @@ export let _resetJuceAudioShimChain = function () {}; } if (seekTime !== undefined) { enqueue(async (gen) => { - const r = await _audioSeek(seekTime, 'audio-element-shim'); + const r = await _audioSeek(seekTime, 'audio-element-shim', { + restartActiveLoopWhilePlaying: true, + }); if (!r.completed) return; // seek cancelled by teardown if (gen !== _juceShimGen) return; audio.dispatchEvent(new Event('seeked')); diff --git a/static/js/loop-preferences.js b/static/js/loop-preferences.js new file mode 100644 index 00000000..1fe629de --- /dev/null +++ b/static/js/loop-preferences.js @@ -0,0 +1,62 @@ +// Persistent policy for built-in loop entry points. Keep storage handling here +// so the playback controller can stay focused on transport and lifecycle. + +export const LOOP_PREFERENCES_STORAGE_KEY = 'feedback.loop.preferences.v1'; + +export const LOOP_PREFERENCE_DEFAULTS = Object.freeze({ + activation: 'arm', + firstPass: 'count-in', + repeat: 'count-in', +}); + +const _VALID_LOOP_PREFERENCES = Object.freeze({ + activation: new Set(['arm', 'auto']), + firstPass: new Set(['count-in', 'immediate']), + repeat: new Set(['count-in', 'continuous']), +}); + +export function normalizeLoopPreferences(value) { + const source = value && typeof value === 'object' ? value : {}; + return { + activation: _VALID_LOOP_PREFERENCES.activation.has(source.activation) + ? source.activation + : LOOP_PREFERENCE_DEFAULTS.activation, + firstPass: _VALID_LOOP_PREFERENCES.firstPass.has(source.firstPass) + ? source.firstPass + : LOOP_PREFERENCE_DEFAULTS.firstPass, + repeat: _VALID_LOOP_PREFERENCES.repeat.has(source.repeat) + ? source.repeat + : LOOP_PREFERENCE_DEFAULTS.repeat, + }; +} + +export function loadLoopPreferences(storage) { + if (arguments.length === 0) { + try { storage = globalThis.localStorage; } catch (_) { storage = null; } + } + if (!storage || typeof storage.getItem !== 'function') { + return normalizeLoopPreferences(null); + } + try { + const raw = storage.getItem(LOOP_PREFERENCES_STORAGE_KEY); + if (!raw) return normalizeLoopPreferences(null); + return normalizeLoopPreferences(JSON.parse(raw)); + } catch (_) { + return normalizeLoopPreferences(null); + } +} + +export function saveLoopPreferences(preferences, storage) { + const normalized = normalizeLoopPreferences(preferences); + if (arguments.length < 2) { + try { storage = globalThis.localStorage; } catch (_) { storage = null; } + } + if (!storage || typeof storage.setItem !== 'function') return normalized; + try { + storage.setItem(LOOP_PREFERENCES_STORAGE_KEY, JSON.stringify(normalized)); + } catch (_) { + // Storage can be unavailable in private mode or locked-down embeds. + // Preferences still apply for the current page lifetime. + } + return normalized; +} diff --git a/static/js/loops.js b/static/js/loops.js index 898f4846..f316cbb6 100644 --- a/static/js/loops.js +++ b/static/js/loops.js @@ -1,101 +1,170 @@ -// The A–B loop — set / clear / persist, and the saved-loops list. +// Unified A-B loop controller. Bounds, armed/active lifecycle, activation +// policy, first-pass policy, and repeat policy all live here. UI entry points +// (manual A/B, saved loops, and Practice Section) configure this controller; +// none of them owns playback behavior. // -// The second slice out of app.js's strongly-connected core, and it owns the loop -// state: loopA, loopB, _loopMutationGen. Nothing outside this module writes them -// (restartCurrentSong() looked like it did, but it declares its own local shadows). -// -// DIRECTION MATTERS HERE. loops and section-practice are mutually dependent — the -// SCC in miniature. clearLoop() has to drop section-practice's selection, and -// practiceSection() has to call setLoop(). Both directions cannot be imports or the -// no-cycle gate (rightly) rejects it. So the edge is oriented: -// -// section-practice -> reaches loops through the HOST SEAM (host.setLoop, …) -// loops -> imports section-practice DIRECTLY -// -// section-practice is the higher-level feature — it is a consumer of loops, not the -// other way round — so it is the one that gets the indirection. app.js wires this -// module's exports into the seam for it. -// -// See ./host.js: reading an unwired hook THROWS, and tests/js/host_contract.test.js -// fails CI if the hooks used here and the hooks app.js wires ever drift apart. -import { esc, uiPrompt } from './dom.js'; -import { _audioSeek, _audioTime } from './transport.js'; +// The long-standing plugin-facing setLoop(a, b) contract remains compatible: +// it seeks to A and activates the loop. Built-in UI paths opt into the new +// preference-driven behavior with { activation: 'preference' }. +import { uiPrompt } from './dom.js'; +import { _audioSeek, _audioTime, audioSeekGen, togglePlay } from './transport.js'; +import { + _cancelCountIn, + isCountingIn, + pauseBackingForCountIn, + startCountIn, +} from './count-in.js'; +import { S } from './player-state.js'; +import { + loadLoopPreferences, + normalizeLoopPreferences, + saveLoopPreferences, +} from './loop-preferences.js'; import { formatTime } from './format.js'; import { host } from './host.js'; import { _setSectionPracticeMode, _syncSectionPracticeFromLoop, _updateSectionPracticeHighlight, - practiceSection, resetSelection, } from './section-practice.js'; -// ── A-B Loop ──────────────────────────────────────────────────────────── export let loopA = null; export let loopB = null; -// Bumped on every NON-practiceSection loop mutation (direct setLoop from Saved -// Loops / the plugin API, and clearLoop). practiceSection() captures it and bails -// if it changes mid-retry, so a stale section retry can't overwrite a loop the -// user just set/cleared by another path. practiceSection's own setLoop calls pass -// skipSectionSync and do NOT bump it (they must not supersede themselves). export let _loopMutationGen = 0; +let _loopPhase = 'inactive'; // inactive | partial | armed | starting | active +let _loopSource = null; +let _loopOperationGen = 0; +let _loopWrapInFlight = false; +let _loopPreferences = loadLoopPreferences(); +let _savedLoopsLoadGen = 0; +let _savedLoopsRetryTimer = null; +let _loopIndicatorPulseTimer = null; + +function _validLoopBounds(a = loopA, b = loopB) { + return Number.isFinite(a) && Number.isFinite(b) && b > a; +} + +function _loopTransportSnapshot() { + const valid = _validLoopBounds(); + return { + startTime: valid ? loopA : null, + endTime: valid ? loopB : null, + enabled: valid && _loopPhase === 'active', + state: valid ? _loopPhase : (_loopPhase === 'partial' ? 'partial' : 'inactive'), + }; +} + +export function getLoopState() { + return { + loopA, + loopB, + active: _loopPhase === 'active', + configured: _validLoopBounds(), + state: _loopPhase, + source: _loopSource, + preferences: { ..._loopPreferences }, + }; +} + +export function isLoopActive() { + return _loopPhase === 'active' && _validLoopBounds(); +} + +function _emitLoopSet(emitTransportEvent = true) { + if (!emitTransportEvent || typeof window === 'undefined') return; + window.feedBack?.playback?.transportEvent?.('loop-set', { + requesterId: 'core.loop', + loopA, + loopB, + loop: _loopTransportSnapshot(), + }); +} + +function _emitLoopCleared(reason, emitTransportEvent = true) { + if (!emitTransportEvent || typeof window === 'undefined') return; + window.feedBack?.playback?.transportEvent?.('loop-cleared', { + requesterId: 'core.loop', + reason: reason || 'app loop cleared', + loop: { enabled: false, state: 'inactive' }, + }); +} + +function _setPointButtonState(id, selected) { + const el = document.getElementById(id); + if (!el) return; + el.classList.toggle('loop-point-set', !!selected); + el.setAttribute('aria-pressed', selected ? 'true' : 'false'); +} + +export function cancelLoopOperations(options = {}) { + const { deactivate = false } = options; + _loopOperationGen++; + _loopWrapInFlight = false; + _cancelCountIn(); + if (_loopPhase === 'starting' || (deactivate && _loopPhase === 'active')) { + _loopPhase = _validLoopBounds() ? 'armed' : (loopA !== null ? 'partial' : 'inactive'); + } + updateLoopUI(); + return _loopOperationGen; +} + export function setLoopStart() { + const hadConfiguredLoop = _validLoopBounds(); + cancelLoopOperations({ deactivate: true }); loopA = _audioTime(); - document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition'; + loopB = null; + _loopPhase = Number.isFinite(loopA) ? 'partial' : 'inactive'; + _loopSource = 'manual'; + _loopMutationGen++; + _setSectionPracticeMode(false, { skipClearLoop: true }); + resetSelection(); updateLoopUI(); + _syncSavedLoopSelection(); + if (hadConfiguredLoop) _emitLoopCleared('loop bounds changed'); } -export function setLoopEnd() { - if (loopA === null) return; - loopB = _audioTime(); - if (loopB <= loopA) { loopB = null; return; } - document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition'; - updateLoopUI(); - // Manual A/B arming is a loop mutation like setLoop()'s — emit the same - // transport event so event-driven consumers (note_detect drill sync) see - // button-armed loops without having to poll getLoop(). - window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } }); +export async function setLoopEnd() { + if (!Number.isFinite(loopA)) return false; + const end = _audioTime(); + if (!Number.isFinite(end) || end <= loopA) { + loopB = null; + _loopPhase = 'partial'; + updateLoopUI(); + return false; + } + return setLoop(loopA, end, { + activation: 'preference', + source: 'manual', + }); } export function clearLoop(options) { const { emitTransportEvent = true } = options || {}; - // playSong() clears the loop on every song load, so only signal a - // loop-cleared transport event when a loop was actually active — - // otherwise every song switch emits a spurious playback:loop-cleared. const hadLoop = loopA !== null || loopB !== null; + cancelLoopOperations({ deactivate: true }); _setSectionPracticeMode(false, { skipClearLoop: true }); loopA = null; loopB = null; - document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition'; - document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition'; - document.getElementById('btn-loop-clear').classList.add('hidden'); - document.getElementById('btn-loop-save').classList.add('hidden'); - document.getElementById('loop-label').textContent = ''; - document.getElementById('saved-loops').value = ''; + _loopPhase = 'inactive'; + _loopSource = null; + if (hadLoop) _loopMutationGen++; + const saved = document.getElementById('saved-loops'); + if (saved) saved.value = ''; resetSelection(); _updateSectionPracticeHighlight(_audioTime()); - if (hadLoop && emitTransportEvent && typeof window !== 'undefined') { - window.feedBack?.playback?.transportEvent?.('loop-cleared', { - requesterId: 'core.loop', - reason: 'app loop cleared', - loop: { enabled: false, state: 'inactive' }, - }); - } + updateLoopUI(); + _syncSavedLoopSelection(); + if (hadLoop) _emitLoopCleared('app loop cleared', emitTransportEvent); } -// Resync #saved-loops + #btn-loop-delete with the currently-active -// loopA/loopB. Used by both setLoop's success path (so plugin-driven -// loops show up correctly in the dropdown) and loadSavedLoop's -// failure path (so a cancelled selection reverts to the still-active -// loop). Without this sync, deleteSelectedLoop could target a stale -// option that doesn't match the active loop. function _syncSavedLoopSelection() { const sel = document.getElementById('saved-loops'); const delBtn = document.getElementById('btn-loop-delete'); - if (!sel || !delBtn) return; + if (!sel) return; let selected = ''; - if (loopA !== null && loopB !== null) { + if (_validLoopBounds()) { for (const opt of sel.options) { if (Number(opt.dataset.start) === loopA && Number(opt.dataset.end) === loopB) { selected = opt.value; @@ -104,141 +173,420 @@ function _syncSavedLoopSelection() { } } sel.value = selected; - delBtn.classList.toggle('hidden', !selected); + if (delBtn) delBtn.disabled = !selected; } -// Programmatically set both loop endpoints and seek to A. The dropdown -// path (loadSavedLoop) and the plugin-API path (window.feedBack.setLoop) -// both funnel through here so the UI state stays canonical regardless of -// who triggered the loop. +async function _configureLoop(aNum, bNum, options) { + const { + emitTransportEvent = true, + skipSectionSync = false, + commitGuard = null, + source = 'ui', + } = options; + if (typeof commitGuard === 'function' && !commitGuard()) return false; + + cancelLoopOperations({ deactivate: true }); + if (typeof commitGuard === 'function' && !commitGuard()) return false; + loopA = aNum; + loopB = bNum; + _loopPhase = 'armed'; + _loopSource = source; + if (!skipSectionSync) _loopMutationGen++; + updateLoopUI(); + _syncSavedLoopSelection(); + if (!skipSectionSync) _syncSectionPracticeFromLoop(); + _emitLoopSet(emitTransportEvent); + + if (_loopPreferences.activation === 'auto') { + // A failed/cancelled auto-start leaves the valid loop visibly armed so + // the user can retry with Start Loop once the transport is ready. + await startLoop({ source }); + } + return true; +} + +// Backward-compatible public API plus a preference-driven built-in mode. // -// Returns true if the seek landed at A and the loop is now active; -// returns false if the seek was cancelled by teardown or landed off-target -// (JUCE clamp / HTML5 snap > 50ms from A). On false, loopA/loopB are NOT -// committed and the UI is not painted — the prior loop (if any) stays -// active. Throws on invalid inputs. +// Default/legacy: seek to A, then commit an active loop. This preserves plugin +// callers such as note-detection drill mode and the playback capability adapter. +// +// { activation: 'preference' }: configure without disturbing playback, then +// start only when the persisted activation preference says "automatic". export async function setLoop(a, b, options) { - const { emitTransportEvent = true, skipSectionSync = false, commitGuard = null } = options || {}; + const opts = options || {}; + const { + activation = 'legacy', + emitTransportEvent = true, + skipSectionSync = false, + commitGuard = null, + source = 'plugin', + } = opts; const aNum = Number(a); const bNum = Number(b); if (!Number.isFinite(aNum) || !Number.isFinite(bNum) || bNum <= aNum) { throw new Error(`setLoop: requires finite a and b with b > a (got a=${a}, b=${b})`); } - // Don't arm loopA/loopB before the seek lands — the 60Hz tick's wrap - // detector (`ct >= loopB`) would trigger startCountIn against - // half-applied state. - const r = await _audioSeek(aNum, 'loop-set'); - if (!r.completed || Math.abs(r.to - aNum) > 0.05) return false; - // Caller-owned staleness gate, re-checked after the awaited seek and before - // we commit loopA/loopB. practiceSection() passes this so a superseded retry - // (newer section click, mode turned off, or song/arrangement teardown that - // happened during the seek) does not arm a stale loop. Returning false here - // leaves the prior loop (if any) untouched, same as the off-target path. + if (activation === 'preference') { + return _configureLoop(aNum, bNum, { + emitTransportEvent, + skipSectionSync, + commitGuard, + source, + }); + } + if (typeof commitGuard === 'function' && !commitGuard()) return false; + const priorPhase = _loopPhase; + const operation = ++_loopOperationGen; + _loopWrapInFlight = false; + _cancelCountIn(); + _loopPhase = 'starting'; + updateLoopUI(); + const seekGeneration = audioSeekGen(); + const r = await _audioSeek(aNum, 'loop-set', { + guard: () => operation === _loopOperationGen + && seekGeneration === audioSeekGen() + && (typeof commitGuard !== 'function' || commitGuard()), + }); + if (operation !== _loopOperationGen + || seekGeneration !== audioSeekGen() + || !r.completed + || Math.abs(r.to - aNum) > 0.05 + || (typeof commitGuard === 'function' && !commitGuard())) { + if (operation === _loopOperationGen) { + _loopPhase = priorPhase; + updateLoopUI(); + } + return false; + } loopA = aNum; loopB = bNum; - // A direct (non-practice) loop set supersedes any in-flight practiceSection - // retry; practiceSection passes skipSectionSync and is exempt so it doesn't - // cancel itself. + _loopPhase = 'active'; + _loopSource = source; if (!skipSectionSync) _loopMutationGen++; - document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition'; - document.getElementById('btn-loop-b').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition'; updateLoopUI(); - // Sync the saved-loops dropdown so a plugin-driven setLoop call - // surfaces the matching saved option (and Delete button) — otherwise - // the dropdown can stay on a stale selection and deleteSelectedLoop - // would target the wrong record. _syncSavedLoopSelection(); - // practiceSection() passes skipSectionSync: it sets its own section state - // under a request-gen guard, so the shared setLoop path must NOT re-sync - // here — otherwise a stale (superseded / mode-off) practiceSection retry - // that lands inside setLoop would re-arm the loop and flip the mode back on - // before the caller's gen check can bail. Direct callers (Saved Loops, - // window.feedBack.setLoop) still sync so their chip selection tracks. - if (!skipSectionSync && typeof _syncSectionPracticeFromLoop === 'function') { - _syncSectionPracticeFromLoop(); + if (!skipSectionSync) _syncSectionPracticeFromLoop(); + _emitLoopSet(emitTransportEvent); + return true; +} + +export async function startLoop(options = {}) { + if (!_validLoopBounds()) return false; + const bounds = { a: loopA, b: loopB }; + const operation = cancelLoopOperations({ deactivate: true }); + const seekGeneration = audioSeekGen(); + _loopPhase = 'starting'; + _loopSource = options.source || _loopSource || 'ui'; + updateLoopUI(); + + const currentBoundsStillMatch = () => operation === _loopOperationGen + && seekGeneration === audioSeekGen() + && loopA === bounds.a + && loopB === bounds.b; + const countInFirstPass = _loopPreferences.firstPass === 'count-in'; + if (countInFirstPass) { + // On an initial start/restart, stop the native backing engine before + // repositioning it. Seeking a still-running JUCE transport can leak a + // short false start from A before the count-in owns playback. + await pauseBackingForCountIn(); + if (!currentBoundsStillMatch()) return false; + } + const r = await _audioSeek(bounds.a, 'loop-start', { guard: currentBoundsStillMatch }); + if (!currentBoundsStillMatch() || !r.completed || Math.abs(r.to - bounds.a) > 0.05) { + if (operation === _loopOperationGen) { + _loopPhase = _validLoopBounds() ? 'armed' : 'inactive'; + updateLoopUI(); + } + return false; + } + + _loopPhase = 'active'; + updateLoopUI(); + if (countInFirstPass) { + const started = await startCountIn({ + immediate: true, + bounds, + backingAlreadyPaused: true, + }); + if (!started && currentBoundsStillMatch()) { + _loopPhase = 'armed'; + updateLoopUI(); + return false; + } + return started; + } + + if (window.feedBack) { + window.feedBack.emit('loop:restart', { + loopA: bounds.a, + loopB: bounds.b, + time: bounds.a, + }); } - if (emitTransportEvent && typeof window !== 'undefined') { - window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } }); + if (!S.isPlaying) await togglePlay(); + if (!currentBoundsStillMatch()) return false; + if (!S.isPlaying) { + _loopPhase = 'armed'; + updateLoopUI(); + return false; } return true; } +export async function handleLoopBoundary(currentTime) { + if (!isLoopActive() + || !S.isPlaying + || !Number.isFinite(currentTime) + || currentTime < loopB + || _loopWrapInFlight + || isCountingIn()) { + return false; + } + const bounds = { a: loopA, b: loopB }; + const operation = _loopOperationGen; + _loopWrapInFlight = true; + + // Note-detection conductor loops already provide their own audible lead-in + // and historically request a delay-free host wrap. + const repeatMode = window._ndAnyDrillActive ? 'continuous' : _loopPreferences.repeat; + if (repeatMode === 'count-in') { + const started = await startCountIn({ bounds }); + _loopWrapInFlight = false; + return started; + } + + const stillCurrent = () => operation === _loopOperationGen + && _loopPhase === 'active' + && loopA === bounds.a + && loopB === bounds.b; + const r = await _audioSeek(bounds.a, 'loop-wrap-continuous', { guard: stillCurrent }); + _loopWrapInFlight = false; + if (!stillCurrent() || !r.completed || Math.abs(r.to - bounds.a) > 0.05) return false; + S.lastAudioTime = r.to; + if (window.feedBack) { + window.feedBack.emit('loop:restart', { + loopA: bounds.a, + loopB: bounds.b, + time: bounds.a, + }); + } + return true; +} + +export function updateLoopPreference(name, value) { + if (!Object.prototype.hasOwnProperty.call(_loopPreferences, name)) { + return { ..._loopPreferences }; + } + _loopPreferences = saveLoopPreferences(normalizeLoopPreferences({ + ..._loopPreferences, + [name]: value, + })); + updateLoopUI(); + return { ..._loopPreferences }; +} + +function _loopTimelineDuration() { + const highwayDuration = Number(window.highway?.getSongInfo?.()?.duration); + if (Number.isFinite(highwayDuration) && highwayDuration > 0) return highwayDuration; + const songDuration = Number(window.feedBack?.currentSong?.duration); + return Number.isFinite(songDuration) && songDuration > 0 ? songDuration : null; +} + +function _updateLoopTimeline(valid) { + const timeline = document.getElementById('v3-loop-timeline'); + if (!timeline) return; + const hasStart = Number.isFinite(loopA); + const duration = hasStart ? _loopTimelineDuration() : null; + const visible = hasStart && Number.isFinite(duration) && duration > 0; + timeline.hidden = !visible; + timeline.dataset.state = visible ? (valid ? _loopPhase : 'partial') : 'inactive'; + if (!visible) return; + + const region = document.getElementById('v3-loop-timeline-region'); + if (!region) return; + const startPercent = Math.max(0, Math.min(100, (loopA / duration) * 100)); + const endPercent = valid + ? Math.max(startPercent, Math.min(100, (loopB / duration) * 100)) + : startPercent; + region.style.left = `${startPercent}%`; + region.style.width = `${endPercent - startPercent}%`; +} + +export function pulseLoopIndicator() { + const indicator = document.getElementById('v3-loop-indicator'); + if (!indicator || indicator.hidden) return; + indicator.classList.remove('is-returning'); + // Restart the single, short animation even when two restart events arrive + // close together (for example an outside seek immediately followed by A). + void indicator.offsetWidth; + indicator.classList.add('is-returning'); + if (_loopIndicatorPulseTimer !== null) clearTimeout(_loopIndicatorPulseTimer); + _loopIndicatorPulseTimer = setTimeout(() => { + indicator.classList.remove('is-returning'); + _loopIndicatorPulseTimer = null; + }, 700); +} + export function updateLoopUI() { + const valid = _validLoopBounds(); + const active = valid && _loopPhase === 'active'; const label = document.getElementById('loop-label'); - const hasLoop = loopA !== null && loopB !== null; - if (hasLoop) { - label.textContent = `${formatTime(loopA)} → ${formatTime(loopB)}`; - document.getElementById('btn-loop-clear').classList.remove('hidden'); - document.getElementById('btn-loop-save').classList.remove('hidden'); - } else if (loopA !== null) { - label.textContent = `${formatTime(loopA)} → ?`; - document.getElementById('btn-loop-clear').classList.add('hidden'); - document.getElementById('btn-loop-save').classList.add('hidden'); - } else { - label.textContent = ''; + if (label) { + label.textContent = valid + ? `${formatTime(loopA)} → ${formatTime(loopB)}` + : (Number.isFinite(loopA) ? `${formatTime(loopA)} → ?` : ''); + } + + const status = document.getElementById('loop-status'); + if (status) { + status.textContent = _loopPhase === 'active' + ? 'Loop active' + : (_loopPhase === 'starting' + ? 'Starting loop…' + : (_loopPhase === 'armed' + ? 'Loop configured — armed' + : (_loopPhase === 'partial' ? 'Set B to finish the loop' : 'No loop configured'))); + status.dataset.state = _loopPhase; + } + + _setPointButtonState('btn-loop-a', Number.isFinite(loopA)); + _setPointButtonState('btn-loop-b', valid); + const setB = document.getElementById('btn-loop-b'); + if (setB) setB.disabled = !Number.isFinite(loopA); + const clear = document.getElementById('btn-loop-clear'); + if (clear) clear.disabled = loopA === null && loopB === null; + const save = document.getElementById('btn-loop-save'); + if (save) save.disabled = !valid; + const start = document.getElementById('btn-loop-start'); + if (start) { + start.disabled = !valid || _loopPhase === 'starting'; + start.textContent = _loopPhase === 'active' ? 'Restart Loop' : 'Start Loop'; + start.setAttribute('aria-describedby', 'loop-status'); + } + + const activation = document.getElementById('loop-activation-preference'); + if (activation) activation.value = _loopPreferences.activation; + const firstPass = document.getElementById('loop-first-pass-preference'); + if (firstPass) firstPass.value = _loopPreferences.firstPass; + const repeat = document.getElementById('loop-repeat-preference'); + if (repeat) repeat.value = _loopPreferences.repeat; + + const pill = document.getElementById('section-practice-pill'); + if (pill) { + pill.classList.toggle('section-practice-pill--active', active); + pill.classList.toggle('section-practice-pill--armed', _loopPhase === 'armed' || _loopPhase === 'partial'); } + + // Persistent, low-key feedback in the regular game HUD. The Practice & + // Loops rail can auto-hide, so it cannot be the only explanation for why + // playback keeps returning to the same region. + const hudIndicator = document.getElementById('v3-loop-indicator'); + if (hudIndicator) { + const hudLabel = document.getElementById('v3-loop-indicator-label'); + const range = document.getElementById('v3-loop-indicator-range'); + const openButton = document.getElementById('v3-loop-indicator-open'); + const announcement = document.getElementById('v3-loop-announcement'); + const rangeText = valid ? `${formatTime(loopA)} – ${formatTime(loopB)}` : ''; + const hudState = active ? 'active' : (_loopPhase === 'starting' ? 'starting' : 'armed'); + const stateText = active + ? 'Loop on' + : (_loopPhase === 'starting' ? 'Loop starting' : 'Loop ready'); + if (hudLabel) hudLabel.textContent = stateText; + if (range) range.textContent = rangeText; + hudIndicator.hidden = !valid; + hudIndicator.dataset.state = valid ? hudState : 'inactive'; + if (openButton) { + openButton.setAttribute( + 'aria-label', + valid ? `${stateText}, ${rangeText}. Open Practice and Loops` : 'Open Practice and Loops', + ); + } + if (announcement) { + announcement.textContent = valid ? `${stateText}, ${rangeText}` : 'Loop disabled'; + } + } + _updateLoopTimeline(valid); host._updateEditRegionBtn(); } export async function loadSavedLoops() { + const request = ++_savedLoopsLoadGen; const sel = document.getElementById('saved-loops'); const delBtn = document.getElementById('btn-loop-delete'); - if (!host.currentFilename()) { sel.classList.add('hidden'); delBtn.classList.add('hidden'); return; } - - const resp = await fetch(`/api/loops?filename=${encodeURIComponent(decodeURIComponent(host.currentFilename()))}`); - const loops = await resp.json(); - - sel.innerHTML = ''; - for (const l of loops) { - sel.innerHTML += ``; + if (!sel) { + if (_savedLoopsRetryTimer !== null) clearTimeout(_savedLoopsRetryTimer); + _savedLoopsRetryTimer = setTimeout(() => { + _savedLoopsRetryTimer = null; + loadSavedLoops(); + }, 100); + return; } - if (loops.length > 0) { - sel.classList.remove('hidden'); - } else { - sel.classList.add('hidden'); + const filename = host.currentFilename(); + if (!filename) { + sel.innerHTML = ''; + sel.disabled = true; + if (delBtn) delBtn.disabled = true; + return; + } + + try { + const resp = await fetch(`/api/loops?filename=${encodeURIComponent(decodeURIComponent(filename))}`); + const loops = await resp.json(); + if (request !== _savedLoopsLoadGen || filename !== host.currentFilename()) return; + sel.innerHTML = loops.length + ? '' + : ''; + for (const savedLoop of loops) { + const option = document.createElement('option'); + option.value = String(savedLoop.id); + option.dataset.start = String(savedLoop.start); + option.dataset.end = String(savedLoop.end); + option.textContent = `${savedLoop.name} (${formatTime(savedLoop.start)}→${formatTime(savedLoop.end)})`; + sel.appendChild(option); + } + sel.disabled = loops.length === 0; + _syncSavedLoopSelection(); + } catch (err) { + if (request !== _savedLoopsLoadGen) return; + console.warn('[loadSavedLoops] failed:', err); + sel.innerHTML = ''; + sel.disabled = true; + if (delBtn) delBtn.disabled = true; } - delBtn.classList.add('hidden'); } export async function loadSavedLoop(loopId) { const sel = document.getElementById('saved-loops'); + if (!sel) return false; const opt = sel.selectedOptions[0]; - const delBtn = document.getElementById('btn-loop-delete'); if (!loopId || !opt?.dataset.start) { - delBtn.classList.add('hidden'); - return; + _syncSavedLoopSelection(); + return false; } let ok = false; try { - // Pass raw strings — setLoop's Number() coercion is stricter than - // parseFloat (rejects "12abc") so malformed dataset values throw - // and fall into the catch instead of silently truncating. - ok = await setLoop(opt.dataset.start, opt.dataset.end); + ok = await setLoop(opt.dataset.start, opt.dataset.end, { + activation: 'preference', + source: 'saved', + }); } catch (err) { - // Malformed dataset (server returned bad data): treat the same as - // a failed seek so the dropdown resyncs and we don't propagate an - // uncaught rejection out of the onchange handler. console.warn('[loadSavedLoop] setLoop threw:', err); - ok = false; } - if (!ok) { - // Seek aborted, landed off-target, or input was malformed. - // Resync the dropdown with the still-active loop so the UI - // doesn't lie about which loop is loaded. - _syncSavedLoopSelection(); - return; - } - // Success path: setLoop already called _syncSavedLoopSelection, - // which surfaces the delete button when the new loop matches a - // saved option (which the dropdown selection guarantees here). + if (!ok) _syncSavedLoopSelection(); + return ok; } export async function saveCurrentLoop() { - if (loopA === null || loopB === null || !host.currentFilename()) return; - const name = await uiPrompt({ title: 'Save Loop', label: 'Loop name', value: 'Loop', okLabel: 'Save' }); - if (name === null) return; // cancelled - const finalName = name.trim() || 'Loop'; // never persist an empty name + if (!_validLoopBounds() || !host.currentFilename()) return; + const name = await uiPrompt({ + title: 'Save Loop', + label: 'Loop name', + value: 'Loop', + okLabel: 'Save', + }); + if (name === null) return; + const finalName = name.trim() || 'Loop'; await fetch('/api/loops', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -250,12 +598,11 @@ export async function saveCurrentLoop() { }), }); await loadSavedLoops(); - document.getElementById('btn-loop-save').classList.add('hidden'); } export async function deleteSelectedLoop() { const sel = document.getElementById('saved-loops'); - const loopId = sel.value; + const loopId = sel && sel.value; if (!loopId) return; await fetch(`/api/loops/${loopId}`, { method: 'DELETE' }); clearLoop(); diff --git a/static/js/section-practice.js b/static/js/section-practice.js index dc3eb46c..0b53e81d 100644 --- a/static/js/section-practice.js +++ b/static/js/section-practice.js @@ -7,11 +7,11 @@ // 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. +// It owns only section-selection state — the _sectionPractice* / +// _sectionParents* scalars are read nowhere else and move in with it. Loop +// bounds and lifecycle belong to the shared loop controller. Calls back into +// app.js and the controller cross the host seam so the module graph remains +// acyclic. // // 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 @@ -62,9 +62,9 @@ let _sectionPracticeMode = false; let _sectionPracticeActiveParent = -1; let _sectionPracticeWholeSection = false; let _sectionPracticeSavedPartIndex = 0; -// Monotonic token to cancel stale practiceSection() retries: a newer click +// Monotonic token to cancel stale practiceSection() requests: 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. +// any in-flight controller work so it cannot arm the wrong loop. 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 @@ -78,10 +78,10 @@ export function _setSectionPracticeMode(on, opts = {}) { _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. + // Section selection is UI state only. The loop controller separately owns + // the pill's armed/active classes. const pill = document.getElementById('section-practice-pill'); - if (pill) pill.classList.toggle('section-practice-pill--active', _sectionPracticeMode); + if (pill) pill.classList.toggle('section-practice-pill--section-selected', _sectionPracticeMode); _sectionPracticeFollowParent = -1; if (_sectionPracticeMode) { if (opts.defaultWholeOn) { @@ -93,10 +93,9 @@ export function _setSectionPracticeMode(on, opts = {}) { } } 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. + // request: otherwise stale controller work landing after the user + // unchecks Section Practice could re-arm the loop and flip the mode + // back on via _syncSectionPracticeFromLoop(). _sectionPracticeRequestGen++; // Cancel any pending count-in: every section-practice teardown routes // through here (mode toggle off, clearLoop, and _hideSectionPracticeBar @@ -495,18 +494,59 @@ function _migrateSectionPracticeDomLayout(bar) { } function _sectionPracticeBarInnerHtml() { - return '
' + return '
' + + 'Practice & Loops' + + 'No loop configured' + + '
' + + '
' + + '
Custom loop
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + 'How the loop plays' + + '' + + '' + + '' + + '
' + + '
' + + '
Practice Section
' + + '
' + '' + _sectionPracticeWholeCheckboxHtml() + _sectionPracticePieceRowHtml() + '
' + '
' + 'Sections:' - + '' - + '
'; + + '' + + '' + + '
' + + ''; } function _ensureSectionPracticeWholeCheckbox() { @@ -545,13 +585,13 @@ function _sectionPracticeCurrentPartIndex() { function _sectionPracticePillHtml() { return ''; } @@ -568,12 +608,12 @@ function _syncSectionPracticePillV3Chrome(isV3) { ring.setAttribute('aria-hidden', 'true'); pill.insertBefore(ring, pill.firstChild); } - pill.setAttribute('title', 'Practice'); - pill.setAttribute('aria-label', 'Practice'); + pill.setAttribute('title', 'Practice & Loops'); + pill.setAttribute('aria-label', 'Practice & Loops'); } else { if (ring) ring.remove(); - pill.setAttribute('title', 'Section practice'); - pill.setAttribute('aria-label', 'Section practice'); + pill.setAttribute('title', 'Practice & Loops'); + pill.setAttribute('aria-label', 'Practice & Loops'); } } @@ -679,7 +719,11 @@ function _ensureSectionPracticeDom() { let bar = document.getElementById('section-practice-bar'); if (bar) { _ensureSectionPracticeControlWrap(bar); - _migrateSectionPracticeDomLayout(bar); + if (!bar.querySelector('.practice-loop-options')) { + bar.innerHTML = _sectionPracticeBarInnerHtml(); + } else { + _migrateSectionPracticeDomLayout(bar); + } if (!bar.querySelector('#section-practice-piece-row')) { const controlsRow = bar.querySelector('.section-practice-controls-row') || bar.querySelector('.section-practice-primary-row'); @@ -692,6 +736,7 @@ function _ensureSectionPracticeDom() { _ensureSectionPracticeWholeCheckbox(); bar.querySelector('.section-practice-show-all-wrap')?.remove(); _placeSectionPracticeControlForChrome(); + host.updateLoopUI(); return bar; } const controls = document.getElementById('player-controls'); @@ -701,7 +746,7 @@ function _ensureSectionPracticeDom() { bar.id = 'section-practice-bar'; bar.className = 'section-practice-bar'; bar.setAttribute('role', 'dialog'); - bar.setAttribute('aria-label', 'Section practice'); + bar.setAttribute('aria-label', 'Practice & Loops'); bar.innerHTML = _sectionPracticeBarInnerHtml(); const ctrl = document.createElement('div'); ctrl.id = 'section-practice-control'; @@ -713,6 +758,7 @@ function _ensureSectionPracticeDom() { // anchors on `#player-controls > button:last-of-type` (see static/v3/index.html). _mountSectionPracticeControlSafe(ctrl); _placeSectionPracticeControlForChrome(); + host.updateLoopUI(); return bar; } @@ -822,7 +868,9 @@ export function _sectionPracticeBarIsReady() { const ctrl = document.getElementById('section-practice-control'); if (!ctrl || ctrl.classList.contains('section-practice-control--hidden')) return false; const scroll = document.getElementById('section-practice-scroll'); - return !!(scroll && scroll.querySelector('[data-parent-idx]')); + const empty = document.getElementById('section-practice-empty'); + return !!(scroll && (scroll.querySelector('[data-parent-idx]') + || (empty && !empty.classList.contains('hidden')))); } export function _installSectionPracticeDrawHook() { @@ -893,10 +941,21 @@ export function renderSectionPracticeBar() { const bar = _ensureSectionPracticeDom(); const scroll = document.getElementById('section-practice-scroll'); if (!bar || !scroll) return; + const empty = document.getElementById('section-practice-empty'); + const mode = document.getElementById('section-practice-mode'); + const whole = document.getElementById('section-practice-whole'); if (!parents.length) { - _hideSectionPracticeBar(); + _showSectionPracticeBar(bar); + scroll.innerHTML = ''; + if (empty) empty.classList.remove('hidden'); + if (mode) mode.disabled = true; + if (whole) whole.disabled = true; + _syncSectionPracticePieceUi(); return; } + if (empty) empty.classList.add('hidden'); + if (mode) mode.disabled = false; + if (whole) whole.disabled = false; if (_sectionPracticeActiveParent >= parents.length) { _sectionPracticeResetSelectionUi(); } @@ -1037,49 +1096,40 @@ export async function practiceSection(index, opts = {}) { // 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. + _setSectionPracticeMode(true, { skipClearLoop: true }); + + // This feature only supplies chart-derived bounds. setLoop's shared + // controller applies the activation, first-pass, and repeat policies. + let ok = false; if (requestGen !== _sectionPracticeRequestGen || seekGen !== 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. + // skipSectionSync: this function owns section-selection state and + // applies it below under the request-gen guard. + // commitGuard: prevent a superseded request from committing bounds + // at all; the controller re-checks it immediately before arming. ok = await host.setLoop(start, end, { + activation: 'preference', + source: 'section', skipSectionSync: true, commitGuard: () => requestGen === _sectionPracticeRequestGen && seekGen === 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 !== audioSeekGen() || loopGen !== host._loopMutationGen()) return; - - if (ok) { - _sectionPracticeWholeSection = whole; - if (!whole) { - _sectionPracticeSelected = index; - _sectionPracticeSavedPartIndex = index; + // Re-check after the awaited controller work before painting selection. + if (requestGen !== _sectionPracticeRequestGen || seekGen !== audioSeekGen() || loopGen !== host._loopMutationGen()) return; + + if (ok) { + _sectionPracticeWholeSection = whole; + if (!whole) { + _sectionPracticeSelected = index; + _sectionPracticeSavedPartIndex = index; + } + _blurSectionPracticeFocusIfNeeded(); + _updateSectionPracticeHighlight(_audioTime()); + } else { + _setSectionPracticeMode(false, { skipClearLoop: true }); } - _blurSectionPracticeFocusIfNeeded(); - _updateSectionPracticeHighlight(_audioTime()); - host.startCountIn({ immediate: true }); - } else { - _setSectionPracticeMode(false, { skipClearLoop: true }); - } } finally { _sectionPracticeRequestInFlight--; } @@ -1189,7 +1239,8 @@ export function _maybeRefreshSectionPracticeDuration(dur) { // Re-render when section metadata appears (before audio duration is known). export function _ensureSectionPracticeBar() { - if (_sectionPracticeSourceSections().length === 0) return; + const player = document.getElementById('player'); + if (!player || !player.classList.contains('active') || !host.currentFilename()) return; if (!_sectionPracticeBarIsReady()) { renderSectionPracticeBar(); } diff --git a/static/js/transport.js b/static/js/transport.js index 0da16018..210e9f48 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -225,6 +225,45 @@ let _audioSeekChain = Promise.resolve(); let _audioSeekGen = 0; +// The transport owns Play, but the loop controller owns the active A bound. +// app.js wires the two together without introducing a transport <-> loops +// import cycle. Timeline seeks remain unrestricted; this resolver is consulted +// only when paused playback is about to start. +let _loopPlayStartTargetResolver = null; +let _loopRestartHandler = null; + +export function setLoopPlayStartTargetResolver(resolver) { + _loopPlayStartTargetResolver = typeof resolver === 'function' ? resolver : null; +} + +// app.js wires this to the loop controller without introducing a +// transport <-> loops module cycle. It is invoked only when the resolver +// changes an outside-loop position to A, so the controller can apply the +// configured first-pass policy instead of performing a bare seek. +export function setLoopRestartHandler(handler) { + _loopRestartHandler = typeof handler === 'function' ? handler : null; +} + +async function _restartLoopFromOutside(requestedTime, targetTime, trigger) { + if (!_loopRestartHandler) return null; + try { + const result = await _loopRestartHandler({ + requestedTime, + targetTime, + trigger, + }); + if (result && typeof result === 'object') return result; + return { + completed: result === true, + from: NaN, + to: result === true ? targetTime : NaN, + }; + } catch (err) { + console.warn('[loop] outside-position restart failed:', err); + return { completed: false, from: NaN, to: NaN }; + } +} + export function _resetAudioSeekState() { // Bump the generation — in-flight chain callbacks see the mismatch on // their next guard check and short-circuit (no emit, no further state @@ -263,20 +302,57 @@ function _juceSeekWithTimeout(s) { // session. Callers that need the actual landed position (because JUCE may // clamp or HTML5 may snap to the seekable range) should read `to` rather // than re-using the requested `s`. -export async function _audioSeek(s, reason) { +export async function _audioSeek(s, reason, options = {}) { // Single funnel for every audio repositioning. Emits song:seek so // plugins (notedetect detection-suppression during seek transients, // practice-journal segment tracking) can react to any chart-time // jump regardless of which UI path triggered it. `reason` is a // free-form short string ('seek-by', 'loop-wrap', 'loop-set', // 'arrangement-restore', 'jump-fix') so subscribers can filter. + // While playing, an outside-loop timeline request is a semantic loop + // restart, not just a redirected seek. Let the loop controller apply its + // first-pass policy (count-in or immediate). The controller's own seek + // does not set restartActiveLoopWhilePlaying, so this cannot recurse. + if (options.restartActiveLoopWhilePlaying + && S.isPlaying + && _loopPlayStartTargetResolver + && _loopRestartHandler) { + let resolved = s; + try { + resolved = _loopPlayStartTargetResolver(s); + } catch (_) { + resolved = s; + } + if (Number.isFinite(resolved) && Math.abs(resolved - s) > 0.01) { + return _restartLoopFromOutside(s, resolved, 'seek'); + } + } + const gen = _audioSeekGen; + const guard = typeof options.guard === 'function' ? options.guard : null; + const permitted = () => { + if (!guard) return true; + try { return !!guard(); } catch (_) { return false; } + }; _audioSeekChain = _audioSeekChain.then(async () => { - if (gen !== _audioSeekGen) return { completed: false, from: NaN, to: NaN }; + if (gen !== _audioSeekGen || !permitted()) { + return { completed: false, from: NaN, to: NaN }; + } + let target = s; + if (options.restartActiveLoopWhilePlaying && S.isPlaying && _loopPlayStartTargetResolver) { + try { + const resolved = _loopPlayStartTargetResolver(s); + if (Number.isFinite(resolved)) target = resolved; + } catch (_) { + // A UI policy hook must never poison the canonical seek queue. + } + } const from = _audioTime(); - if (window._juceMode) await _juceSeekWithTimeout(s); - else audio.currentTime = s; - if (gen !== _audioSeekGen) return { completed: false, from, to: NaN }; + if (window._juceMode) await _juceSeekWithTimeout(target); + else audio.currentTime = target; + if (gen !== _audioSeekGen || !permitted()) { + return { completed: false, from, to: NaN }; + } // Read the verified post-seek position rather than the requested `s` // so plugins observe the actual clock — JUCE may clamp or roll back, // and HTML5 may snap to the nearest seekable range. @@ -307,7 +383,43 @@ export async function _audioSeek(s, reason) { // clobber the UI of a newer attempt N+1 within the same session. let _playAttemptGen = 0; +async function _prepareLoopPlayStart() { + if (!_loopPlayStartTargetResolver) return true; + const current = _audioTime(); + let target = current; + try { + const resolved = _loopPlayStartTargetResolver(current); + if (Number.isFinite(resolved)) target = resolved; + } catch (_) { + // A UI policy hook must never block ordinary playback. + return true; + } + if (!Number.isFinite(target) || Math.abs(target - current) <= 0.01) return true; + const restarted = await _restartLoopFromOutside(current, target, 'play'); + if (restarted !== null) { + const completed = !!restarted.completed + && Number.isFinite(restarted.to) + && Math.abs(restarted.to - target) <= 0.05; + // The loop controller owns playback after a successful restart: it + // either scheduled the count-in or started immediate playback itself. + // Signal togglePlay() to stop instead of applying the original click a + // second time (which would bypass the count-in or pause immediately). + return completed ? 'handled' : false; + } + const r = await _audioSeek(target, 'loop-play-start'); + return r.completed && Math.abs(r.to - target) <= 0.05; +} + export async function togglePlay() { + // Seeking is intentionally free while paused. The active-loop rule is + // applied only at the transition into playback: resolve to A, verify the + // backend landed there, then start. Re-read S.isPlaying after the await so + // two rapid toggles still settle as Play -> Pause rather than double-start. + if (!S.isPlaying) { + const preparation = await _prepareLoopPlayStart(); + if (preparation !== true) return; + } + if (window._juceMode) { if (S.isPlaying) { await jucePlayer.pause(); @@ -366,7 +478,9 @@ export async function togglePlay() { } export async function seekBy(s) { - await _audioSeek(Math.max(0, _audioTime() + s), 'seek-by'); + await _audioSeek(Math.max(0, _audioTime() + s), 'seek-by', { + restartActiveLoopWhilePlaying: true, + }); } /** diff --git a/static/style.css b/static/style.css index 951118ce..5af9476a 100644 --- a/static/style.css +++ b/static/style.css @@ -81,6 +81,10 @@ html { scroll-behavior: smooth; } border-color: #4080e0; color: #93c5fd; } +.section-practice-pill.section-practice-pill--armed { + border-color: #d97706; + color: #fbbf24; +} .section-practice-pill-icon { font-size: 13px; line-height: 1; } .section-practice-pill-caret { font-size: 10px; opacity: 0.8; } @@ -105,6 +109,110 @@ html { scroll-behavior: smooth; } } #section-practice-bar.section-practice-bar--open { display: flex; } +.practice-loop-heading { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + color: #f8fafc; + font-size: 14px; +} + +.practice-loop-status { + color: #94a3b8; + font-size: 11px; + font-weight: 500; + text-align: right; +} + +.practice-loop-status[data-state="armed"] { color: #fbbf24; } +.practice-loop-status[data-state="starting"] { color: #7dd3fc; } +.practice-loop-status[data-state="active"] { color: #86efac; } + +.practice-loop-group { + min-width: 0; + margin: 0; + padding: 8px 0; + border: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.practice-loop-group:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.practice-loop-group-title { + margin-bottom: 6px; + color: #94a3b8; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.practice-loop-start { + border-color: rgba(34, 211, 238, 0.55) !important; +} + +.practice-loop-bounds { + min-width: 5.5rem; + color: #94a3b8; + font-size: 11px; + font-variant-numeric: tabular-nums; +} + +.practice-loop-saved { + margin-top: 6px; +} + +.practice-loop-saved select { + flex: 1 1 180px; + min-width: 0; +} + +.practice-loop-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 6px; + color: #cbd5e1; + font-size: 11px; +} + +.practice-loop-option select { + flex: 0 0 190px; + width: 190px; +} + +.practice-section-empty { + color: #64748b; + font-size: 11px; + font-style: italic; +} + +#section-practice-bar button:disabled, +#section-practice-bar select:disabled { + cursor: not-allowed; + opacity: 0.45; +} + +#section-practice-bar button:focus-visible, +#section-practice-bar select:focus-visible, +#section-practice-bar input:focus-visible { + outline: 2px solid #22d3ee; + outline-offset: 2px; +} + +#section-practice-bar .loop-point-set { + border-color: rgba(74, 222, 128, 0.55); + background: rgba(20, 83, 45, 0.5); + color: #86efac; +} + /* v3 chrome: the pill rides the left icon rail; popover opens to its right. */ .section-practice-control--v3 { padding: 0; } .section-practice-control--v3 .section-practice-pill-text, diff --git a/static/v3/index.html b/static/v3/index.html index 666ae0b3..b72f3afd 100644 --- a/static/v3/index.html +++ b/static/v3/index.html @@ -1000,6 +1000,16 @@

Gameplay Settings

+ + + + + Loop disabled