feat(audio): stems ride a bespoke mixer channel; tuner/minigames use capture demands

Ownership-plan client migrations (feedBack-desktop feat/audio-ownership-phase-a
counterpart):
- juce-audio.js: the stems graph requests its own mixer channel
  (audio.mixer.requestChannel('stems')) and pushes there instead of the
  aggregate renderer bus - a renderer stall no longer starves stem audio
  behind everything else, and per-channel underflow counters name it in
  field logs. Sink already flips to 'none' before any push, so the s5
  double-audio guard holds by construction. Legacy renderer-bus path kept
  for old desktop mains / no-capacity refusals.
- tuner + minigames: startAudio start-then-remember-to-undo hacks replaced
  by the refcounted 'capture' demand (plan s6.1) with legacy fallback;
  demand released on stop/supersede, and holder death releases it anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-14 22:51:56 +02:00
co-authored by Claude Fable 5
parent 4e0e3c5417
commit 26351e244d
3 changed files with 89 additions and 15 deletions
+29 -9
View File
@@ -28,6 +28,14 @@
// if it changes mid-flight, so a re-entrant start()/restart() can't orphan
// a worker + interval created by a superseded call.
let _startGen = 0;
// Whether we hold the engine `capture` demand (ownership plan §6.1).
let _holdsCaptureDemand = false;
function _releaseCaptureDemand(desktop) {
if (!_holdsCaptureDemand) return;
_holdsCaptureDemand = false;
try { desktop?.audio?.leases?.releaseDemand('capture', 'tuner'); } catch (_) {}
}
// Timestamp of the last frame posted to the worker (watchdog, see above).
let _frameSentAt = 0;
@@ -104,20 +112,31 @@
var started = false;
try {
var running = typeof desktop.audio.isAudioRunning === 'function'
? await desktop.audio.isAudioRunning() : false;
if (!running && typeof desktop.audio.startAudio === 'function') {
await desktop.audio.startAudio();
started = true;
if (typeof desktop.audio.leases?.acquireDemand === 'function') {
// Ownership plan §6.1: express "the engine must capture" as a
// refcounted demand instead of the start-then-remember-to-undo
// hack — the engine keeps running while ANY holder needs it,
// and a dead renderer releases automatically.
await desktop.audio.leases.acquireDemand('capture', 'tuner');
_holdsCaptureDemand = true;
} else {
// Legacy desktop main without the lease registry.
var running = typeof desktop.audio.isAudioRunning === 'function'
? await desktop.audio.isAudioRunning() : false;
if (!running && typeof desktop.audio.startAudio === 'function') {
await desktop.audio.startAudio();
started = true;
}
}
} catch (e) {
// A failed startAudio means frames will never arrive — surface it
// A failed start/demand means frames will never arrive — surface it
// rather than silently claiming a dead bridge.
console.warn('[tuner] bridge startAudio failed:', e && e.message ? e.message : e);
console.warn('[tuner] bridge capture request failed:', e && e.message ? e.message : e);
}
if (myGen !== _startGen) {
// Superseded by a newer start/stop while awaiting — undo any engine
// start we triggered and bail without claiming the bridge.
// Superseded by a newer start/stop while awaiting — undo whatever
// we acquired and bail without claiming the bridge.
_releaseCaptureDemand(desktop);
if (started && typeof desktop.audio.stopAudio === 'function') {
try { desktop.audio.stopAudio(); } catch (_) {}
}
@@ -249,6 +268,7 @@
// Invalidate any start still suspended on an await so it aborts instead
// of installing a worker/interval after we've torn down.
_startGen++;
_releaseCaptureDemand((typeof window !== 'undefined') ? window.feedBackDesktop : null);
if (_bridgeInterval) { clearInterval(_bridgeInterval); _bridgeInterval = null; }
_usingDesktopBridge = false;
if (_detectInterval) { clearInterval(_detectInterval); _detectInterval = null; }