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
+74 -18
View File
@@ -5990,6 +5990,56 @@ function _resolvePlayerOrigin() {
// next song:ready. song:ready also fires on arrangement switches / seeks,
// which never arm the flag, so those don't auto-restart.
let _pendingAutostart = false;
// Autoplay gate (window.feedBack.holdAutoplay): a plugin (the tuner) can defer the
// auto-start of a freshly-loaded song until it's cleared — "tune before you play".
// The hold is claimed synchronously on song:loading (so it beats this song:ready
// autostart); release() — or a fail-open backstop — runs the deferred start.
// Generation-guarded so a newer song invalidates a stale hold. Manual Play never
// flows through here, so Play always wins.
let _autoplayHeld = false;
let _autoplayStart = null;
let _autoplayGen = 0;
let _autoplayBackstop = null;
const AUTOPLAY_HOLD_BACKSTOP_MS = 12000;
function _clearAutoplayHold() {
if (_autoplayBackstop) { clearTimeout(_autoplayBackstop); _autoplayBackstop = null; }
_autoplayHeld = false;
_autoplayStart = null;
_autoplayGen++;
}
function _releaseAutoplay(gen) {
if (gen !== _autoplayGen) return; // a newer song superseded this hold
if (_autoplayBackstop) { clearTimeout(_autoplayBackstop); _autoplayBackstop = null; }
_autoplayHeld = false;
const start = _autoplayStart;
_autoplayStart = null;
if (typeof start === 'function') start();
}
let _autoplayHoldToken = 0;
window.feedBack.holdAutoplay = function () {
const gen = _autoplayGen;
const token = ++_autoplayHoldToken; // this hold's identity — a stale release from an earlier hold is a no-op
_autoplayHeld = true;
if (_autoplayBackstop) clearTimeout(_autoplayBackstop);
// Fail-open: a hold that's never released (a plugin that claimed but wedged before
// it could decide) must never permanently block the song. Once the holder commits
// to an intentional, user-dismissable hold it calls release.settle() to cancel this
// — so the backstop can't cut off e.g. a user still tuning past the timeout.
_autoplayBackstop = setTimeout(() => _releaseAutoplay(gen), AUTOPLAY_HOLD_BACKSTOP_MS);
let released = false;
function release() {
if (released || gen !== _autoplayGen || token !== _autoplayHoldToken) return;
released = true;
_releaseAutoplay(gen);
}
// Cancel the fail-open backstop WITHOUT releasing: the holder has taken explicit
// responsibility for releasing (on dismiss), and a song switch clears the hold anyway.
release.settle = function () {
if (gen !== _autoplayGen || token !== _autoplayHoldToken) return;
if (_autoplayBackstop) { clearTimeout(_autoplayBackstop); _autoplayBackstop = null; }
};
return release;
};
window.feedBack.on('song:ready', () => {
if (!_pendingAutostart) return;
_pendingAutostart = false;
@@ -6014,27 +6064,30 @@ window.feedBack.on('song:ready', () => {
if (authors.length) _creditsTimer = setTimeout(hideSongCreditsOverlay, _CREDITS_HOLD_MS);
return;
}
// "Countdown before song": play a 4-beat count-in, then start. Otherwise
// reuse the Play button's start path directly (handles HTML5 + _juceMode).
if (_countdownBeforeSongEnabled()) {
// The count-in (~2.5s) gives the credits their on-screen dwell.
Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err));
} else if (authors.length) {
// No count-in window — hold the credits a couple seconds, then start.
// _cancelCountIn() and changeArrangement() both clear _creditsTimer, so
// a teardown / arrangement switch during the hold cancels this play.
_creditsTimer = setTimeout(() => {
_creditsTimer = null;
// If playback doesn't actually start (e.g. HTML5 autoplay rejection),
// song:play never fires — clear the credits promptly rather than
// waiting for the backstop. On success the song:play listener owns it.
// The actual auto-start: a count-in (which handles HTML5 + _juceMode) or the
// Play path directly. Guarded so a manual Play during a gate / credits hold
// can't double-toggle, and so a stale (released-after-leaving) start never
// begins playback off the player.
const start = () => {
if (isPlaying) return;
if (!document.getElementById('player')?.classList.contains('active')) { hideSongCreditsOverlay(); return; }
if (_countdownBeforeSongEnabled()) {
Promise.resolve(startSongCountIn()).catch((err) => console.warn('[app] song count-in failed:', err));
} else {
Promise.resolve(togglePlay())
.then(() => { if (!isPlaying) hideSongCreditsOverlay(); })
.catch((err) => { console.warn('[app] autoplay failed:', err); hideSongCreditsOverlay(); });
}, _CREDITS_HOLD_MS);
} else {
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err));
}
}
};
// A plugin (the tuner) may gate playback until it's cleared. The hold was
// claimed on song:loading; stash the start and let release()/the backstop run
// it. _cancelCountIn()/changeArrangement() clear _creditsTimer below, so a
// teardown during the credits dwell still cancels a non-gated play.
if (_autoplayHeld) { _autoplayStart = start; return; }
// Not gated: a count-in starts now (it owns its on-screen dwell); otherwise
// let the credits dwell a couple seconds first, then start.
if (_countdownBeforeSongEnabled() || !authors.length) start();
else _creditsTimer = setTimeout(() => { _creditsTimer = null; start(); }, _CREDITS_HOLD_MS);
});
// ── Resume last session ────────────────────────────────────────────────────
@@ -6331,6 +6384,9 @@ async function playSong(filename, arrangement, options) {
if (!options || options.bridge !== false) {
_recordPlaybackBridge('playback.window-play-song', 'window.playSong', 'legacy playSong entry point used');
}
// Invalidate any prior song's autoplay gate before plugins re-claim it on the
// song:loading emit below.
_clearAutoplayHold();
window.feedBack.emit('song:loading', { filename, arrangement: arrangement ?? null });
// Cancel any pending art/metadata requests