From cb236e6c04fcaa18e0da587471b13ca8e0568cdb Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sat, 11 Jul 2026 21:32:31 +0200 Subject: [PATCH] =?UTF-8?q?refactor(app):=20carve=20the=20A=E2=80=93B=20lo?= =?UTF-8?q?op=20out=20of=20app.js=20(R3a)=20(#888)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit static/js/loops.js (261) — bodies VERBATIM. app.js 8,421 → 8,224. The second slice out of the strongly-connected core. It OWNS the loop state — loopA, loopB, _loopMutationGen. Nothing outside writes them: restartCurrentSong() looked like it did, but it declares its own local `let loopA/loopB` shadows, so the module-level bindings only ever change in setLoop / setLoopStart / setLoopEnd / clearLoop. All four move here. No state container. DIRECTION IS THE WHOLE DESIGN. loops and section-practice are mutually dependent — the SCC in miniature. clearLoop() must drop section-practice's selection, and practiceSection() must call setLoop(). Both edges cannot be imports or no-cycle (rightly) rejects it. So: section-practice -> reaches loops through the HOST SEAM (host.setLoop, …) loops -> imports section-practice DIRECTLY section-practice is the higher-level feature — a consumer of loops, not the reverse — so it is the one that takes the indirection. app.js hands the loop module's exports across into the seam for it. Graph stays acyclic; no-cycle passes. THE CONTRACT TEST EARNED ITS KEEP IMMEDIATELY. It failed on the first build with "these hooks are wired by app.js but no module reads them: playSong". My dependency scan had counted a mention of playSong() inside a COMMENT in loops.js as a real call. Wired but unused is precisely the "fossil of a rename" case the test exists for — and it caught it on a path no test executes. VERIFIED BY DRIVING BOTH SIDES OF THE SEAM. A/B against origin/main in two browsers, real song loaded: * setLoop(5,12) -> true; getLoop() -> 5,12 — IDENTICAL * clearLoop() (loops -> section-practice, a direct import) -> getLoop() -> null,null — IDENTICAL * onPhraseNext() (section-practice -> loops, ACROSS THE SEAM) -> ok — IDENTICAL * loadSavedLoop / saveCurrentLoop / deleteSelectedLoop on window — IDENTICAL * zero page errors either side. An unwired hook throws, so a live app is itself proof the seam is wired. Harness: loop_api extracts the loop helpers by signature — retargeted to loops.js, `export` stripped for the vm sandbox, and the sandbox's existing _audioSeek / _audioTime / formatTime spies are now routed through a `host` object so every assertion holds unchanged, just through the indirection the real code uses. It is SPLIT: one test still reads app.js for the window.feedBack API surface, which stayed. pytest 2396, node 1040/1040, ESLint 0 (no-cycle clean), tailwind clean. Co-authored-by: Claude Opus 4.8 (1M context) --- static/app.js | 257 +++++-------------------------------- static/js/loops.js | 261 ++++++++++++++++++++++++++++++++++++++ tests/js/loop_api.test.js | 41 ++++-- 3 files changed, 321 insertions(+), 238 deletions(-) create mode 100644 static/js/loops.js diff --git a/static/app.js b/static/app.js index a49ea5a..b0ad882 100644 --- a/static/app.js +++ b/static/app.js @@ -40,6 +40,21 @@ import { importSettings, } from './js/settings-io.js'; import { audio } from './js/audio-el.js'; +import { + _loopMutationGen, + clearLoop, + deleteSelectedLoop, + loadSavedLoop, + loadSavedLoops, + loopA, + loopB, + saveCurrentLoop, + setLoop, + setLoopEnd, + setLoopStart, + updateLoopUI, +} from './js/loops.js'; + import { _buildSectionParents, _ensureSectionPracticeBar, @@ -6387,156 +6402,12 @@ if (window.feedBack) { function formatTime(s) { return `${Math.floor(s/60)}:${String(Math.floor(s%60)).padStart(2,'0')}`; } -// ── A-B Loop ──────────────────────────────────────────────────────────── -let loopA = null; -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). -let _loopMutationGen = 0; -function setLoopStart() { - 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'; - updateLoopUI(); -} -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' } }); -} -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; - _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 = ''; - 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' }, - }); - } -} -// 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; - let selected = ''; - if (loopA !== null && loopB !== null) { - for (const opt of sel.options) { - if (Number(opt.dataset.start) === loopA && Number(opt.dataset.end) === loopB) { - selected = opt.value; - break; - } - } - } - sel.value = selected; - delBtn.classList.toggle('hidden', !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. -// -// 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. -async function setLoop(a, b, options) { - const { emitTransportEvent = true, skipSectionSync = false, commitGuard = null } = options || {}; - 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 (typeof commitGuard === 'function' && !commitGuard()) 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. - 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 (emitTransportEvent && typeof window !== 'undefined') { - window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } }); - } - return true; -} -function updateLoopUI() { - 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 = ''; - } - _updateEditRegionBtn(); -} // ── Highway → Editor handoff ("Edit region") ──────────────────────────── // The flip side of the editor's "Loop in 3D" button: jump from the player @@ -6713,87 +6584,10 @@ window.onPhraseNext = onPhraseNext; -async function loadSavedLoops() { - const sel = document.getElementById('saved-loops'); - const delBtn = document.getElementById('btn-loop-delete'); - if (!currentFilename) { sel.classList.add('hidden'); delBtn.classList.add('hidden'); return; } - - const resp = await fetch(`/api/loops?filename=${encodeURIComponent(decodeURIComponent(currentFilename))}`); - const loops = await resp.json(); - - sel.innerHTML = ''; - for (const l of loops) { - sel.innerHTML += ``; - } - if (loops.length > 0) { - sel.classList.remove('hidden'); - } else { - sel.classList.add('hidden'); - } - delBtn.classList.add('hidden'); -} - -async function loadSavedLoop(loopId) { - const sel = document.getElementById('saved-loops'); - const opt = sel.selectedOptions[0]; - const delBtn = document.getElementById('btn-loop-delete'); - if (!loopId || !opt?.dataset.start) { - delBtn.classList.add('hidden'); - return; - } - 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); - } 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). -} -async function saveCurrentLoop() { - if (loopA === null || loopB === null || !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 - await fetch('/api/loops', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - filename: decodeURIComponent(currentFilename), - name: finalName, - start: loopA, - end: loopB, - }), - }); - await loadSavedLoops(); - document.getElementById('btn-loop-save').classList.add('hidden'); -} -async function deleteSelectedLoop() { - const sel = document.getElementById('saved-loops'); - const loopId = sel.value; - if (!loopId) return; - await fetch(`/api/loops/${loopId}`, { method: 'DELETE' }); - clearLoop(); - await loadSavedLoops(); -} + // ── Count-in click sound (Web Audio API) ──────────────────────────────── let _audioCtx = null; @@ -8378,17 +8172,26 @@ async function checkScanAndLoad() { configureHost({ _audioTime, _audioDuration, - setLoop, - clearLoop, startCountIn, _cancelCountIn, formatTime, - // Read-only getters. app.js still OWNS these reassigned scalars — the module only - // ever reads them, so no state container is needed. + _audioSeek, + _updateEditRegionBtn, + // 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 + // module's own exports, handed across. + setLoop, + clearLoop, + // Read-only getters. The module only ever READS these reassigned scalars, so no + // state container is needed. loopA/loopB/_loopMutationGen are owned by + // ./js/loops.js now and imported here as live bindings; _audioSeekGen and + // currentFilename are still app.js's. loopA: () => loopA, loopB: () => loopB, - _audioSeekGen: () => _audioSeekGen, _loopMutationGen: () => _loopMutationGen, + _audioSeekGen: () => _audioSeekGen, + currentFilename: () => currentFilename, }); Object.assign(window, { diff --git a/static/js/loops.js b/static/js/loops.js new file mode 100644 index 0000000..2a497f8 --- /dev/null +++ b/static/js/loops.js @@ -0,0 +1,261 @@ +// The A–B loop — set / clear / persist, and the saved-loops list. +// +// 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 { 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; + +export function setLoopStart() { + loopA = host._audioTime(); + document.getElementById('btn-loop-a').className = 'px-3 py-1.5 bg-green-900/50 rounded-lg text-xs text-green-300 transition'; + updateLoopUI(); +} + +export function setLoopEnd() { + if (loopA === null) return; + loopB = host._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 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; + _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 = ''; + resetSelection(); + _updateSectionPracticeHighlight(host._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' }, + }); + } +} + +// 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; + let selected = ''; + if (loopA !== null && loopB !== null) { + for (const opt of sel.options) { + if (Number(opt.dataset.start) === loopA && Number(opt.dataset.end) === loopB) { + selected = opt.value; + break; + } + } + } + sel.value = selected; + delBtn.classList.toggle('hidden', !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. +// +// 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. +export async function setLoop(a, b, options) { + const { emitTransportEvent = true, skipSectionSync = false, commitGuard = null } = options || {}; + 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 host._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 (typeof commitGuard === 'function' && !commitGuard()) 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. + 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 (emitTransportEvent && typeof window !== 'undefined') { + window.feedBack?.playback?.transportEvent?.('loop-set', { requesterId: 'core.loop', loopA, loopB, loop: { startTime: loopA, endTime: loopB, enabled: true, state: 'active' } }); + } + return true; +} + +export function updateLoopUI() { + const label = document.getElementById('loop-label'); + const hasLoop = loopA !== null && loopB !== null; + if (hasLoop) { + label.textContent = `${host.formatTime(loopA)} → ${host.formatTime(loopB)}`; + document.getElementById('btn-loop-clear').classList.remove('hidden'); + document.getElementById('btn-loop-save').classList.remove('hidden'); + } else if (loopA !== null) { + label.textContent = `${host.formatTime(loopA)} → ?`; + document.getElementById('btn-loop-clear').classList.add('hidden'); + document.getElementById('btn-loop-save').classList.add('hidden'); + } else { + label.textContent = ''; + } + host._updateEditRegionBtn(); +} + +export async function loadSavedLoops() { + 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 (loops.length > 0) { + sel.classList.remove('hidden'); + } else { + sel.classList.add('hidden'); + } + delBtn.classList.add('hidden'); +} + +export async function loadSavedLoop(loopId) { + const sel = document.getElementById('saved-loops'); + const opt = sel.selectedOptions[0]; + const delBtn = document.getElementById('btn-loop-delete'); + if (!loopId || !opt?.dataset.start) { + delBtn.classList.add('hidden'); + return; + } + 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); + } 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). +} + +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 + await fetch('/api/loops', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filename: decodeURIComponent(host.currentFilename()), + name: finalName, + start: loopA, + end: loopB, + }), + }); + await loadSavedLoops(); + document.getElementById('btn-loop-save').classList.add('hidden'); +} + +export async function deleteSelectedLoop() { + const sel = document.getElementById('saved-loops'); + const loopId = sel.value; + if (!loopId) return; + await fetch(`/api/loops/${loopId}`, { method: 'DELETE' }); + clearLoop(); + await loadSavedLoops(); +} diff --git a/tests/js/loop_api.test.js b/tests/js/loop_api.test.js index 6d5b10f..a12fe21 100644 --- a/tests/js/loop_api.test.js +++ b/tests/js/loop_api.test.js @@ -11,11 +11,16 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); +// The A-B loop was carved out of app.js into its own module (R3a). The +// window.feedBack API surface it is published through stayed in app.js. +const LOOPS_JS = path.join(__dirname, '..', '..', 'static', 'js', 'loops.js'); const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js'); -function extractFunction(src, signature) { +function extractFunction(rawSrc, signature) { + // loops.js is an ES module; the vm sandbox evaluates plain script text. + const src = rawSrc.replace(/^export /gm, ''); const start = src.indexOf(signature); - if (start === -1) throw new Error(`extractFunction: '${signature}' not found in app.js`); + if (start === -1) throw new Error(`extractFunction: '${signature}' not found in static/js/loops.js`); let scan = start + signature.length; if (src[scan] === '(') { let parenDepth = 1; @@ -89,6 +94,7 @@ function buildSandbox() { // updateLoopUI references formatTime for the label; we don't // assert on the label text in these tests, so a stub is enough. formatTime: (s) => String(s), + _updateEditRegionBtn: () => {}, window: { feedBack: { playback: { @@ -97,6 +103,19 @@ function buildSandbox() { }, }, }; + // The loop module reaches back into app.js through the host seam + // (static/js/host.js), so the extracted bodies call host._audioSeek(), + // host._audioTime(), and so on. Point the seam at the SAME spies the sandbox + // already had: the assertions below are unchanged, they just travel through the + // indirection the real code now uses. + sandbox.host = { + _audioSeek: (...a) => sandbox._audioSeek(...a), + _audioTime: () => sandbox._audioTime(), + formatTime: (...a) => sandbox.formatTime(...a), + _updateEditRegionBtn: () => sandbox._updateEditRegionBtn(), + currentFilename: () => 'test-song.sloppak', + startCountIn: () => {}, + }; vm.createContext(sandbox); return sandbox; } @@ -129,7 +148,7 @@ function loadFunctions(sandbox, src) { } test('setLoop mutates loopA/loopB and seeks to A', async () => { - const src = fs.readFileSync(APP_JS, 'utf8'); + const src = fs.readFileSync(LOOPS_JS, 'utf8'); const sandbox = buildSandbox(); loadFunctions(sandbox, src); @@ -145,7 +164,7 @@ test('setLoop mutates loopA/loopB and seeks to A', async () => { test('setLoop returns false and leaves loopA/loopB untouched on cancelled seek', async () => { // Plugin-facing contract: cancelled seek (teardown gen bump) returns // false; the loop is NOT armed. - const src = fs.readFileSync(APP_JS, 'utf8'); + const src = fs.readFileSync(LOOPS_JS, 'utf8'); const sandbox = buildSandbox(); sandbox._audioSeek = () => Promise.resolve({ completed: false, from: NaN, to: NaN }); loadFunctions(sandbox, src); @@ -162,7 +181,7 @@ test('setLoop returns false and leaves loopA/loopB untouched on cancelled seek', test('setLoop returns false and leaves loopA/loopB untouched on off-target landing', async () => { // JUCE rollback / HTML5 clamp: completed:true but to drifts > 50ms // from the requested a. The loop is NOT armed. - const src = fs.readFileSync(APP_JS, 'utf8'); + const src = fs.readFileSync(LOOPS_JS, 'utf8'); const sandbox = buildSandbox(); sandbox._audioSeek = (s) => Promise.resolve({ completed: true, from: 0, to: s + 0.5 }); loadFunctions(sandbox, src); @@ -180,7 +199,7 @@ test('setLoop coerces string inputs (parseFloat-style)', async () => { // loadSavedLoop passes parseFloat(dataset.start) — but the dataset // values may already be strings. Number() coercion in setLoop must // accept finite numeric strings. - const src = fs.readFileSync(APP_JS, 'utf8'); + const src = fs.readFileSync(LOOPS_JS, 'utf8'); const sandbox = buildSandbox(); loadFunctions(sandbox, src); @@ -191,7 +210,7 @@ test('setLoop coerces string inputs (parseFloat-style)', async () => { }); test('setLoop rejects non-finite inputs', async () => { - const src = fs.readFileSync(APP_JS, 'utf8'); + const src = fs.readFileSync(LOOPS_JS, 'utf8'); const sandbox = buildSandbox(); loadFunctions(sandbox, src); @@ -201,7 +220,7 @@ test('setLoop rejects non-finite inputs', async () => { }); test('setLoop rejects b <= a', async () => { - const src = fs.readFileSync(APP_JS, 'utf8'); + const src = fs.readFileSync(LOOPS_JS, 'utf8'); const sandbox = buildSandbox(); loadFunctions(sandbox, src); @@ -210,7 +229,7 @@ test('setLoop rejects b <= a', async () => { }); test('clearLoop resets loopA/loopB to null (and asks section-practice to drop its selection)', async () => { - const src = fs.readFileSync(APP_JS, 'utf8'); + const src = fs.readFileSync(LOOPS_JS, 'utf8'); const sandbox = buildSandbox(); loadFunctions(sandbox, src); @@ -231,7 +250,7 @@ test('clearLoop resets loopA/loopB to null (and asks section-practice to drop it }); test('loop helpers emit transport snapshots by default and can suppress adapter echoes', async () => { - const src = fs.readFileSync(APP_JS, 'utf8'); + const src = fs.readFileSync(LOOPS_JS, 'utf8'); const sandbox = buildSandbox(); loadFunctions(sandbox, src); @@ -269,7 +288,7 @@ test('loadSavedLoop funnels through setLoop (no duplicated UI mutation)', () => // re-implementing the loopA/loopB assignment. Catches a future drift // where someone "fixes" loadSavedLoop and forgets to keep setLoop in // sync. - const src = fs.readFileSync(APP_JS, 'utf8'); + const src = fs.readFileSync(LOOPS_JS, 'utf8'); const fn = extractFunction(src, 'async function loadSavedLoop('); assert.match(fn, /await\s+setLoop\(/, 'loadSavedLoop must call setLoop'); // The pre-refactor body assigned loopA = parseFloat(...) directly;