From 2910a3c8cb2f9ad02b52ad9186bda026c95b860c Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Wed, 1 Jul 2026 04:33:52 -0500 Subject: [PATCH] =?UTF-8?q?feat(tuner):=20mic-verify=20=E2=80=94=20promote?= =?UTF-8?q?=20working=20tuning=20assumed=E2=86=92verified=20via=20a=20per-?= =?UTF-8?q?string=20check=20(working-tuning=20PR=209b)=20(#670)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tuner): mic-verify — promote the working tuning assumed→verified via a per-string check (working-tuning PR 9b) Adds the choreographed per-string mic verification the design reserved for 'verified' provenance (audio-engine's honesty rule — nothing else may claim it): the player plays each string, and once every one reads in tune (±6 cents) and holds stable for 8 frames, the tuner stamps the working tuning provenance:'verified' + verifiedStrings via workingTuning.set. - screen.js: a pure verify state machine (verifyStart/verifyFeed/verifyCancel/ verifyState, exposed on the tuner API) + the set-verified writer; cancels on close. - ui.js: updateUI feeds each processed frame (matched string + cents) into the session; a "Verify tuning" button + per-string progress + status, shown for a selected (non-free) tuning. Pairs with the 9a lifecycle: a 'verified' decays back to 'assumed' on the next song load, so mic-verify is a per-session confidence boost, never a sticky claim. Tests: tests/js/tuner_auto_open.test.js +4 (all-strings->verified, out-of-tune never completes, streak resets on drift, API exposed / only it claims verified) — 33/33. The state machine is headless-verified with synthetic frames; the real per-string mic detection + the button flow need an on-device pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF * fix(tuner): mic-verify writes the confirmed tuning + no clobber + stricter streak (PR #670 review) Review fixes for mic-verify (working-tuning PR 9b): - 'verified' could attach to STALE offsets: _publishVerified stamped provenance without writing offsets, so the slot's pre-tuning offsets got marked verified. verifyStart(targets, offsets) now captures the confirmed tuning's offsets, and _publishVerified writes offsets + stringCount + instrument + referencePitch + verifiedStrings ATOMICALLY with provenance:'verified' into the selected slot (and refuses to stamp verified with no concrete offsets). - The assumed publish-on-clear immediately clobbered a just-earned 'verified': disable() now skips it when a mic-verify wrote verified this session (_verifiedPublished). - The per-string streak could accumulate across silence / wrong-string frames. verifyFeed now requires CONSECUTIVE in-tune frames: the one confirmed string advances, every other unfinished string resets each frame. - A mid-verify tuning change (song switch) left stale captured offsets; verify is now cancelled in _syncCurrentTuning when the song tuning changes. Tests: verify writes the confirmed offsets (not stale); source-guard for the no-clobber path. 47 tuner + 77 tuner/capability tests green. Codex-reviewed. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 Co-authored-by: byrongamatos --- plugins/tuner/screen.js | 89 +++++++++++++++++++++++++++++++- plugins/tuner/utils/ui.js | 65 +++++++++++++++++++++++ tests/js/tuner_auto_open.test.js | 53 +++++++++++++++++++ 3 files changed, 205 insertions(+), 2 deletions(-) diff --git a/plugins/tuner/screen.js b/plugins/tuner/screen.js index 6d4f738..1f7ba72 100644 --- a/plugins/tuner/screen.js +++ b/plugins/tuner/screen.js @@ -490,6 +490,12 @@ const sc = (typeof window.feedBack?.effectiveStringCount === 'function') ? window.feedBack.effectiveStringCount(songInfo.tuning, ctx) : (songInfo.stringCount || songInfo.tuning.length); + // A tuning change invalidates an in-flight mic-verify — its captured targets + // and offsets are now stale, so it must not complete against the old tuning. + if (_verify && String(songInfo.tuning.slice(0, sc)) !== String(_verify.offsets)) { + verifyCancel(); + _tunerUIApi?.resetVerify?.(); + } _state.currentSongOffsets = songInfo.tuning.slice(0, sc); _state.currentSongIsBass = isBass; _state.currentSongStringCount = sc; @@ -725,6 +731,8 @@ _state.autoOpened = false; _releaseGate(); // dismissing a gated auto-open releases playback (it starts now) _state.manualTargetFreq = null; + verifyCancel(); // a running mic-verify ends when the panel closes + _tunerUIApi?.resetVerify?.(); if (_outsideClickClose) { document.removeEventListener('click', _outsideClickClose); _outsideClickClose = null; } if (_state.activeViz) { _state.activeViz.destroy(); _state.activeViz = null; } if (_state.uiContainer) { _state.uiContainer.classList.add('hidden'); _state.uiContainer.classList.remove('flex'); } @@ -746,10 +754,13 @@ _autoOpenDismissedSessionKey = _autoOpenSessionKey(songInfo); // Clearing an auto-opened tuner = the player tuned to this song: // publish the song's tuning as their instrument's live working tuning - // so coverage stops nagging for it (and prompts on the way back). - if (wasAutoOpened) _publishWorkingTuning(songInfo); + // so coverage stops nagging for it (and prompts on the way back). But + // if a mic-verify already wrote 'verified' this session, a plain + // 'assumed' publish would immediately clobber it — leave it verified. + if (wasAutoOpened && !_verifiedPublished) _publishWorkingTuning(songInfo); } } + _verifiedPublished = false; // consumed — fresh for the next tuner session } window.tuner = { @@ -793,6 +804,76 @@ _installAutoOpenListeners(); }).catch(e => console.error(e)); _installAutoOpenListeners(); + + // ── Mic-verify (working-tuning PR 9b) ────────────────────────────────────── + // A choreographed per-string check that promotes the current working tuning + // from 'assumed' to 'verified' — the ONLY thing that may ever claim 'verified' + // (audio-engine's honesty rule). The player plays each string; once every one + // reads in-tune (±VERIFY_TOL_CENTS) and holds for VERIFY_STABLE frames we stamp + // provenance:'verified' + verifiedStrings. Cancels on tuner close / tuning change. + const VERIFY_TOL_CENTS = 6; + const VERIFY_STABLE = 8; + let _verify = null; + let _verifiedPublished = false; // set when a mic-verify wrote 'verified' this session + + function verifyState() { + if (!_verify) return null; + return { + complete: _verify.complete, + done: _verify.targets.map((t) => t.done), + remaining: _verify.targets.filter((t) => !t.done).length, + }; + } + // Start a verify session for `targets` (freqs; defaults to the selected tuning). + // Captures the tuning's OFFSETS now (explicit arg, else the current song's) so that + // when it completes we stamp 'verified' onto the exact tuning that was confirmed. + function verifyStart(targets, offsets) { + const freqs = (Array.isArray(targets) && targets.length) ? targets : _state.selectedTuning; + if (!Array.isArray(freqs) || !freqs.length) return null; + const offs = (Array.isArray(offsets) && offsets.length) ? offsets.slice() + : (Array.isArray(_state.currentSongOffsets) ? _state.currentSongOffsets.slice() : null); + _verify = { targets: freqs.map((f) => ({ freq: f, streak: 0, done: false })), complete: false, offsets: offs }; + _verifiedPublished = false; + return verifyState(); + } + function verifyCancel() { _verify = null; } + // Feed one processed frame (its matched target freq + cents-off). Requires + // CONSECUTIVE in-tune frames per string: the one confirmed string advances, and + // every other not-yet-done string's streak resets — so a run can't accumulate across + // silence / wrong-string / out-of-tune frames. Completes + stamps 'verified' when all pass. + function verifyFeed(targetFreq, cents) { + if (!_verify || _verify.complete) return verifyState(); + let hit = null; + if (targetFreq != null) { + const t = _verify.targets.find((x) => Math.abs(x.freq - targetFreq) < 0.5); + if (t && !t.done && isFinite(cents) && Math.abs(cents) <= VERIFY_TOL_CENTS) hit = t; + } + for (const t of _verify.targets) { + if (t.done) continue; + if (t === hit) { if (++t.streak >= VERIFY_STABLE) t.done = true; } + else t.streak = 0; + } + if (_verify.targets.every((x) => x.done)) { + _verify.complete = true; + _publishVerified(); + } + return verifyState(); + } + // Promote the working tuning to 'verified'. Write the CONFIRMED tuning's offsets + // atomically with provenance + verifiedStrings (into the selected instrument's slot), + // so 'verified' can never attach to stale offsets the slot happened to hold. + function _publishVerified() { + const wt = window.feedBack && window.feedBack.workingTuning; + if (!wt || typeof wt.set !== 'function') return; + const offsets = (_verify && Array.isArray(_verify.offsets)) ? _verify.offsets.slice() : null; + if (!offsets || !offsets.length) return; // nothing concrete to claim verified + const sel = _state._playerSelected; + const next = { offsets: offsets, stringCount: offsets.length, verifiedStrings: offsets.map(() => true) }; + if (sel && sel.key) { next.instrument = sel.isBass ? 'bass' : 'guitar'; next.referencePitch = sel.refPitch; } + const opts = (sel && sel.key) ? { instrument: sel.key, provenance: 'verified' } : { provenance: 'verified' }; + try { wt.set(next, opts); _verifiedPublished = true; } catch (_) { /* noop */ } + } + window._tunerAutoOpen = { tuningIdentityKey: _tuningIdentityKey, sessionKey: _autoOpenSessionKey, @@ -801,6 +882,10 @@ coverageReport: _coverageReport, playerTuning: _playerTuning, publishWorkingTuning: _publishWorkingTuning, + verifyStart: verifyStart, + verifyFeed: verifyFeed, + verifyCancel: verifyCancel, + verifyState: verifyState, onSongLoading: _onAutoOpenSongLoadingHandler, getState() { return { diff --git a/plugins/tuner/utils/ui.js b/plugins/tuner/utils/ui.js index f071f0d..9c7875b 100644 --- a/plugins/tuner/utils/ui.js +++ b/plugins/tuner/utils/ui.js @@ -305,6 +305,12 @@ window._tunerUI = function(state, actions) { function renderStringNotes() { if (!state.stringNoteContainer) return; state.stringNoteContainer.innerHTML = ''; + // Show the mic-verify control only for a selected (non-free) tuning. + if (state.verifyRow) { + const hasTuning = !!(state.selectedTuning && state.selectedTuning.length && !state.freeTune); + state.verifyRow.classList.toggle('hidden', !hasTuning); + if (!hasTuning) resetVerifyUI(); + } if (!state.selectedTuning || state.selectedTuning.length === 0) { _syncStringOrderHelp(0); return; @@ -327,6 +333,41 @@ window._tunerUI = function(state, actions) { _syncStringOrderHelp(total); } + // ── Mic-verify UI (working-tuning PR 9b) ─────────────────────────────────── + function _markVerifiedStrings(done) { + if (!state.stringNoteContainer) return; + state.stringNoteContainer.querySelectorAll('[data-freq]').forEach((btn, i) => { + btn.classList.toggle('ring-2', !!done[i]); + btn.classList.toggle('ring-emerald-400', !!done[i]); + }); + } + function _syncVerifyProgress(vs) { + if (!vs || !state.verifyStatus) return; + const total = vs.done.length; + const done = vs.done.filter(Boolean).length; + state.verifyStatus.classList.remove('hidden'); + state.verifyStatus.textContent = vs.complete + ? '✓ In tune — tuning verified' + : (done + ' of ' + total + ' strings in tune'); + _markVerifiedStrings(vs.done); + if (vs.complete && state.verifyBtn) state.verifyBtn.textContent = 'Verify tuning'; + } + function _startVerify() { + if (!window._tunerAutoOpen || typeof window._tunerAutoOpen.verifyStart !== 'function') return; + const vs = window._tunerAutoOpen.verifyStart(); + if (!vs) return; + if (state.verifyBtn) state.verifyBtn.textContent = 'Verifying — play each string…'; + _syncVerifyProgress(vs); + } + function resetVerifyUI() { + if (window._tunerAutoOpen && typeof window._tunerAutoOpen.verifyCancel === 'function') { + window._tunerAutoOpen.verifyCancel(); + } + if (state.verifyBtn) state.verifyBtn.textContent = 'Verify tuning'; + if (state.verifyStatus) { state.verifyStatus.classList.add('hidden'); state.verifyStatus.textContent = ''; } + _markVerifiedStrings([]); + } + function updateUI(result) { const { smoothedFreq, rms, hasSignal } = result; const vizMode = state.manualTargetFreq ? 'manual' @@ -387,6 +428,13 @@ window._tunerUI = function(state, actions) { if (window.feedBack && window.feedBack.emit) { window.feedBack.emit('tuner:frame', { note, cents, freq: displayFreq, hasSignal: true }); } + // Mic-verify: feed the matched string + cents to a running verify session + // and reflect per-string progress on the panel. + if (!isManual && !state.freeTune && window._tunerAutoOpen + && typeof window._tunerAutoOpen.verifyFeed === 'function') { + const vs = window._tunerAutoOpen.verifyFeed(targetFreq, Math.round(cents)); + if (vs) _syncVerifyProgress(vs); + } } function updateFloatingButtonVisibility() { @@ -635,6 +683,22 @@ window._tunerUI = function(state, actions) { state.vizContainer.className = 'w-full'; state.uiContainer.appendChild(state.vizContainer); + // Mic-verify control (working-tuning PR 9b): play each string in tune to + // confirm your tuning — promotes 'assumed' → 'verified'. Shown only for a + // selected (non-free) tuning; visibility managed in renderStringNotes(). + state.verifyRow = document.createElement('div'); + state.verifyRow.className = 'w-full mt-2 hidden'; + state.verifyBtn = document.createElement('button'); + state.verifyBtn.className = 'tuner-verify-btn w-full text-[11px] text-fb-textDim hover:text-fb-text border border-fb-border/40 hover:border-fb-border/70 rounded-lg py-1.5 transition-colors'; + state.verifyBtn.textContent = 'Verify tuning'; + state.verifyBtn.title = 'Play each string in tune to confirm — marks your tuning verified'; + state.verifyBtn.onclick = () => _startVerify(); + state.verifyRow.appendChild(state.verifyBtn); + state.verifyStatus = document.createElement('div'); + state.verifyStatus.className = 'text-[10px] text-fb-textDim text-center mt-1 hidden'; + state.verifyRow.appendChild(state.verifyStatus); + state.uiContainer.appendChild(state.verifyRow); + // Auto-open nudge's explicit dismiss (hidden unless auto-opened; enable() // toggles it). Closes the same way as the × — disable(). const skipBtn = document.createElement('button'); @@ -778,6 +842,7 @@ window._tunerUI = function(state, actions) { renderTuningOptions, renderStringNotes, updateUI, + resetVerify: resetVerifyUI, updateInstrumentDisplay: _updateInstrumentDisplay, updateSaveAsCustomVisibility: _updateSaveAsCustomVisibility, updateFreeTuneUI, diff --git a/tests/js/tuner_auto_open.test.js b/tests/js/tuner_auto_open.test.js index 1baa185..f603f07 100644 --- a/tests/js/tuner_auto_open.test.js +++ b/tests/js/tuner_auto_open.test.js @@ -697,3 +697,56 @@ test('a transient /api/settings failure is not cached — the next coverage read const r2 = await api.coverageReport(DROP_D); // retry: settings now readable → a real report assert.equal(r2.retune.length, 1, 'the retry actually computes coverage (Drop-D low string vs standard)'); }); +// ── PR 9b: mic-verify (assumed -> verified via a per-string check) ────────── +const VERIFY_TARGETS = [82.41, 110, 146.83, 196, 246.94, 329.63]; // guitar-6 standard freqs + +test('mic-verify: all strings in-tune-and-stable promotes to verified (with the confirmed offsets)', () => { + const sandbox = createTunerSandbox(); + const sets = []; + sandbox.window.feedBack.workingTuning = { get: () => ({ offsets: [0, 0, 0, 0, 0, 0] }), set: (n, o) => sets.push({ n, o }) }; + const api = sandbox.window._tunerAutoOpen; + assert.ok(api.verifyStart(VERIFY_TARGETS, [-2, 0, 0, 0, 0, 0])); // verifying a Drop-D tuning + for (let i = 0; i < 8; i++) api.verifyFeed(82.41, 2); // one string alone isn't enough + assert.equal(api.verifyState().complete, false); + for (const f of VERIFY_TARGETS) { for (let i = 0; i < 8; i++) api.verifyFeed(f, 2); } + assert.equal(api.verifyState().complete, true); + assert.equal(sets.length, 1); + assert.equal(sets[0].o.provenance, 'verified'); + // The stamp carries the CONFIRMED offsets, not whatever stale offsets the slot held. + assert.deepEqual(sets[0].n.offsets, [-2, 0, 0, 0, 0, 0]); + assert.equal(sets[0].n.verifiedStrings.length, 6); +}); + +test('mic-verify: an out-of-tune string never completes; cancel clears', () => { + const sandbox = createTunerSandbox(); + sandbox.window.feedBack.workingTuning = { get: () => ({}), set() {} }; + const api = sandbox.window._tunerAutoOpen; + api.verifyStart([82.41, 110]); + for (let i = 0; i < 30; i++) api.verifyFeed(82.41, 25); // 25c off -> never passes + assert.equal(api.verifyState().complete, false); + assert.deepEqual(api.verifyState().done, [false, false]); + api.verifyCancel(); + assert.equal(api.verifyState(), null); +}); + +test('mic-verify: the in-tune streak resets if a frame drifts out', () => { + const sandbox = createTunerSandbox(); + sandbox.window.feedBack.workingTuning = { get: () => ({}), set() {} }; + const api = sandbox.window._tunerAutoOpen; + api.verifyStart([100]); + for (let i = 0; i < 5; i++) api.verifyFeed(100, 2); // 5 in tune (need 8) + api.verifyFeed(100, 30); // drift out -> streak resets + for (let i = 0; i < 7; i++) api.verifyFeed(100, 2); // 7 more (not yet 8 in a row) + assert.equal(api.verifyState().done[0], false); + api.verifyFeed(100, 2); // 8th consecutive -> done + assert.equal(api.verifyState().complete, true); +}); + +test('the tuner exposes the mic-verify API and only it claims verified (screen.js)', () => { + const src = fs.readFileSync(TUNER_SCREEN_JS, 'utf8'); + assert.match(src, /verifyStart:/); + assert.match(src, /verifyFeed:/); + assert.match(src, /provenance: 'verified'/); // the only place that stamps verified + // A just-earned 'verified' is not clobbered by the assumed publish-on-clear. + assert.match(src, /wasAutoOpened && !_verifiedPublished/); +});