mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
refactor(app): carve the A–B loop out of app.js (R3a) (#888)
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
64f04565e2
commit
cb236e6c04
+30
-227
@@ -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 = '<option value="">Saved Loops</option>';
|
||||
for (const l of loops) {
|
||||
sel.innerHTML += `<option value="${l.id}" data-start="${l.start}" data-end="${l.end}">${esc(l.name)} (${formatTime(l.start)}→${formatTime(l.end)})</option>`;
|
||||
}
|
||||
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, {
|
||||
|
||||
Reference in New Issue
Block a user