mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-12 11:49:28 +00:00
This reverts commit 9a58a55fe8.
This commit is contained in:
+10
-120
@@ -5375,94 +5375,15 @@ window.jucePlayer = jucePlayer;
|
|||||||
if (_elCtx) return;
|
if (_elCtx) return;
|
||||||
const el = document.getElementById('audio');
|
const el = document.getElementById('audio');
|
||||||
if (!el) throw new Error('no core audio element');
|
if (!el) throw new Error('no core audio element');
|
||||||
// Assign the module state ONLY after the whole chain succeeded.
|
_elCtx = new AudioContext();
|
||||||
// createMediaElementSource throws InvalidStateError when another
|
_elSource = _elCtx.createMediaElementSource(el);
|
||||||
// consumer (highway_3d's analyser tap) already owns the element's
|
_elSource.connect(_elCtx.destination);
|
||||||
// one-shot source — assigning _elCtx before that throw poisoned every
|
_elTap = _makeTap(_elCtx);
|
||||||
// later tick into `_elTap.active` TypeErrors (tester log 2026-07-11)
|
await _elTap.attach(_elSource);
|
||||||
// while the song kept playing on the default device.
|
|
||||||
const ctx = new AudioContext();
|
|
||||||
let source, tap;
|
|
||||||
try {
|
|
||||||
source = ctx.createMediaElementSource(el);
|
|
||||||
source.connect(ctx.destination);
|
|
||||||
tap = _makeTap(ctx);
|
|
||||||
await tap.attach(source);
|
|
||||||
} catch (e) {
|
|
||||||
try { await ctx.close(); } catch (_) { /* already closed */ }
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
_elCtx = ctx; _elSource = source; _elTap = tap;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Whole-app loopback capture ───────────────────────────────────────────
|
|
||||||
// Preferred mode: one getDisplayMedia frame-audio capture covers EVERY
|
|
||||||
// sound the app makes (song, previews, UI) — no per-surface taps, so
|
|
||||||
// plugin-private AudioContexts (song-preview, future plugins) survive
|
|
||||||
// exclusive/ASIO output too. The desktop main process answers the request
|
|
||||||
// with this window's own frame (frame-scoped — no other apps' audio).
|
|
||||||
// Local playback is silenced via the suppressLocalAudioPlayback track
|
|
||||||
// constraint, with a page-mute IPC fallback (capture taps frame audio
|
|
||||||
// before the output mute, so a muted page still feeds the stream).
|
|
||||||
let _lbStream = null, _lbCtx = null, _lbTap = null, _lbPageMuted = false;
|
|
||||||
let _loopbackUnavailable = false; // sticky: probe once, then fall back
|
|
||||||
async function _engageLoopback() {
|
|
||||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
|
||||||
video: true,
|
|
||||||
audio: { suppressLocalAudioPlayback: true },
|
|
||||||
});
|
|
||||||
for (const t of stream.getVideoTracks()) t.stop(); // required, unused
|
|
||||||
const track = stream.getAudioTracks()[0];
|
|
||||||
if (!track) {
|
|
||||||
for (const t of stream.getTracks()) t.stop();
|
|
||||||
throw new Error('no loopback audio track');
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
// 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);
|
|
||||||
await tap.attach(source);
|
|
||||||
const suppressed = track.getSettings?.().suppressLocalAudioPlayback === true;
|
|
||||||
if (!suppressed && typeof api.setPageMuted === 'function') {
|
|
||||||
_lbPageMuted = (await api.setPageMuted(true)) === true;
|
|
||||||
}
|
|
||||||
if (window._asioDiagEnabled?.()) {
|
|
||||||
console.log('[asio-diag] loopback: suppressed=', suppressed,
|
|
||||||
'pageMuted=', _lbPageMuted, 'rate=', _lbCtx.sampleRate);
|
|
||||||
}
|
|
||||||
await api.setRendererBus(true, 1.0);
|
|
||||||
tap.active = true;
|
|
||||||
_lbStream = stream; _lbTap = tap;
|
|
||||||
_mode = 'loopback';
|
|
||||||
console.log('[renderer-bus] engaged: app loopback → engine bus');
|
|
||||||
} catch (e) {
|
|
||||||
for (const t of stream.getTracks()) t.stop();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function _teardownLoopback() {
|
|
||||||
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 */ }
|
|
||||||
}
|
|
||||||
_lbPageMuted = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Engagement state machine ─────────────────────────────────────────────
|
// ── Engagement state machine ─────────────────────────────────────────────
|
||||||
// 'off' | 'loopback' | 'element' | 'stems' (element/stems = fallback when
|
// 'off' | 'element' | 'stems'
|
||||||
// loopback capture is unavailable: old desktop main, denied capture)
|
|
||||||
let _mode = 'off';
|
let _mode = 'off';
|
||||||
let _stemsGraph = null; // { context, masterNode } snapshot while engaged
|
let _stemsGraph = null; // { context, masterNode } snapshot while engaged
|
||||||
let _stemsTap = null;
|
let _stemsTap = null;
|
||||||
@@ -5487,9 +5408,7 @@ window.jucePlayer = jucePlayer;
|
|||||||
const prev = _mode;
|
const prev = _mode;
|
||||||
_mode = 'off';
|
_mode = 'off';
|
||||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
||||||
if (prev === 'loopback') {
|
if (prev === 'element' && _elCtx) {
|
||||||
await _teardownLoopback();
|
|
||||||
} else if (prev === 'element' && _elCtx) {
|
|
||||||
_elTap.active = false;
|
_elTap.active = false;
|
||||||
await _setSink(_elCtx, false).catch(() => {});
|
await _setSink(_elCtx, false).catch(() => {});
|
||||||
} else if (prev === 'stems' && _stemsGraph) {
|
} else if (prev === 'stems' && _stemsGraph) {
|
||||||
@@ -5548,20 +5467,9 @@ window.jucePlayer = jucePlayer;
|
|||||||
|
|
||||||
let want = 'off';
|
let want = 'off';
|
||||||
if (running && exclusive) {
|
if (running && exclusive) {
|
||||||
// Loopback covers ALL app audio (song, previews, UI), so it
|
if (stems) want = 'stems';
|
||||||
// engages for the whole exclusive session — not just while a
|
|
||||||
// song is loaded. Per-surface modes remain as fallback when
|
|
||||||
// loopback capture is unavailable (old desktop main without
|
|
||||||
// the display-media handler, capture denied).
|
|
||||||
if (!_loopbackUnavailable) want = 'loopback';
|
|
||||||
else if (stems) want = 'stems';
|
|
||||||
else if (elementSong) want = 'element';
|
else if (elementSong) want = 'element';
|
||||||
}
|
}
|
||||||
// Song audio riding the native transport must not ALSO ride the
|
|
||||||
// loopback (double-carry into the same engine output). The native
|
|
||||||
// transport plays from the engine, not the page, so page loopback
|
|
||||||
// never hears it — no conflict; loopback stays engaged for
|
|
||||||
// previews/UI while the transport owns the song.
|
|
||||||
|
|
||||||
// [asio-diag] full decision vector, change-gated (500ms poll —
|
// [asio-diag] full decision vector, change-gated (500ms poll —
|
||||||
// steady state must not flood the buffer). This is the feeder-side
|
// steady state must not flood the buffer). This is the feeder-side
|
||||||
@@ -5573,7 +5481,6 @@ window.jucePlayer = jucePlayer;
|
|||||||
+ ' stems=' + !!stems + ' songAudio=' + !!songAudio
|
+ ' stems=' + !!stems + ' songAudio=' + !!songAudio
|
||||||
+ ' juceMode=' + !!window._juceMode
|
+ ' juceMode=' + !!window._juceMode
|
||||||
+ ' elementSong=' + elementSong
|
+ ' elementSong=' + elementSong
|
||||||
+ ' loopbackUnavailable=' + _loopbackUnavailable
|
|
||||||
+ ' want=' + want + ' mode=' + _mode;
|
+ ' want=' + want + ' mode=' + _mode;
|
||||||
if (d !== window._lastRendererBusDecision) {
|
if (d !== window._lastRendererBusDecision) {
|
||||||
window._lastRendererBusDecision = d;
|
window._lastRendererBusDecision = d;
|
||||||
@@ -5585,29 +5492,12 @@ window.jucePlayer = jucePlayer;
|
|||||||
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
|
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
|
||||||
if (want !== _mode || stemsGraphChanged) {
|
if (want !== _mode || stemsGraphChanged) {
|
||||||
await _disengage();
|
await _disengage();
|
||||||
try {
|
if (want === 'stems') await _engageStems(stems);
|
||||||
if (want === 'loopback') await _engageLoopback();
|
else if (want === 'element') await _engageElement();
|
||||||
else if (want === 'stems') await _engageStems(stems);
|
|
||||||
else if (want === 'element') await _engageElement();
|
|
||||||
} catch (e) {
|
|
||||||
if (want === 'loopback') {
|
|
||||||
// Capture unavailable (no handler in an old desktop
|
|
||||||
// main, permission denied) — remember and fall back to
|
|
||||||
// the per-surface modes on the next tick.
|
|
||||||
_loopbackUnavailable = true;
|
|
||||||
console.warn('[renderer-bus] loopback capture unavailable — falling back to surface taps:', e);
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[renderer-bus] reevaluate failed (will retry):', e);
|
console.warn('[renderer-bus] reevaluate failed (will retry):', e);
|
||||||
_mode = 'off';
|
_mode = 'off';
|
||||||
// A partial engage may have left the bus enabled with no producer
|
|
||||||
// and the page muted — undo both so a failed tick can't strand
|
|
||||||
// audio in silence until the next successful engage.
|
|
||||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
|
||||||
await _teardownLoopback().catch(() => {});
|
|
||||||
} finally {
|
} finally {
|
||||||
_busy = false;
|
_busy = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,42 +50,17 @@ function makeFakeContext(sampleRate = 48000) {
|
|||||||
this.mediaSourceEl = el;
|
this.mediaSourceEl = el;
|
||||||
return { connect() {}, disconnect() {} };
|
return { connect() {}, disconnect() {} };
|
||||||
},
|
},
|
||||||
createMediaStreamSource(stream) {
|
|
||||||
this.mediaStreamSource = stream;
|
|
||||||
return { connect() {}, disconnect() {} };
|
|
||||||
},
|
|
||||||
close() { this.closed = true; return Promise.resolve(); },
|
|
||||||
};
|
};
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fake getDisplayMedia stream for the loopback-capture path.
|
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
|
||||||
function makeLoopbackStream({ suppressed = true } = {}) {
|
const calls = { setRendererBus: [], pushRendererAudio: [] };
|
||||||
const stopped = [];
|
|
||||||
const audioTrack = {
|
|
||||||
kind: 'audio',
|
|
||||||
stop() { stopped.push('audio'); },
|
|
||||||
getSettings: () => (suppressed ? { suppressLocalAudioPlayback: true } : {}),
|
|
||||||
};
|
|
||||||
const videoTrack = { kind: 'video', stop() { stopped.push('video'); } };
|
|
||||||
return {
|
|
||||||
__stopped: stopped,
|
|
||||||
getAudioTracks: () => [audioTrack],
|
|
||||||
getVideoTracks: () => [videoTrack],
|
|
||||||
getTracks: () => [videoTrack, audioTrack],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// `displayMedia`: undefined → loopback capture unavailable (Docker sphere /
|
|
||||||
// old desktop main); a function → used as navigator.mediaDevices.getDisplayMedia.
|
|
||||||
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true, displayMedia } = {}) {
|
|
||||||
const calls = { setRendererBus: [], pushRendererAudio: [], setPageMuted: [] };
|
|
||||||
|
|
||||||
const api = {
|
const api = {
|
||||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||||
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
|
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
|
||||||
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
|
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
|
||||||
setPageMuted: (m) => { calls.setPageMuted.push(m); return Promise.resolve(m); },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
class FakeWorkletNode {
|
class FakeWorkletNode {
|
||||||
@@ -110,7 +85,6 @@ function makeSandbox({ isAudioRunning = () => true, exclusive = () => true, disp
|
|||||||
__createdContexts: [],
|
__createdContexts: [],
|
||||||
__audioEl: { id: 'audio' },
|
__audioEl: { id: 'audio' },
|
||||||
__calls: calls,
|
__calls: calls,
|
||||||
navigator: { mediaDevices: displayMedia ? { getDisplayMedia: displayMedia } : {} },
|
|
||||||
window: null,
|
window: null,
|
||||||
};
|
};
|
||||||
sandbox.window = {
|
sandbox.window = {
|
||||||
@@ -137,21 +111,12 @@ function makeStemsGraph() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Surface-mode (stems/element) tests run WITHOUT getDisplayMedia: the first
|
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
|
||||||
// tick probes loopback, fails, and latches _loopbackUnavailable; the second
|
|
||||||
// tick exercises the fallback surface mode. This mirrors an old desktop main
|
|
||||||
// without the display-media handler.
|
|
||||||
async function reevaluateWithFallback(sb) {
|
|
||||||
await sb.window._reevaluateRendererBus(); // loopback probe → unavailable
|
|
||||||
await sb.window._reevaluateRendererBus(); // surface fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked (loopback unavailable)', async () => {
|
|
||||||
const sb = makeSandbox({ exclusive: () => true });
|
const sb = makeSandbox({ exclusive: () => true });
|
||||||
const graph = makeStemsGraph();
|
const graph = makeStemsGraph();
|
||||||
sb.window.feedBack.stems.audioGraph = graph;
|
sb.window.feedBack.stems.audioGraph = graph;
|
||||||
|
|
||||||
await reevaluateWithFallback(sb);
|
await sb.window._reevaluateRendererBus();
|
||||||
|
|
||||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||||
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems ctx re-pointed at null sink');
|
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems ctx re-pointed at null sink');
|
||||||
@@ -163,7 +128,7 @@ test('output returns to shared → bus disabled, sink restored', async () => {
|
|||||||
const graph = makeStemsGraph();
|
const graph = makeStemsGraph();
|
||||||
sb.window.feedBack.stems.audioGraph = graph;
|
sb.window.feedBack.stems.audioGraph = graph;
|
||||||
|
|
||||||
await reevaluateWithFallback(sb);
|
await sb.window._reevaluateRendererBus();
|
||||||
excl = false;
|
excl = false;
|
||||||
await sb.window._reevaluateRendererBus();
|
await sb.window._reevaluateRendererBus();
|
||||||
|
|
||||||
@@ -180,27 +145,26 @@ test('stems graph + shared output → feeder stays off (no double audio)', async
|
|||||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
|
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('element song + exclusive → element captured into bus (loopback unavailable)', async () => {
|
test('element song + exclusive → element captured into bus', async () => {
|
||||||
const sb = makeSandbox({ exclusive: () => true });
|
const sb = makeSandbox({ exclusive: () => true });
|
||||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||||
sb.window._juceMode = false;
|
sb.window._juceMode = false;
|
||||||
|
|
||||||
await reevaluateWithFallback(sb);
|
await sb.window._reevaluateRendererBus();
|
||||||
|
|
||||||
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
|
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
|
||||||
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
|
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
|
||||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('native-transport song, loopback unavailable → surface modes stay off', async () => {
|
test('song riding the native transport (_juceMode) → feeder stays off', async () => {
|
||||||
const sb = makeSandbox({ exclusive: () => true });
|
const sb = makeSandbox({ exclusive: () => true });
|
||||||
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
|
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
|
||||||
sb.window._juceMode = true;
|
sb.window._juceMode = true;
|
||||||
|
|
||||||
await reevaluateWithFallback(sb);
|
await sb.window._reevaluateRendererBus();
|
||||||
|
|
||||||
assert.ok(!sb.__calls.setRendererBus.some(([en]) => en === true),
|
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
|
||||||
'bus never ENABLED (failed-probe cleanup may disable it)');
|
|
||||||
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
|
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -208,7 +172,7 @@ test('stems graph replaced mid-engagement → re-engages on the new graph', asyn
|
|||||||
const sb = makeSandbox({ exclusive: () => true });
|
const sb = makeSandbox({ exclusive: () => true });
|
||||||
const g1 = makeStemsGraph();
|
const g1 = makeStemsGraph();
|
||||||
sb.window.feedBack.stems.audioGraph = g1;
|
sb.window.feedBack.stems.audioGraph = g1;
|
||||||
await reevaluateWithFallback(sb);
|
await sb.window._reevaluateRendererBus();
|
||||||
|
|
||||||
const g2 = makeStemsGraph();
|
const g2 = makeStemsGraph();
|
||||||
sb.window.feedBack.stems.audioGraph = g2;
|
sb.window.feedBack.stems.audioGraph = g2;
|
||||||
@@ -218,105 +182,6 @@ test('stems graph replaced mid-engagement → re-engages on the new graph', asyn
|
|||||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
|
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Loopback mode (whole-app capture) ────────────────────────────────────────
|
|
||||||
|
|
||||||
test('exclusive output + loopback available → engages without any song loaded', async () => {
|
|
||||||
const stream = makeLoopbackStream();
|
|
||||||
const sb = makeSandbox({ exclusive: () => true, displayMedia: () => Promise.resolve(stream) });
|
|
||||||
|
|
||||||
await sb.window._reevaluateRendererBus();
|
|
||||||
|
|
||||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled for whole session');
|
|
||||||
assert.ok(stream.__stopped.includes('video'), 'unused video track stopped');
|
|
||||||
assert.equal(sb.__createdContexts.at(-1)?.mediaStreamSource, stream, 'loopback stream captured');
|
|
||||||
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) });
|
|
||||||
const graph = makeStemsGraph();
|
|
||||||
sb.window.feedBack.stems.audioGraph = graph;
|
|
||||||
|
|
||||||
await sb.window._reevaluateRendererBus();
|
|
||||||
|
|
||||||
assert.equal(graph.context.sinkIdCalls.length, 0, 'stems ctx untouched — loopback owns capture');
|
|
||||||
assert.equal(sb.__createdContexts.at(-1)?.mediaStreamSource, stream, 'loopback engaged');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('suppressLocalAudioPlayback unsupported → page-mute fallback, unmuted on disengage', async () => {
|
|
||||||
let excl = true;
|
|
||||||
const stream = makeLoopbackStream({ suppressed: false });
|
|
||||||
const sb = makeSandbox({ exclusive: () => excl, displayMedia: () => Promise.resolve(stream) });
|
|
||||||
|
|
||||||
await sb.window._reevaluateRendererBus();
|
|
||||||
assert.deepEqual(sb.__calls.setPageMuted, [true], 'page muted as fallback');
|
|
||||||
|
|
||||||
excl = false;
|
|
||||||
await sb.window._reevaluateRendererBus();
|
|
||||||
assert.deepEqual(sb.__calls.setPageMuted, [true, false], 'page unmuted on disengage');
|
|
||||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getDisplayMedia rejected → sticky fallback to surface modes', async () => {
|
|
||||||
const sb = makeSandbox({
|
|
||||||
exclusive: () => true,
|
|
||||||
displayMedia: () => Promise.reject(new DOMException('denied', 'NotAllowedError')),
|
|
||||||
});
|
|
||||||
const graph = makeStemsGraph();
|
|
||||||
sb.window.feedBack.stems.audioGraph = graph;
|
|
||||||
|
|
||||||
await sb.window._reevaluateRendererBus(); // probe fails, latches unavailable
|
|
||||||
await sb.window._reevaluateRendererBus(); // falls back to stems
|
|
||||||
|
|
||||||
assert.equal(graph.context.sinkIdCalls.at(-1)?.type, 'none', 'stems fallback engaged');
|
|
||||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled via fallback');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('element capture collision (createMediaElementSource throws) → no poisoned state, clean retry', async () => {
|
|
||||||
const sb = makeSandbox({ exclusive: () => true }); // loopback unavailable
|
|
||||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
|
||||||
// First capture attempt collides (highway analyser owns the element).
|
|
||||||
let collide = true;
|
|
||||||
const origFactory = sb.AudioContext;
|
|
||||||
sb.__createdContexts.length = 0;
|
|
||||||
// Patch contexts so createMediaElementSource throws while colliding.
|
|
||||||
sb.AudioContext = function () {
|
|
||||||
const c = origFactory();
|
|
||||||
const orig = c.createMediaElementSource.bind(c);
|
|
||||||
c.createMediaElementSource = (el) => {
|
|
||||||
if (collide) throw new DOMException('already connected', 'InvalidStateError');
|
|
||||||
return orig(el);
|
|
||||||
};
|
|
||||||
c.close = () => Promise.resolve();
|
|
||||||
return c;
|
|
||||||
};
|
|
||||||
|
|
||||||
await reevaluateWithFallback(sb); // element engage fails (collision)
|
|
||||||
assert.ok(!sb.__calls.setRendererBus.some(([en]) => en === true), 'bus never left enabled');
|
|
||||||
|
|
||||||
collide = false;
|
|
||||||
await sb.window._reevaluateRendererBus(); // retry succeeds — no TypeError, fresh ctx
|
|
||||||
|
|
||||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'element engaged after collision cleared');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('engine stops → bus disabled', async () => {
|
test('engine stops → bus disabled', async () => {
|
||||||
let running = true;
|
let running = true;
|
||||||
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
|
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
|
||||||
|
|||||||
Reference in New Issue
Block a user