mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-09-11 11:14:31 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5429d31a2 | ||
|
|
11fb837907 | ||
|
|
f74901cc12 | ||
|
|
381dccc716 | ||
|
|
92c86f5393 | ||
|
|
6797c03603 | ||
|
|
c223ace419 | ||
|
|
ff7e855e35 | ||
|
|
8f53ea11ae | ||
|
|
3d34469535 | ||
|
|
c02239813b | ||
|
|
403024f37b | ||
|
|
f68bf6b2fa | ||
|
|
a732523e7a | ||
|
|
ef2093f8ad | ||
|
|
ec63235ecd |
@@ -2386,7 +2386,25 @@ app.include_router(ws_highway.router)
|
||||
|
||||
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
class _RevalidatedStaticFiles(StaticFiles):
|
||||
"""StaticFiles that forces conditional revalidation on every request.
|
||||
|
||||
Without Cache-Control, Chromium applies heuristic freshness (10% of the
|
||||
file's age since Last-Modified) and serves /static/app.js from its disk
|
||||
cache for hours-to-days without asking the server. In the desktop app that
|
||||
meant a new build's renderer ran the PREVIOUS build's app.js — the
|
||||
2026-07-11 ASIO investigation lost a day to a stale loader that couldn't
|
||||
even load module plugins. `no-cache` does NOT disable caching: the browser
|
||||
keeps the cached copy and revalidates with If-None-Match; unchanged files
|
||||
still cost only a 304."""
|
||||
|
||||
async def get_response(self, path, scope):
|
||||
response = await super().get_response(path, scope)
|
||||
response.headers.setdefault("Cache-Control", "no-cache")
|
||||
return response
|
||||
|
||||
|
||||
app.mount("/static", _RevalidatedStaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
+227
-14
@@ -4852,9 +4852,37 @@ window.jucePlayer = jucePlayer;
|
||||
// on a dead JUCE backing transport. This watcher migrates the loaded song
|
||||
// between the two paths whenever the engine's running state changes, preserving
|
||||
// playback position and play/pause state.
|
||||
// [asio-diag] global error tap: the 2026-07-11 tester log showed an uncaught
|
||||
// SyntaxError with no source location and the routing watcher/feeder never
|
||||
// installing — an error event carries filename:line even for parse errors in
|
||||
// other scripts, which console output does not. Gated on the desktop --debug
|
||||
// flag via window._asioDiagEnabled (installed just below; resolves async, so
|
||||
// errors thrown in the first ~second of a debug run may be missed — the
|
||||
// stale-cache class of failure reproduces on every later tick anyway).
|
||||
window.addEventListener('error', (e) => {
|
||||
if (!window._asioDiagEnabled?.()) return;
|
||||
console.warn('[asio-diag] uncaught-error:', e.message,
|
||||
'at', (e.filename || '<unknown>') + ':' + (e.lineno || 0) + ':' + (e.colno || 0));
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
if (!window._asioDiagEnabled?.()) return;
|
||||
const r = e.reason;
|
||||
console.warn('[asio-diag] unhandled-rejection:',
|
||||
(r && (r.name + ': ' + r.message)) || String(r));
|
||||
});
|
||||
|
||||
(function _installJuceEngineRoutingWatcher() {
|
||||
const juceApi = window.feedBackDesktop?.audio;
|
||||
if (!juceApi || typeof juceApi.isAudioRunning !== 'function') return;
|
||||
if (!juceApi || typeof juceApi.isAudioRunning !== 'function') {
|
||||
// Desktop bridge present but audio API incomplete — the whole
|
||||
// exclusive reroute chain is dead and this line is the only witness.
|
||||
// (Docker sphere has no bridge at all: stay silent, nothing to
|
||||
// diagnose there and no debug flag to gate on.)
|
||||
if (window.feedBackDesktop) {
|
||||
console.log('[asio-diag] routing watcher NOT installed (audio api incomplete)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let _rerouteInFlight = false;
|
||||
// URL that JUCE's loadBackingTrack *explicitly rejected* (ok === false —
|
||||
@@ -4883,7 +4911,13 @@ window.jucePlayer = jucePlayer;
|
||||
// renderer-bus feeder below via window._asioDiagEnabled.
|
||||
let _asioDiag = false;
|
||||
if (typeof juceApi.debugEnabled === 'function') {
|
||||
juceApi.debugEnabled().then((v) => { _asioDiag = !!v; }).catch(() => {});
|
||||
juceApi.debugEnabled().then((v) => {
|
||||
_asioDiag = !!v;
|
||||
// Deferred install line: the flag resolves async, so logging at
|
||||
// IIFE entry would race it. Change-detection isn't needed — this
|
||||
// runs once per page load.
|
||||
if (_asioDiag) console.log('[asio-diag] routing watcher installed');
|
||||
}).catch(() => {});
|
||||
}
|
||||
window._asioDiagEnabled = () => _asioDiag;
|
||||
async function _outputIsExclusive() {
|
||||
@@ -5305,7 +5339,23 @@ window.jucePlayer = jucePlayer;
|
||||
(function _installRendererBusFeeder() {
|
||||
const api = window.feedBackDesktop?.audio;
|
||||
if (!api || typeof api.setRendererBus !== 'function'
|
||||
|| typeof api.pushRendererAudio !== 'function') return;
|
||||
|| typeof api.pushRendererAudio !== 'function') {
|
||||
// Silent in the Docker sphere (no bridge, no debug flag); a desktop
|
||||
// bridge missing the bus API is the diagnostic case.
|
||||
if (window.feedBackDesktop) {
|
||||
console.log('[asio-diag] renderer-bus feeder NOT installed (api=' + !!api
|
||||
+ ' setRendererBus=' + typeof api?.setRendererBus
|
||||
+ ' pushRendererAudio=' + typeof api?.pushRendererAudio + ')');
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Deferred like the watcher's install line: gate on the async debug flag.
|
||||
if (typeof api.debugEnabled === 'function') {
|
||||
api.debugEnabled().then((v) => {
|
||||
if (v) console.log('[asio-diag] renderer-bus feeder installed (loopback-capable='
|
||||
+ (typeof window.navigator?.mediaDevices?.getDisplayMedia === 'function') + ')');
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
const TAP_WORKLET = `
|
||||
class FeedbackBusTap extends AudioWorkletProcessor {
|
||||
@@ -5375,15 +5425,94 @@ window.jucePlayer = jucePlayer;
|
||||
if (_elCtx) return;
|
||||
const el = document.getElementById('audio');
|
||||
if (!el) throw new Error('no core audio element');
|
||||
_elCtx = new AudioContext();
|
||||
_elSource = _elCtx.createMediaElementSource(el);
|
||||
_elSource.connect(_elCtx.destination);
|
||||
_elTap = _makeTap(_elCtx);
|
||||
await _elTap.attach(_elSource);
|
||||
// Assign the module state ONLY after the whole chain succeeded.
|
||||
// createMediaElementSource throws InvalidStateError when another
|
||||
// consumer (highway_3d's analyser tap) already owns the element's
|
||||
// one-shot source — assigning _elCtx before that throw poisoned every
|
||||
// later tick into `_elTap.active` TypeErrors (tester log 2026-07-11)
|
||||
// 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 ─────────────────────────────────────────────
|
||||
// 'off' | 'element' | 'stems'
|
||||
// 'off' | 'loopback' | 'element' | 'stems' (element/stems = fallback when
|
||||
// loopback capture is unavailable: old desktop main, denied capture)
|
||||
let _mode = 'off';
|
||||
let _stemsGraph = null; // { context, masterNode } snapshot while engaged
|
||||
let _stemsTap = null;
|
||||
@@ -5408,7 +5537,9 @@ window.jucePlayer = jucePlayer;
|
||||
const prev = _mode;
|
||||
_mode = 'off';
|
||||
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
|
||||
if (prev === 'element' && _elCtx) {
|
||||
if (prev === 'loopback') {
|
||||
await _teardownLoopback();
|
||||
} else if (prev === 'element' && _elCtx) {
|
||||
_elTap.active = false;
|
||||
await _setSink(_elCtx, false).catch(() => {});
|
||||
} else if (prev === 'stems' && _stemsGraph) {
|
||||
@@ -5467,9 +5598,20 @@ window.jucePlayer = jucePlayer;
|
||||
|
||||
let want = 'off';
|
||||
if (running && exclusive) {
|
||||
if (stems) want = 'stems';
|
||||
// Loopback covers ALL app audio (song, previews, UI), so it
|
||||
// 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';
|
||||
}
|
||||
// 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 —
|
||||
// steady state must not flood the buffer). This is the feeder-side
|
||||
@@ -5481,6 +5623,7 @@ window.jucePlayer = jucePlayer;
|
||||
+ ' stems=' + !!stems + ' songAudio=' + !!songAudio
|
||||
+ ' juceMode=' + !!window._juceMode
|
||||
+ ' elementSong=' + elementSong
|
||||
+ ' loopbackUnavailable=' + _loopbackUnavailable
|
||||
+ ' want=' + want + ' mode=' + _mode;
|
||||
if (d !== window._lastRendererBusDecision) {
|
||||
window._lastRendererBusDecision = d;
|
||||
@@ -5492,12 +5635,33 @@ window.jucePlayer = jucePlayer;
|
||||
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
|
||||
if (want !== _mode || stemsGraphChanged) {
|
||||
await _disengage();
|
||||
if (want === 'stems') await _engageStems(stems);
|
||||
else if (want === 'element') await _engageElement();
|
||||
try {
|
||||
if (want === 'loopback') await _engageLoopback();
|
||||
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) {
|
||||
console.warn('[renderer-bus] reevaluate failed (will retry):', e);
|
||||
// Explicit name/message/stack head — the console-message forward
|
||||
// stringifies a DOMException to the useless "[object DOMException]".
|
||||
console.warn('[renderer-bus] reevaluate failed (will retry):',
|
||||
(e && e.name ? e.name + ': ' + e.message : String(e)),
|
||||
(e && e.stack ? '| ' + String(e.stack).split('\n')[1] : ''));
|
||||
_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 {
|
||||
_busy = false;
|
||||
}
|
||||
@@ -12165,3 +12329,52 @@ async function bootstrapPluginsAndUi() {
|
||||
})
|
||||
.catch(() => {});
|
||||
})();
|
||||
|
||||
|
||||
// ─── The window contract ────────────────────────────────────────────────────
|
||||
// app.js is a classic script today, so every top-level `function foo()` here is
|
||||
// implicitly a property of `window`. The R3a migration turns this file into an
|
||||
// ES module, where that stops being true — module scope is not global scope, and
|
||||
// each of these names would silently vanish from `window`.
|
||||
//
|
||||
// Everything below is reached by NAME from outside this file, so each one is
|
||||
// made explicit BEFORE the flip. While app.js is still classic this whole block
|
||||
// is a no-op (it just re-assigns what is already there), which is exactly what
|
||||
// makes it safe to land on its own.
|
||||
//
|
||||
// The consumers are: inline on*= handlers in static/v3/index.html; on*= handlers
|
||||
// this file builds inside template literals; static/v3/*.js; the capabilities;
|
||||
// bundled plugins; and — easy to forget, since they live in other repos —
|
||||
// feedback-desktop and the external plugins. Constitution II names
|
||||
// `window.playSong` / `window.showScreen` / `window.feedBack` as the public
|
||||
// extension contract.
|
||||
//
|
||||
// Guarded by tests/js/window_contract.test.js. Add a name here the moment
|
||||
// anything outside app.js calls it.
|
||||
Object.assign(window, {
|
||||
_confirmDialog, _getArrangementNamingMode, _libraryLocalFilename, _librarySongArtUrl,
|
||||
_librarySongId, _onHeaderClick, _onNamingModeChange, _trapFocusInModal,
|
||||
changeArrangement, checkPluginUpdates, clearLibFilters, clearLoop,
|
||||
deleteSelectedLoop, exportDiagnostics, exportSettings, filterFavorites,
|
||||
filterLibrary, fullRescanLibrary, goFavPage, handleSliderInput,
|
||||
hideScanBanner, importSettings, loadPlugins, loadSavedLoop,
|
||||
loadSettings, onSectionPracticeModeChange, openEditModal, persistSetting,
|
||||
pickDlcFolder, pinCurrentArrangementDefault, playSong, previewDiagnostics,
|
||||
previewEditArt, renderGridCards, renderTreeInto, rescanLibrary,
|
||||
retuneSong, saveCurrentLoop, saveSettings, seekBy,
|
||||
setAvOffsetMs, setFavView, setInstrumentPathway, setLibView,
|
||||
setLibraryProvider, setLoopEnd, setLoopStart, setMastery,
|
||||
setSpeed, setViz, showScreen, sortFavorites,
|
||||
sortLibrary, syncLibrarySong, toggleAllArtists, toggleAllFavoriteArtists,
|
||||
toggleLibFilters, togglePlay, toggleSectionPracticePopover, uiPrompt,
|
||||
updatePlugin, uploadSongs,
|
||||
|
||||
// These four are invisible to every static scan. app.js:2156-2157 picks the
|
||||
// handler NAME at runtime —
|
||||
// const letterFn = favoritesOnly ? 'filterFavTreeLetter' : 'filterTreeLetter';
|
||||
// — and interpolates it: `onclick="${letterFn}('A')"`. So the names never
|
||||
// appear as identifiers anywhere, and ESLint / no-undef / a grep for
|
||||
// `onclick="fn` all miss them. They are the library A-Z rail and its
|
||||
// pagination; drop one and those buttons throw at click time, nowhere else.
|
||||
filterFavTreeLetter, filterTreeLetter, goFavTreePage, goTreePage,
|
||||
});
|
||||
|
||||
+13
-13
@@ -119,19 +119,19 @@
|
||||
script logs anything; load it as early as possible. See
|
||||
docs/diagnostics-bundle-spec.md (feedBack#166). -->
|
||||
<script defer src="/static/diagnostics.js"></script>
|
||||
<script defer src="/static/capabilities.js"></script>
|
||||
<script defer src="/static/capabilities/library.js"></script>
|
||||
<script defer src="/static/capabilities/tuning.js"></script>
|
||||
<script defer src="/static/capabilities/working-tuning.js"></script>
|
||||
<script defer src="/static/capabilities/audio-session.js"></script>
|
||||
<script defer src="/static/capabilities/audio-effects.js"></script>
|
||||
<script defer src="/static/capabilities/playback.js"></script>
|
||||
<script type="module" src="/static/capabilities.js"></script>
|
||||
<script type="module" src="/static/capabilities/library.js"></script>
|
||||
<script type="module" src="/static/capabilities/tuning.js"></script>
|
||||
<script type="module" src="/static/capabilities/working-tuning.js"></script>
|
||||
<script type="module" src="/static/capabilities/audio-session.js"></script>
|
||||
<script type="module" src="/static/capabilities/audio-effects.js"></script>
|
||||
<script type="module" src="/static/capabilities/playback.js"></script>
|
||||
<!-- fee[dB]ack v0.3.0: ui.library-card-injection capability (plugin card actions). -->
|
||||
<script defer src="/static/capabilities/library-card-actions.js"></script>
|
||||
<script defer src="/static/capabilities/visualization.js"></script>
|
||||
<script defer src="/static/capabilities/note-detection.js"></script>
|
||||
<script defer src="/static/capabilities/midi-input.js"></script>
|
||||
<script defer src="/static/capabilities/interface-scale.js"></script>
|
||||
<script type="module" src="/static/capabilities/library-card-actions.js"></script>
|
||||
<script type="module" src="/static/capabilities/visualization.js"></script>
|
||||
<script type="module" src="/static/capabilities/note-detection.js"></script>
|
||||
<script type="module" src="/static/capabilities/midi-input.js"></script>
|
||||
<script type="module" src="/static/capabilities/interface-scale.js"></script>
|
||||
</head>
|
||||
<body class="h-screen flex overflow-hidden bg-fb-sidebar text-fb-text font-display">
|
||||
|
||||
@@ -1244,7 +1244,7 @@
|
||||
<script defer src="/static/highway.js"></script>
|
||||
<script defer src="/static/vendor/lottie.min.js"></script>
|
||||
<script defer src="/static/lottie-api.js"></script>
|
||||
<script defer src="/static/app.js"></script>
|
||||
<script type="module" src="/static/app.js"></script>
|
||||
<script defer src="/static/audio-mixer.js"></script>
|
||||
<script defer src="/static/vendor/shepherd.min.js"></script>
|
||||
<script defer src="/static/tour-engine.js"></script>
|
||||
|
||||
@@ -50,17 +50,42 @@ function makeFakeContext(sampleRate = 48000) {
|
||||
this.mediaSourceEl = el;
|
||||
return { connect() {}, disconnect() {} };
|
||||
},
|
||||
createMediaStreamSource(stream) {
|
||||
this.mediaStreamSource = stream;
|
||||
return { connect() {}, disconnect() {} };
|
||||
},
|
||||
close() { this.closed = true; return Promise.resolve(); },
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
|
||||
const calls = { setRendererBus: [], pushRendererAudio: [] };
|
||||
// Fake getDisplayMedia stream for the loopback-capture path.
|
||||
function makeLoopbackStream({ suppressed = true } = {}) {
|
||||
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 = {
|
||||
isAudioRunning: () => Promise.resolve(isAudioRunning()),
|
||||
setRendererBus: (en, g) => { calls.setRendererBus.push([en, g]); return Promise.resolve(); },
|
||||
pushRendererAudio: (buf, rate) => { calls.pushRendererAudio.push([buf.length, rate]); },
|
||||
setPageMuted: (m) => { calls.setPageMuted.push(m); return Promise.resolve(m); },
|
||||
};
|
||||
|
||||
class FakeWorkletNode {
|
||||
@@ -85,6 +110,7 @@ function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {
|
||||
__createdContexts: [],
|
||||
__audioEl: { id: 'audio' },
|
||||
__calls: calls,
|
||||
navigator: { mediaDevices: displayMedia ? { getDisplayMedia: displayMedia } : {} },
|
||||
window: null,
|
||||
};
|
||||
sandbox.window = {
|
||||
@@ -111,12 +137,21 @@ function makeStemsGraph() {
|
||||
};
|
||||
}
|
||||
|
||||
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
|
||||
// Surface-mode (stems/element) tests run WITHOUT getDisplayMedia: the first
|
||||
// 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 graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
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');
|
||||
@@ -128,7 +163,7 @@ test('output returns to shared → bus disabled, sink restored', async () => {
|
||||
const graph = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = graph;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
excl = false;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
|
||||
@@ -145,26 +180,27 @@ 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');
|
||||
});
|
||||
|
||||
test('element song + exclusive → element captured into bus', async () => {
|
||||
test('element song + exclusive → element captured into bus (loopback unavailable)', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/api/sloppak/x.sloppak/file/stems/full.ogg' };
|
||||
sb.window._juceMode = false;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
assert.equal(sb.__createdContexts.length, 1, 'capture context created');
|
||||
assert.equal(sb.__createdContexts[0].mediaSourceEl, sb.__audioEl, 'element source captured');
|
||||
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 'bus enabled');
|
||||
});
|
||||
|
||||
test('song riding the native transport (_juceMode) → feeder stays off', async () => {
|
||||
test('native-transport song, loopback unavailable → surface modes stay off', async () => {
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
|
||||
sb.window._juceMode = true;
|
||||
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
|
||||
assert.ok(!sb.__calls.setRendererBus.some(([en]) => en === true),
|
||||
'bus never ENABLED (failed-probe cleanup may disable it)');
|
||||
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
|
||||
});
|
||||
|
||||
@@ -172,7 +208,7 @@ test('stems graph replaced mid-engagement → re-engages on the new graph', asyn
|
||||
const sb = makeSandbox({ exclusive: () => true });
|
||||
const g1 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g1;
|
||||
await sb.window._reevaluateRendererBus();
|
||||
await reevaluateWithFallback(sb);
|
||||
|
||||
const g2 = makeStemsGraph();
|
||||
sb.window.feedBack.stems.audioGraph = g2;
|
||||
@@ -182,6 +218,105 @@ 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');
|
||||
});
|
||||
|
||||
// ── 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 () => {
|
||||
let running = true;
|
||||
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Guards app.js's `window` contract ahead of the R3a ES-module flip.
|
||||
//
|
||||
// app.js is a classic script, so every top-level `function foo()` is implicitly
|
||||
// a property of `window`. As an ES module it will not be — module scope is not
|
||||
// global scope. Any name reached from OUTSIDE app.js must therefore be an
|
||||
// explicit `window.foo = …` before the flip, or it vanishes silently.
|
||||
//
|
||||
// "Silently" is the whole problem. A missing inline handler is a ReferenceError
|
||||
// only when someone clicks the button; a `typeof window.setViz !== 'function'`
|
||||
// guard (capabilities/visualization.js) just degrades and says nothing. Neither
|
||||
// shows up in a test run, so this file is the thing standing between a dropped
|
||||
// name and a dead button in production.
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const APP_JS = fs.readFileSync(path.join(ROOT, 'static', 'app.js'), 'utf8');
|
||||
const V3_HTML = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'index.html'), 'utf8');
|
||||
|
||||
// Every name app.js publishes: the scattered `window.foo = …` assignments plus
|
||||
// the consolidated `Object.assign(window, { … })` contract block at the bottom.
|
||||
function exposedNames() {
|
||||
const names = new Set(
|
||||
[...APP_JS.matchAll(/^window\.([A-Za-z_$][\w$]*)\s*=/gm)].map((m) => m[1]),
|
||||
);
|
||||
const block = APP_JS.match(/Object\.assign\(window, \{([\s\S]*?)\n\}\);/);
|
||||
assert.ok(block, 'the Object.assign(window, …) contract block is missing from app.js');
|
||||
// Strip the comments first — the prose inside them is full of words that
|
||||
// would otherwise scrape as identifiers.
|
||||
const body = block[1].replace(/\/\/[^\n]*/g, '');
|
||||
for (const m of body.matchAll(/([A-Za-z_$][\w$]*)\s*(?=,|$)/gm)) names.add(m[1]);
|
||||
return names;
|
||||
}
|
||||
|
||||
// app.js's own top-level `function foo()` declarations — the names that stop
|
||||
// being global under `type="module"`.
|
||||
function topLevelFunctions() {
|
||||
return new Set(
|
||||
[...APP_JS.matchAll(/^(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/gm)].map((m) => m[1]),
|
||||
);
|
||||
}
|
||||
|
||||
const HANDLER = /on(?:click|change|input|submit|keyup|keydown|mousedown|error|focus|blur)\s*=\s*"([A-Za-z_$][\w$]*)/g;
|
||||
|
||||
test('every inline on*= handler in the v3 shell is on window', () => {
|
||||
const exposed = exposedNames();
|
||||
const owned = topLevelFunctions();
|
||||
const missing = [...V3_HTML.matchAll(HANDLER)]
|
||||
.map((m) => m[1])
|
||||
.filter((n) => owned.has(n) && !exposed.has(n));
|
||||
assert.deepEqual([...new Set(missing)], [], 'inline handlers that would break under type="module"');
|
||||
});
|
||||
|
||||
test('every on*= handler app.js builds in a template literal is on window', () => {
|
||||
// e.g. `<button onclick="goFavPage(${p})">` — these resolve against window at
|
||||
// CLICK time, exactly like the ones written into the HTML, but they live in a
|
||||
// JS string so scanning index.html alone never finds them.
|
||||
const exposed = exposedNames();
|
||||
const owned = topLevelFunctions();
|
||||
const missing = [...APP_JS.matchAll(HANDLER)]
|
||||
.map((m) => m[1])
|
||||
.filter((n) => owned.has(n) && !exposed.has(n));
|
||||
assert.deepEqual([...new Set(missing)], [], 'generated handlers that would break under type="module"');
|
||||
});
|
||||
|
||||
test('the runtime-composed handler names are on window', () => {
|
||||
// app.js:2156-2157 chooses the handler NAME at runtime:
|
||||
// const letterFn = favoritesOnly ? 'filterFavTreeLetter' : 'filterTreeLetter';
|
||||
// const pageFn = favoritesOnly ? 'goFavTreePage' : 'goTreePage';
|
||||
// then interpolates it: `onclick="${letterFn}('A')"`.
|
||||
//
|
||||
// ponytail: hardcoded on purpose. These names exist only inside string
|
||||
// literals, so the two scans above cannot see them, and neither can ESLint,
|
||||
// no-undef, or a grep for `onclick="fn`. They are the library A–Z rail and
|
||||
// its pagination — drop one and those buttons throw on click and nowhere
|
||||
// else. If that ternary ever gains a branch, add the new name here too.
|
||||
const exposed = exposedNames();
|
||||
for (const name of ['filterTreeLetter', 'filterFavTreeLetter', 'goTreePage', 'goFavTreePage']) {
|
||||
assert.ok(exposed.has(name), `window.${name} is required by the runtime-composed A–Z rail / pagination handlers`);
|
||||
}
|
||||
});
|
||||
|
||||
test('cross-file window.* readers still resolve', () => {
|
||||
// Names other core scripts read off window. capabilities/visualization.js is
|
||||
// the cautionary one: it reads window.setViz behind a `typeof` guard, so
|
||||
// losing it degrades the visualization capability in SILENCE rather than
|
||||
// throwing.
|
||||
const exposed = exposedNames();
|
||||
for (const name of ['setViz', 'showScreen', 'playSong', 'uiPrompt', '_confirmDialog', 'loadPlugins']) {
|
||||
assert.ok(exposed.has(name), `window.${name} is read by another file`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user