fix(tuner): mic-verify stamps the tuning it actually checked + on-device test plan (#684)

Working-tuning follow-ups:

- Mic-verify used _state.currentSongOffsets for the 'verified' stamp, but for a
  MANUALLY-selected tuning (tuner opened off a song) that's a stale/different
  song's tuning — so verify could mark the WRONG tuning verified. It now derives
  the verified offsets from the tuning actually being checked (its target freqs;
  the player's reference pitch cancels in the ratio), so 'verified' always
  attaches to the tuning the player confirmed. Explicit offsets still win.

- Adds docs/working-tuning-on-device-tests.md: the checklist for the parts that
  can't be covered headlessly — the auto-open/gate flow, both-directions prompts,
  mic-verify detection, and the tuner-mic-vs-note_detect ASIO/exclusive-mode
  contention flagged in the design charrette.

Test: mic-verify with no explicit offsets / no song context derives the correct
offsets (Drop-D). 55 tuner tests green.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos 2026-07-01 11:45:26 +02:00 committed by GitHub
parent 2910a3c8cb
commit 96a84996a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 92 additions and 2 deletions

View File

@ -0,0 +1,53 @@
# Working-tuning — on-device test checklist
The working-tuning series (PRs 19) ships with headless unit tests for every state
machine (`tests/js/working_tuning*.test.js`, `tests/js/tuner_auto_open.test.js`). The
items below are the parts that **cannot** be covered headlessly — they need a real mic,
a real instrument, and (for the ASIO item) a specific audio backend. Run these on a
build before shipping the feature to users.
Prereq: enable the opt-in in **Tuner → settings → "Auto-open on tuning change"** (it's
off by default). Have a guitar (and a bass, for the per-instrument checks) on hand.
## 1. Auto-open + gate ("tune before you play")
- [ ] Load a song whose tuning differs from your instrument's current tuning → the tuner
**auto-opens** and playback **waits** (does not start underneath it).
- [ ] Load a song already covered by your tuning → **no** auto-open, playback starts.
- [ ] **Skip** ("I've tuned") → playback starts, and the tuner badge stops flagging this
song's tuning (a working tuning was recorded).
- [ ] **Back to library** / **Esc** → leaves the song, records **nothing** (re-enter the
same song → it still prompts).
- [ ] Take **longer than 12 s** to tune with the panel open → playback does **not** start
underneath you (the fail-open backstop was settled once the panel opened).
- [ ] Hit **Play** manually while the panel is open → Play wins; no double-start.
## 2. Both-directions retune prompt
- [ ] From standard, load a Drop-C# song → prompted **down** (E→C#). Tune down, Skip.
- [ ] Now load a standard song → prompted **back up** (C#→E). (Pre-series, this direction
was silent.)
- [ ] Switch guitar↔bass in the instrument card → each instrument remembers its **own**
working tuning; the card label follows the selection (dim = home, amber = retuned).
## 3. Mic-verify (assumed → verified)
- [ ] With a selected (non-free) tuning, tap **Verify tuning** and play each string in tune.
Each string needs ~8 stable in-tune frames (±6 ¢); the per-string progress advances.
- [ ] Play a string **out of tune** → it never completes; drifting out mid-streak resets it.
- [ ] Complete all strings → the instrument card's provenance glyph flips to the **filled**
(verified) diamond, and the recorded working tuning carries the tuning you verified
(not a stale one).
- [ ] Load the **next** song → the verified state **decays to assumed** (per-session only).
- [ ] Verify against a **manually-selected** tuning (tuner opened off a song) → the stamped
offsets match that tuning, not the last song's.
## 4. Mic contention with note-detection (the ASIO / exclusive-mode risk)
This is the item flagged in the design charrette: the tuner's mic capture must not starve
note_detect's scoring input.
- [ ] Desktop, **ASIO / WASAPI-exclusive** device: auto-open the tuner mid-song, tune, Skip
→ scoring resumes cleanly; no dropped input, no device-in-use error, no crash.
- [ ] Shared/`auto` device: same flow → both the tuner and scoring read the mic without a
stall.
- [ ] Leave the tuner's background badge audio running + start a scored song → note_detect
still scores (the badge auto-start doesn't hold the device exclusively).
Log the build hash and OS/audio backend with results; file any failure against the
working-tuning series.

View File

@ -827,11 +827,33 @@
// 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.
// Per-string semitone offsets (from standard) of the tuning we're verifying, derived
// from its target freqs. This is authoritative for BOTH the song ('_current') tuning
// and a manually-selected tuning — unlike _state.currentSongOffsets, which for a manual
// tuning is a stale/different song's tuning that must NOT be stamped 'verified'. The
// player's reference pitch scales both the targets and the standard, so it cancels.
function _tuningOffsetsFromFreqs(freqs) {
const u = window._tunerUtils;
if (!u || typeof u.offsetsToFreqs !== 'function' || typeof u.freqToMidi !== 'function') return null;
const isBass = /^bass/.test(_state.selectedInstrument || ''); // the selected instrument, not the song's
const refScale = (Number(_state.referencePitch) || 440) / 440;
const std = u.offsetsToFreqs(new Array(freqs.length).fill(0), isBass);
if (!Array.isArray(std) || std.length !== freqs.length) return null;
const out = [];
for (let i = 0; i < freqs.length; i++) {
const f = Number(freqs[i]);
const s = Number(std[i]) * refScale;
if (!(f > 0) || !(s > 0)) return null;
out.push(Math.round(u.freqToMidi(f) - u.freqToMidi(s)) || 0); // normalize -0 → 0
}
return out;
}
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);
// Explicit offsets win (callers/tests that already have them); otherwise derive
// them from the tuning actually being verified.
const offs = (Array.isArray(offsets) && offsets.length) ? offsets.slice() : _tuningOffsetsFromFreqs(freqs);
_verify = { targets: freqs.map((f) => ({ freq: f, streak: 0, done: false })), complete: false, offsets: offs };
_verifiedPublished = false;
return verifyState();

View File

@ -717,6 +717,21 @@ test('mic-verify: all strings in-tune-and-stable promotes to verified (with the
assert.equal(sets[0].n.verifiedStrings.length, 6);
});
test('mic-verify: derives the verified offsets from the tuning being checked (no song context)', () => {
// A manually-selected tuning (no '_current' song) must still stamp the RIGHT offsets,
// derived from the freqs being verified — not a stale currentSongOffsets.
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;
const DROP_D_FREQS = [73.42, 110, 146.83, 196, 246.94, 329.63]; // drop-D guitar
assert.ok(api.verifyStart(DROP_D_FREQS)); // NO explicit offsets, no song context
for (const f of DROP_D_FREQS) { for (let i = 0; i < 8; i++) api.verifyFeed(f, 2); }
assert.equal(api.verifyState().complete, true);
assert.equal(sets.length, 1);
assert.deepEqual(Array.from(sets[0].n.offsets), [-2, 0, 0, 0, 0, 0]); // derived Drop D
});
test('mic-verify: an out-of-tune string never completes; cancel clears', () => {
const sandbox = createTunerSandbox();
sandbox.window.feedBack.workingTuning = { get: () => ({}), set() {} };