diff --git a/plugins/input_setup/screen.js b/plugins/input_setup/screen.js index cf01a74..6d39806 100644 --- a/plugins/input_setup/screen.js +++ b/plugins/input_setup/screen.js @@ -134,16 +134,25 @@ const opts2 = sources.map((s) => '').join(''); const hasDetector = !!(window.noteDetect && typeof window.noteDetect.launchCalibration === 'function'); + const hasVocalCal = inst === 'vocals' && !!(window.feedBack && window.feedBack.vocalCalibration && + window.feedBack.vocalCalibration.version === 1 && + typeof window.feedBack.vocalCalibration.launch === 'function'); + // For vocals: "Calibrate" iff vocal-cal facade present; "Continue" otherwise. + // For guitar/bass: "Calibrate" iff noteDetect present; "Continue" otherwise. + const canCalibrate = inst === 'vocals' ? hasVocalCal : hasDetector; + const notLoadedNotice = inst === 'vocals' + ? (hasVocalCal ? '' : '

Vocal calibration isn’t available yet — you can set it up later from the player.

') + : (hasDetector ? '' : '

The note detector isn’t loaded here — you can calibrate later from the player.

'); const body = '

Pick your audio input, then run the calibration to set levels, channel and latency.

' + (sources.length ? '' + '' : '

No audio input detected yet — plug in your interface, or skip and set this up later.

') + - (hasDetector ? '' : '

The note detector isn’t loaded here — you can calibrate later from the player.

'); + notLoadedNotice; const foot = ''; + (canCalibrate ? 'Calibrate' : 'Continue') + ''; shell(inst, body, foot); const sel = host.querySelector('[data-is-audio]'); @@ -163,7 +172,9 @@ // Tell the tuner tables / note_detect which instrument this is. try { fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ instrument: inst }) }); } catch (_) {} - host.querySelector('[data-is-cal]').addEventListener('click', () => { + const calBtn = host.querySelector('[data-is-cal]'); + let _advancing = false; // double-click guard for the auto-advance fallback + calBtn.addEventListener('click', () => { if (inst === 'vocals') { // Vocals calibration is handled by the vocal-highway plugin's // facade. Guard: if the facade is absent (plugin disabled or @@ -184,6 +195,9 @@ }); } else { // ponytail: vocal-highway not loaded — mark done so wizard doesn't hang + if (_advancing) return; // double-click guard: one advance per panel + _advancing = true; + calBtn.disabled = true; const body = host.querySelector('[data-is-body]'); if (body) body.innerHTML = '

Vocal calibration will be available once the Vocal Highway plugin is enabled. Continuing…

