feat(tuner): gate playback until you've tuned — hold autoplay + no-trap Skip/Back/Esc (working-tuning PR 4) (#666)

* feat(tuner): gate playback until you've tuned — hold autoplay + no-trap Skip/Back/Esc (working-tuning PR 4)

When the opt-in auto-open fires because a song needs a different tuning, playback
now WAITS behind the tuner instead of starting underneath it — the "tune before
you play" model. Built on a new generic core hook window.feedBack.holdAutoplay()
(mirrors holdAutoExit): the tuner claims the hold synchronously on song:loading
(beating the song:ready autostart) and releases it — or a 12s fail-open backstop
does — so a wedged plugin can never strand a song. Generation-guarded; manual
Play always wins.

No one-way trap:
- Skip = "I've tuned" -> plays and records the song's tuning as the instrument's
  current working tuning (the explicit write-point PR 3 left as 'assumed').
- Back to library / Esc -> leave the song, record nothing (reuses requestExitSong;
  Esc is the existing player shortcut).
- The in-panel x is dropped for an auto-open — Skip/Back/Esc are the dismiss
  surface. This also keeps the write honest: Skip is the only on-player dismiss
  that records, so leaving never falsely records a tuning.

Stacked on #660 (working-tuning PR 3). Core app.js gains only the generic hook
(a test asserts it never references the tuner's internals); shell-agnostic.

Needs a desktop smoke-test that the tuner mic doesn't contend with note_detect's
scoring input under ASIO/exclusive mode (per the design charrette).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(tuner): backstop can't cut off tuning + gate race/token hardening (PR #666 review)

Review fixes for the autoplay gate:

- The 12s fail-open backstop could start playback UNDER a legitimately-open tuner
  (a slow / mic-verify retune > 12s). holdAutoplay()'s release now carries a
  .settle() that cancels the backstop; the tuner calls it once the tuner is
  confirmed open (_gateClaimed), so the hold becomes deliberate and only a
  dismiss / song switch releases it. (Fail-open still covers "claimed but wedged
  before deciding".)
- The async song:ready handler could release a NEWER song's gate after its await
  (global _gateClaimed, no guard). It now snapshots _autoOpenGeneration and bails
  if a newer song took over.
- holdAutoplay guarded by song generation, not per-hold — a stale release from an
  earlier hold could clear a later one. Each hold now mints a unique token that
  release()/settle() must match.

Tests: source-level assertions for the token, settle(), the settle-on-open call,
and the song:ready gen-guard. 45 tuner+speed 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:18:29 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent 48f435408f
commit 115c96a3f0
6 changed files with 197 additions and 21 deletions
+1
View File
@@ -148,6 +148,7 @@ function loadPlaySong(sandbox) {
var _playerOriginScreen = null;
var _pendingAutostart = false;
function _clearAutoExit() {}
function _clearAutoplayHold() {}
function _resolvePlayerOrigin() { return 'home'; }
function _recordPlaybackBridge() {}
function _cancelCountIn() {}
+66
View File
@@ -9,6 +9,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');
const TUNER_UI_JS = path.join(__dirname, '..', '..', 'plugins', 'tuner', 'utils', 'ui.js');
function loadTuningHelpers() {
const src = fs.readFileSync(APP_JS, 'utf8');
@@ -461,6 +462,71 @@ test('the tuner badge surfaces a passive coverage cue (badges.js)', () => {
assert.match(badgesSrc, /boxShadow/);
});
// ── Autoplay gate (E2) ─────────────────────────────────────────────────────
test('gate: claims an autoplay hold on song:loading and releases it on dismiss', async () => {
const sandbox = createTunerSandbox({ player: PLAYER_GUITAR_6 });
let holds = 0, releases = 0;
sandbox.window.feedBack.holdAutoplay = () => { holds++; return () => { releases++; }; };
const api = sandbox.window._tunerAutoOpen;
await ready(sandbox, DROP_D); // loads config (feature on)
api.resetState();
api.onSongLoading(); // song:loading → claim the gate
assert.equal(holds, 1);
assert.equal(releases, 0);
sandbox.window.tuner.disable(); // dismiss → release (playback proceeds)
assert.equal(releases, 1);
});
test('gate: does not claim a hold when the feature is off', async () => {
const sandbox = createTunerSandbox({ player: PLAYER_GUITAR_6, autoOpen: false });
let holds = 0;
sandbox.window.feedBack.holdAutoplay = () => { holds++; return () => {}; };
const api = sandbox.window._tunerAutoOpen;
await ready(sandbox, DROP_D); // loads config (feature OFF)
api.onSongLoading();
assert.equal(holds, 0);
});
test('the autoplay gate is a generic core hook with a fail-open backstop (app.js)', () => {
const appSrc = fs.readFileSync(APP_JS, 'utf8');
assert.match(appSrc, /window\.feedBack\.holdAutoplay = function/);
assert.match(appSrc, /AUTOPLAY_HOLD_BACKSTOP_MS/); // fail-open: never strand the song
assert.match(appSrc, /if \(_autoplayHeld\) \{ _autoplayStart = start;/); // a gated start is stashed
assert.match(appSrc, /_clearAutoplayHold\(\);[\s\S]{0,160}emit\('song:loading'/); // reset before plugins re-claim
// Per-hold identity: a stale release from an earlier hold must not clear a later one.
assert.match(appSrc, /token !== _autoplayHoldToken/);
// settle(): a committed holder can cancel the fail-open backstop (no timed release).
assert.match(appSrc, /release\.settle = function/);
// The hook is generic — app.js still doesn't reference the tuner's internals.
assert.doesNotMatch(appSrc, /_tunerAutoOpen|maybeAutoOpenOnTuningChange/);
});
test('the tuner gates playback via holdAutoplay (screen.js)', () => {
const src = fs.readFileSync(TUNER_SCREEN_JS, 'utf8');
assert.match(src, /window\.feedBack\.holdAutoplay\(\)/); // claimed on song:loading
assert.match(src, /_gateClaimed = true/); // kept when the tuner opens
assert.match(src, /if \(!_gateClaimed\) _releaseGate\(\)/); // released when we don't open
assert.match(src, /_releaseGate\(\);[\s\S]{0,80}dismissing a gated/); // released on dismiss
// Once open, the tuner settles the hold so the 12s backstop can't start playback
// while the player is still tuning.
assert.match(src, /_autoplayRelease\.settle\(\)/);
// The async song:ready handler is generation-guarded so it can't release a NEWER
// song's gate after its await.
assert.match(src, /_onAutoOpenSongReady = async \(\) => \{[\s\S]{0,320}myGen !== _autoOpenGeneration\) return;[\s\S]{0,120}_releaseGate/);
});
test('gate escape hatch: auto-open offers "Back to library" (+ Esc) and hides the × (ui.js/screen.js)', () => {
const ui = fs.readFileSync(TUNER_UI_JS, 'utf8');
assert.match(ui, /Back to library/); // an explicit way out, not only play-now
assert.match(ui, /requestExitSong/); // reuses the standard song-exit path (mirrors Esc)
assert.match(ui, /state\.backBtn =/);
assert.match(ui, /state\.closeBtn =/); // × captured so it can be toggled
const screen = fs.readFileSync(TUNER_SCREEN_JS, 'utf8');
assert.match(screen, /_state\.backBtn[\s\S]{0,60}toggle\('hidden', !auto\)/); // shown on auto-open
assert.match(screen, /_state\.closeBtn[\s\S]{0,60}toggle\('hidden', !!auto\)/); // × hidden on auto-open
});
test('auto-open does not require app.js changes', () => {
const appSrc = fs.readFileSync(APP_JS, 'utf8');
assert.doesNotMatch(appSrc, /_tunerAutoOpen|maybeAutoOpenOnTuningChange/);