diff --git a/static/app.js b/static/app.js index f3438da..abd483a 100644 --- a/static/app.js +++ b/static/app.js @@ -5468,7 +5468,9 @@ window.addEventListener('unhandledrejection', (e) => { throw new Error('no loopback audio track'); } try { - _lbCtx = _lbCtx || new AudioContext(); + // Fresh context per session (not reused) so teardown's close() + // fully releases the tap worklet node — see _teardownLoopback. + _lbCtx = new AudioContext(); if (_lbCtx.state !== 'running') await _lbCtx.resume().catch(() => {}); const source = _lbCtx.createMediaStreamSource(stream); const tap = _makeTap(_lbCtx); @@ -5495,6 +5497,13 @@ window.addEventListener('unhandledrejection', (e) => { if (_lbTap) _lbTap.active = false; if (_lbStream) for (const t of _lbStream.getTracks()) t.stop(); _lbStream = null; _lbTap = null; + // Close the capture context so its tap worklet node is released. The + // context is per-session (not reused): without this, each exclusive⇄ + // shared switch orphaned a live worklet on a long-lived context. + if (_lbCtx) { + try { await _lbCtx.close(); } catch (_) { /* already closed */ } + _lbCtx = null; + } if (_lbPageMuted && typeof api.setPageMuted === 'function') { try { await api.setPageMuted(false); } catch (_) { /* engine gone */ } } @@ -5623,24 +5632,6 @@ window.addEventListener('unhandledrejection', (e) => { } - // [asio-diag] full decision vector, change-gated (500ms poll — - // steady state must not flood the buffer). This is the feeder-side - // counterpart of the watcher's [feedpak-route] decision line: it - // shows WHY the bus did or didn't engage (exclusive predicate, - // stems graph presence, native transport ownership, element song). - if (window._asioDiagEnabled?.()) { - const d = 'running=' + running + ' exclusive=' + exclusive - + ' stems=' + !!stems + ' songAudio=' + !!songAudio - + ' juceMode=' + !!window._juceMode - + ' elementSong=' + elementSong - + ' want=' + want + ' mode=' + _mode; - if (d !== window._lastRendererBusDecision) { - window._lastRendererBusDecision = d; - console.log('[asio-diag] renderer-bus:', d); - } - } - - const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph; if (want !== _mode || stemsGraphChanged) { await _disengage(); diff --git a/tests/js/renderer_bus_feeder.test.js b/tests/js/renderer_bus_feeder.test.js index 8438db7..1500816 100644 --- a/tests/js/renderer_bus_feeder.test.js +++ b/tests/js/renderer_bus_feeder.test.js @@ -54,6 +54,7 @@ function makeFakeContext(sampleRate = 48000) { this.mediaStreamSource = stream; return { connect() {}, disconnect() {} }; }, + close() { this.closed = true; return Promise.resolve(); }, }; return ctx; } @@ -231,6 +232,22 @@ test('exclusive output + loopback available → engages without any song loaded' assert.equal(sb.__calls.setPageMuted.length, 0, 'suppress constraint honoured — no page mute'); }); +test('loopback context is closed on disengage (no orphaned tap worklet)', async () => { + let excl = true; + const stream = makeLoopbackStream(); + const sb = makeSandbox({ exclusive: () => excl, displayMedia: () => Promise.resolve(stream) }); + + await sb.window._reevaluateRendererBus(); // engage loopback + const lbCtx = sb.__createdContexts.at(-1); + assert.equal(lbCtx?.mediaStreamSource, stream, 'loopback engaged'); + assert.notEqual(lbCtx.closed, true, 'context live while engaged'); + + excl = false; + await sb.window._reevaluateRendererBus(); // disengage + assert.equal(lbCtx.closed, true, 'loopback context closed on disengage'); + assert.ok(stream.__stopped.includes('audio'), 'capture stream stopped'); +}); + test('loopback preferred over stems when both available', async () => { const stream = makeLoopbackStream(); const sb = makeSandbox({ exclusive: () => true, displayMedia: () => Promise.resolve(stream) });