'; setTimeout(() => advance(inst, true), 1800); @@ -377,7 +391,7 @@ // Settings-panel re-entry (settings.html "Set up input devices" button). // Re-runs the wizard for the player's selected instrument paths, falling - // back to all instruments when progression isn't available. + // back to all instruments when progression isn’t available. window._inputSetupRelaunch = async function () { let instruments = []; try { @@ -388,7 +402,7 @@ instruments = paths.map((p) => (typeof p === 'string' ? p : (p && p.id))).filter(Boolean); } } catch (_) { /* offline — fall back below */ } - if (!instruments.length) instruments = ['guitar', 'bass', 'keys', 'drums']; + if (!instruments.length) instruments = ['guitar', 'bass', 'vocals', 'keys', 'drums']; launch(instruments); }; })(); diff --git a/tests/js/vocal_calibration_handoff.test.js b/tests/js/vocal_calibration_handoff.test.js index 5aa7294..4d803e2 100644 --- a/tests/js/vocal_calibration_handoff.test.js +++ b/tests/js/vocal_calibration_handoff.test.js @@ -199,6 +199,44 @@ test('renderAudioPanel for vocals falls back gracefully when vocalCalibration is assert.equal(result.completed[0], 'vocals', 'completed[0] must be vocals'); }); +// ── 3b. double-click guard: second click must NOT queue a second advance ─────── +// Failure input: vocalCalibration absent, instruments ['vocals','guitar'], +// two clicks on Calibrate before the 1800ms timer fires. +// Without guard: two timers queued → second fires on guitar panel → idx +// increments past guitar → finish() prematurely → guitar silently dropped. + +test('double-click on fallback Calibrate does not double-advance the wizard', async () => { + const timers = []; + const w = loadScreenJs({ + feedBack: { capabilities: null, midiInput: null, vocalCalibration: undefined }, + setTimeout(fn, _ms) { timers.push(fn); }, + }); + + // Two-instrument queue: vocals then guitar (guitar has noteDetect absent too, + // so it would also auto-advance — but we only care about the vocals panel here). + const hostEl = makeEl('div', {}); + // Mount with ['vocals'] only so we can isolate the guard without needing a + // full multi-panel render (guitar panel is MIDI-unrelated complexity). + // The guard must prevent a second timer from being queued at all. + const mountPromise = w.feedBackInputSetup.mount(hostEl, { instruments: ['vocals'] }); + await new Promise((r) => setImmediate(r)); + + const calBtn = hostEl._namedChildren && hostEl._namedChildren['is-cal']; + assert.ok(calBtn, 'button must exist'); + + // Two rapid clicks. + calBtn.click(); + calBtn.click(); + + // Only ONE timer must have been queued (button disabled after first click). + assert.equal(timers.length, 1, 'double-click must only queue one advance timer — input: two clicks before timer fires'); + + timers[0](); + const result = await mountPromise; + assert.equal(result.completed.length, 1); + assert.equal(result.completed[0], 'vocals'); +}); + // ── 4. vocalCalibration facade present → launch() called, not noteDetect ────── test('renderAudioPanel for vocals calls vocalCalibration.launch when facade present', async () => { @@ -241,3 +279,66 @@ test('renderAudioPanel for vocals calls vocalCalibration.launch when facade pres assert.equal(result.completed.length, 1); assert.equal(result.completed[0], 'vocals'); }); + +// ── 5. _inputSetupRelaunch fallback includes 'vocals' (F2) ─────────────────── +// Failure input: window._inputSetupRelaunch called when fetch('/api/progression') +// rejects — the fallback instrument list must include 'vocals' or Settings +// re-calibration silently skips it. + +test('_inputSetupRelaunch fallback list includes vocals when API call fails', () => { + let launchedWith = null; + const w = loadScreenJs({ + feedBack: { capabilities: null, midiInput: null, vocalCalibration: undefined }, + // Reject the fetch to trigger the fallback path. + fetch() { return Promise.reject(new Error('network error')); }, + }); + // Patch launch() to capture the instruments without spinning up a full overlay. + w.feedBackInputSetup._captureNextLaunch = (list) => { launchedWith = list; }; + // We can't easily intercept the internal `launch()` from outside the IIFE. + // Instead, verify the status API: after _inputSetupRelaunch is awaited, + // the fallback list is ['guitar','bass','vocals','keys','drums'] by reading + // the source directly (static code check via the status call on each). + // The definitive check: call status() for 'vocals' — it must be in INSTRUMENTS. + const status = w.feedBackInputSetup.status(['vocals', 'guitar', 'bass', 'keys', 'drums']); + assert.ok('vocals' in status, 'vocals must be in the status map — confirming INSTRUMENTS includes it'); + // Structural read: _inputSetupRelaunch is a closure we cannot easily inspect, + // but the bug was the string literal ['guitar','bass','keys','drums']. + // Verify by reading the source file for the fixed literal. + const src = fs.readFileSync(SCREEN_JS, 'utf8'); + assert.ok( + src.includes("'guitar', 'bass', 'vocals', 'keys', 'drums'") || + src.includes("'guitar','bass','vocals','keys','drums'"), + "fallback list must include 'vocals' — input: /api/progression fetch failure" + ); +}); + +// ── 6. button label keys off vocalCalibration for vocals (F3) ───────────────── +// Failure input: vocalCalibration present but noteDetect absent. +// Without fix: label reads 'Continue' (keyed off hasDetector=false). +// With fix: label reads 'Calibrate' (keyed off hasVocalCal=true). + +test('Calibrate button shows Calibrate when vocalCalibration present and noteDetect absent', async () => { + const facade = { version: 1, launch(_args) {} }; + const w = loadScreenJs({ + feedBack: { capabilities: null, midiInput: null, vocalCalibration: facade }, + // noteDetect is absent — without F3 fix, label would be 'Continue' + }); + // noteDetect deliberately not set + + const hostEl = makeEl('div', {}); + w.feedBackInputSetup.mount(hostEl, { instruments: ['vocals'] }); + await new Promise((r) => setImmediate(r)); + + // The shell sets innerHTML; we check the captured HTML for the button label. + // The innerHTML of hostEl reflects the last shell() call. + const html = hostEl.innerHTML || ''; + assert.ok( + html.includes('Calibrate'), + 'button must read Calibrate when vocalCalibration present (even if noteDetect absent) — input: facade present, noteDetect absent' + ); + assert.equal( + html.includes('>Continue<'), + false, + 'must NOT read Continue when vocalCalibration is present' + ); +});