Tuner auto-open: instrument-coverage check (issue E, stage 2/3) (#656)

With the opt-in auto-open on, only prompt when the player's current
physical tuning doesn't already cover the song. FeedBack is tune-to-song
(the highway draws tab in the song's tuning), so the check aligns the
song's open-string tuning string-for-string against the player's
instrument:

- An 8-string F# player gets no prompt for a 6-/7-string standard song
  (its top strings already match those tunings).
- A song needing an open string the player lacks (e.g. a Drop-A
  7-string's low A on an F# 8-string) still prompts.
- A whole-instrument reference difference (A440 vs A432, or an
  octave-down centOffset, previously ignored) also prompts.

Reads the player's instrument from core /api/settings (the v3 instrument
selector, a stable physical reference); conservative fallback (prompt)
when undeclared or unavailable, so a real retune is never silently
skipped. v3-only. All in plugins/tuner/screen.js; no core changes.

Stacked on #655. Follow-up E1.6: a passive badge cue that names the
strings to retune, plus splitscreen / no-usable-input guards.

Tests: tests/js/tuner_auto_open.test.js (covered/uncovered, the Drop-A
case, reference mismatch, direct contiguous alignment).


Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ChrisBeWithYou
2026-07-01 10:25:57 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 6bfd92aa06
commit 6b0e37aa35
3 changed files with 172 additions and 7 deletions
+73 -7
View File
@@ -8,6 +8,7 @@ const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
const TUNER_SCREEN_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'screen.js');
const TUNING_UTILS_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'tuning-utils.js');
function loadTuningHelpers() {
const src = fs.readFileSync(APP_JS, 'utf8');
@@ -30,6 +31,10 @@ function createTunerSandbox(opts) {
// Auto-open is opt-in (default off in prod). The sandbox defaults it ON so the
// behaviour tests exercise the feature; pass { autoOpen: false } to gate it off.
const autoOpen = !opts || opts.autoOpen !== false;
// The player's physical instrument for the §4 coverage check (core /api/settings).
// Absent → the endpoint reports not-ok → coverage stays conservative (can't
// decide → don't suppress the prompt), preserving the pre-coverage behaviour.
const playerSettings = (opts && opts.player) || null;
const enableCalls = [];
let playerActive = true;
let songInfo = null;
@@ -41,7 +46,13 @@ function createTunerSandbox(opts) {
setTimeout(fn) { fn(); return 0; },
clearTimeout() {},
fetch(url) {
if (String(url).includes('/config')) {
const _u = String(url);
if (_u.includes('/api/settings')) {
return Promise.resolve(playerSettings
? { ok: true, json: () => Promise.resolve(playerSettings) }
: { ok: false, json: () => Promise.resolve({}) });
}
if (_u.includes('/config')) {
return Promise.resolve({
json: () => Promise.resolve({
showFloatingButton: true,
@@ -124,12 +135,8 @@ function createTunerSandbox(opts) {
sandbox.window.highway = {
getSongInfo: () => songInfo,
};
sandbox.window._tunerUtils = {
preferFlats: () => false,
offsetsToFreqs: (offsets) => offsets.map((o, i) => 80 + i * 10),
freqToMidi: () => 40,
midiToNote: () => 'E',
};
// _tunerUtils comes from the REAL tuning-utils.js (loaded into the sandbox
// below) so the §4 coverage check runs real pitch math, not stubbed values.
sandbox.window._tunerUI = () => ({
addButton() {},
initUI() {},
@@ -156,6 +163,7 @@ function createTunerSandbox(opts) {
});
vm.createContext(sandbox);
vm.runInContext(fs.readFileSync(TUNING_UTILS_JS, 'utf8'), sandbox);
vm.runInContext(fs.readFileSync(TUNER_SCREEN_JS, 'utf8'), sandbox);
const realEnable = sandbox.window.tuner.enable.bind(sandbox.window.tuner);
@@ -359,6 +367,64 @@ test('screen.js registers song:loading and song:ready auto-open listeners at boo
assert.doesNotMatch(src, /restartCurrentSong/);
});
// ── §4 instrument-coverage (E1.5) ──────────────────────────────────────────
// Player physical instruments (core /api/settings shape: instrument/string_count/
// tuning offsets/reference_pitch).
const PLAYER_GUITAR_8_FS = { instrument: 'guitar', string_count: 8, tuning: [0, 0, 0, 0, 0, 0, 0, 0], reference_pitch: 440 }; // F# standard
const PLAYER_GUITAR_6 = { instrument: 'guitar', string_count: 6, tuning: [0, 0, 0, 0, 0, 0], reference_pitch: 440 }; // E standard
const SONG_7B = { filename: '7b.sloppak', arrangement: 'Lead', arrangement_index: 0, stringCount: 7, tuning: [0, 0, 0, 0, 0, 0, 0] }; // B standard 7
const SONG_DROP_A7 = { filename: 'dropa7.sloppak', arrangement: 'Lead', arrangement_index: 0, stringCount: 7, tuning: [-2, 0, 0, 0, 0, 0, 0] }; // Drop A 7
test('coverage: an 8-string F# player is NOT prompted for a covered 6-/7-string standard song', async () => {
const sandbox = createTunerSandbox({ player: PLAYER_GUITAR_8_FS });
sandbox.window._tunerAutoOpen.resetState();
await ready(sandbox, DROP_D); // first song → sets lastTuningKey, no open
await ready(sandbox, E_STANDARD); // 6-E lives on the 8-string's top 6 → suppressed
assert.equal(sandbox.__enableCalls.length, 0);
await ready(sandbox, SONG_7B); // 7-B lives on the 8-string's top 7 → suppressed
assert.equal(sandbox.__enableCalls.length, 0);
});
test('coverage: a Drop-A 7-string song STILL prompts the 8-string F# player (dropped string absent)', async () => {
const sandbox = createTunerSandbox({ player: PLAYER_GUITAR_8_FS });
sandbox.window._tunerAutoOpen.resetState();
await ready(sandbox, E_STANDARD); // first → no open
await ready(sandbox, SONG_DROP_A7); // needs an open A1; the 8-string has F#1/B1, not A1 → prompt
assert.equal(sandbox.__enableCalls.length, 1);
});
test('coverage: a 6-string-standard player IS prompted for Drop D', async () => {
const sandbox = createTunerSandbox({ player: PLAYER_GUITAR_6 });
sandbox.window._tunerAutoOpen.resetState();
await ready(sandbox, E_STANDARD); // first → no open
await ready(sandbox, DROP_D); // low E must drop to D → not covered → prompt
assert.equal(sandbox.__enableCalls.length, 1);
});
test('coverage: a reference-pitch mismatch (A432 player vs A440 song) prompts even when the shape matches', async () => {
const sandbox = createTunerSandbox({ player: { ...PLAYER_GUITAR_6, reference_pitch: 432 } });
sandbox.window._tunerAutoOpen.resetState();
await ready(sandbox, DROP_D); // first → no open
await ready(sandbox, E_STANDARD); // shape matches, but the whole instrument is ~32¢ flat → prompt
assert.equal(sandbox.__enableCalls.length, 1);
});
test('coverage: covered/uncovered is decided by contiguous pitch alignment (direct)', async () => {
const sandbox = createTunerSandbox({ player: PLAYER_GUITAR_8_FS });
const cover = (song) => sandbox.window._tunerAutoOpen.coveredByPlayerInstrument(song);
assert.equal(await cover(E_STANDARD), true); // 6-E is a run inside 8-string F#
assert.equal(await cover(SONG_7B), true); // 7-B is a run inside 8-string F#
assert.equal(await cover(SONG_DROP_A7), false); // Drop-A's low A1 isn't an open string on it
});
test('coverage: with no declared instrument it stays conservative (prompts as before)', async () => {
const sandbox = createTunerSandbox(); // no /api/settings instrument
sandbox.window._tunerAutoOpen.resetState();
await ready(sandbox, CUSTOM_GUITAR);
await ready(sandbox, E_STANDARD);
assert.equal(sandbox.__enableCalls.length, 1);
});
test('auto-open does not require app.js changes', () => {
const appSrc = fs.readFileSync(APP_JS, 'utf8');
assert.doesNotMatch(appSrc, /_tunerAutoOpen|maybeAutoOpenOnTuningChange/);