feat(tuner): mic-verify — promote working tuning assumed→verified via a per-string check (working-tuning PR 9b) (#670)

* 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 <noreply@anthropic.com>
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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-07-01 11:33:52 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent 115c96a3f0
commit 2910a3c8cb
3 changed files with 205 additions and 2 deletions
+87 -2
View File
@@ -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 {
+65
View File
@@ -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,