diff --git a/static/app.js b/static/app.js index 48a812b..ce33c97 100644 --- a/static/app.js +++ b/static/app.js @@ -5418,7 +5418,9 @@ window.jucePlayer = jucePlayer; 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); @@ -5445,6 +5447,13 @@ window.jucePlayer = jucePlayer; 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 */ } } 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) });