mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
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:
co-authored by
Claude Opus 4.8
byrongamatos
parent
115c96a3f0
commit
2910a3c8cb
+87
-2
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user