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
+18 -1
View File
@@ -573,6 +573,7 @@ window._tunerUI = function(state, actions) {
closeBtn.title = 'Close';
closeBtn.textContent = '×';
closeBtn.onclick = () => actions.disable();
state.closeBtn = closeBtn;
header.appendChild(closeBtn);
const settingsBtn = document.createElement('button');
@@ -639,11 +640,27 @@ window._tunerUI = function(state, actions) {
const skipBtn = document.createElement('button');
skipBtn.className = 'tuner-skip-btn hidden w-full mt-3 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';
skipBtn.textContent = 'Skip';
skipBtn.title = 'Dismiss the tuner for this song';
skipBtn.title = "I've tuned — play the song";
skipBtn.onclick = () => actions.disable();
state.skipBtn = skipBtn;
state.uiContainer.appendChild(skipBtn);
// Auto-open escape hatch: leave the song entirely instead of committing to
// the play-now choice — so a gated retune is never a one-way trap. Mirrors
// Escape (the player's "Back to library" shortcut) and, like Escape, does
// NOT record a tuning (you're leaving, not asserting you tuned).
const backBtn = document.createElement('button');
backBtn.className = 'tuner-back-btn hidden w-full mt-2 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';
backBtn.textContent = 'Back to library';
backBtn.title = 'Leave the song (Esc)';
backBtn.onclick = () => {
const exit = window.feedBack && window.feedBack.requestExitSong;
if (typeof exit === 'function') exit();
else if (typeof window.requestExitSong === 'function') window.requestExitSong();
};
state.backBtn = backBtn;
state.uiContainer.appendChild(backBtn);
document.body.appendChild(state.uiContainer);
state.uiContainer.addEventListener('click', (e) => e.stopPropagation());
}