Compare commits

...
Author SHA1 Message Date
OmikronApex a732523e7a Merge remote-tracking branch 'origin/main' into feat/renderer-bus-phase2 2026-07-09 22:36:58 +02:00
1b3178037b feat(audio): route feedpak full-mix natively under exclusive output (#824)
Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:18:00 +02:00
845255e404 fix(audio-effects): accept pre-rebrand chain plan schema as alias (#816)
* fix(playback): throttle legacy bridge-hit recording; emit loop-set for manual A/B

window.feedBack.getLoop() is a read surface plugins legitimately poll
(note_detect HUD ticked it at ~30 Hz), but every call recorded a
playback.loop-api bridge hit: compat-shim bookkeeping, a
playback:bridge-hit event, and a diagnostics snapshot rebuild +
stringify per call — real main-thread cost and a saturated hitCount in
the capability inspector, even with no song playing.

- _recordPlaybackBridge now throttles per bridgeId|surface (5 s window).
  Bridge hits are a 'surface still in use' signal, not a call counter.
- setLoopEnd() (manual A/B buttons) now emits the same loop-set
  transport event as setLoop(), so event-driven consumers no longer
  need to poll getLoop() to see button-armed loops.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changelog): note loop-api bridge throttle fix

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audio-effects): accept pre-rebrand chain plan schema as alias

The rebrand renamed PLAN_SCHEMA to 'feedBack.audio_effects.chain_plan.v1'
but shipped plugin bundles (rig_builder <= 2.9.x) still send the
slopsmith-era id, so _validatePlan rejected every plan and providers fell
back to their heavyweight legacy load paths (full chain rebuild per poll
cycle — audible as continuous distortion during songs). Accept the old id
as an explicit alias.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 22:17:57 +02:00
OmikronApexandClaude Fable 5 ef2093f8ad feat(audio): renderer-bus feeder — mix renderer song audio into engine output (Phase 2)
Under exclusive-style output the native backing transport (Phase 1, #824)
carries loose /audio/ songs and feedpak full-mixes, but not the stems
plugin's multi-stem WebAudio graph or tracks JUCE rejected. The feeder taps
the renderer-side master with an AudioWorklet, re-points the owning
AudioContext at a null sink so it keeps rendering without a device, and
pushes ~10 ms chunks over IPC into the desktop engine's renderer bus
(feedBack-desktop#90 follow-up). Inert in the Docker sphere and in shared
mode. Validated by the fix12 tester spike: null-sink rendering works,
clocks hold, no overflow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 20:47:35 +02:00
OmikronApexandClaude Fable 5 ec63235ecd feat(audio): route feedpak full-mix natively under exclusive output
Song playback runs through the renderer, which WASAPI-exclusive (and
ASIO) output silences. Route single-mix feedpaks (stem-less
original_audio packs AND single-stem packs) onto the engine's backing
transport when the output device type is exclusive-style, and migrate
back to HTML5 when it isn't. Extends /api/audio-local-path to resolve
/api/sloppak/.../file/... URLs via the same containment guards as
serve_sloppak_file. Multi-stem packs stay on the WebAudio path
(Phase 2). Includes [feedpak-route] transition-gated diagnostics
logging.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:33:28 +02:00
7 changed files with 763 additions and 50 deletions
+54 -29
View File
@@ -12941,53 +12941,57 @@ _extract_cache = {} # filename -> (tmp_dir, song, timestamp)
_extract_cache_lock = threading.Lock() _extract_cache_lock = threading.Lock()
@app.get("/api/sloppak/{filename:path}/file/{rel_path:path}") def _resolve_sloppak_local_file(filename: str, rel_path: str):
def serve_sloppak_file(filename: str, rel_path: str): """Resolve a file inside a sloppak to its on-disk path.
"""Serve a file from inside a sloppak (stems, cover, etc.)."""
Applies the same containment guards as ``serve_sloppak_file``. Returns the
resolved ``Path`` on success, or an ``(error, status)`` tuple on failure so
callers can produce their endpoint-appropriate response.
"""
dlc = _get_dlc_dir() dlc = _get_dlc_dir()
if not dlc: if not dlc:
return JSONResponse({"error": "not configured"}, 404) return ("not configured", 404)
# `filename` is an attacker-controlled `:path` param. Contain it under # `filename` is caller-controlled. Contain it under DLC_DIR before it
# DLC_DIR before it reaches the resolver, which does a bare # reaches the resolver (see serve_sloppak_file for the traversal rationale).
# `dlc_root / filename`. Without this, `../../../etc` escapes the root
# and the rel_path guard below validates `target` against the already-
# escaped `src`, which trivially passes — yielding arbitrary file reads
# (e.g. /api/sloppak/../../../../etc/file/passwd). Mirrors the guard
# `get_song_art` applies to the same filename param.
resolved = _resolve_dlc_path(dlc, filename) resolved = _resolve_dlc_path(dlc, filename)
if resolved is None: if resolved is None:
return JSONResponse({"error": "forbidden"}, 403) return ("forbidden", 403)
# Confine the endpoint to actual sloppak bundles. Without this, a # Confine to actual sloppak bundles — otherwise any plain subdirectory
# contained-but-non-sloppak `filename` (e.g. `.` → DLC_DIR itself, or # would become a read-any-file-under-DLC_DIR source.
# any plain subdirectory) would make `resolve_source_dir` hand back a
# directory and turn this into a read-any-file-under-DLC_DIR endpoint.
# Mirrors get_song_art's `is_sloppak` dispatch.
if not sloppak_mod.is_sloppak(resolved): if not sloppak_mod.is_sloppak(resolved):
return JSONResponse({"error": "not found"}, 404) return ("not found", 404)
# Canonicalise the cache key against the resolved path so equivalent # Canonicalise the cache key against the resolved path so equivalent URL
# URL forms of the same sloppak (e.g. `A/../B/x.sloppak` vs # forms of the same sloppak converge on one _source_cache entry.
# `B/x.sloppak`) converge on one `_source_cache` entry instead of
# fragmenting / re-unpacking — mirrors get_song_info's keying.
try: try:
filename = resolved.relative_to(dlc.resolve()).as_posix() filename = resolved.relative_to(dlc.resolve()).as_posix()
except ValueError: except ValueError:
# safe_join already proved containment, so this is unreachable in # safe_join already proved containment; fail closed regardless.
# practice; fail closed rather than fall back to the raw param. return ("forbidden", 403)
return JSONResponse({"error": "forbidden"}, 403)
src = sloppak_mod.get_cached_source_dir(filename) src = sloppak_mod.get_cached_source_dir(filename)
if src is None: if src is None:
try: try:
src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR) src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR)
except Exception: except Exception:
return JSONResponse({"error": "not found"}, 404) return ("not found", 404)
# Prevent path traversal within the sloppak. # Prevent path traversal within the sloppak.
target = (src / rel_path).resolve() target = (src / rel_path).resolve()
try: try:
target.relative_to(src.resolve()) target.relative_to(src.resolve())
except ValueError: except ValueError:
return JSONResponse({"error": "forbidden"}, 403) return ("forbidden", 403)
if not target.exists() or not target.is_file(): if not target.exists() or not target.is_file():
return JSONResponse({"error": "not found"}, 404) return ("not found", 404)
return target
@app.get("/api/sloppak/{filename:path}/file/{rel_path:path}")
def serve_sloppak_file(filename: str, rel_path: str):
"""Serve a file from inside a sloppak (stems, cover, etc.)."""
result = _resolve_sloppak_local_file(filename, rel_path)
if isinstance(result, tuple):
error, status = result
return JSONResponse({"error": error}, status)
target = result
ext = target.suffix.lower() ext = target.suffix.lower()
mt = { mt = {
".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg", ".ogg": "audio/ogg", ".opus": "audio/ogg", ".oga": "audio/ogg",
@@ -13910,13 +13914,19 @@ async def highway_ws(websocket: WebSocket, filename: str, arrangement: int = -1,
@app.get("/api/audio-local-path") @app.get("/api/audio-local-path")
def audio_local_path(url: str, request: Request): def audio_local_path(url: str, request: Request):
"""Return absolute local filesystem path for an /audio/… URL (Electron desktop only). """Return absolute local filesystem path for a song URL (Electron desktop only).
Accepts ``/audio/<path>`` where ``<path>`` may include subdirectory segments Accepts ``/audio/<path>`` where ``<path>`` may include subdirectory segments
no scheme, no host, no query string, no fragment. The resolved path must stay no scheme, no host, no query string, no fragment. The resolved path must stay
inside AUDIO_CACHE_DIR or STATIC_DIR; ``..`` traversal, backslashes, and inside AUDIO_CACHE_DIR or STATIC_DIR; ``..`` traversal, backslashes, and
absolute ``filename`` values are rejected. absolute ``filename`` values are rejected.
Also accepts ``/api/sloppak/<filename>/file/<rel>`` (percent-encoded, as
emitted by the highway song payload) and resolves it to the unpacked
sloppak cache file via the same containment guards as
``serve_sloppak_file`` this lets the desktop engine play a feedpak
full-mix natively under WASAPI-exclusive output.
This endpoint returns a raw filesystem path and is intended exclusively for This endpoint returns a raw filesystem path and is intended exclusively for
the Electron desktop process (which runs on loopback). Requests from non- the Electron desktop process (which runs on loopback). Requests from non-
loopback clients are rejected with 403. loopback clients are rejected with 403.
@@ -13929,6 +13939,21 @@ def audio_local_path(url: str, request: Request):
is_loopback = client_host == "localhost" is_loopback = client_host == "localhost"
if not is_loopback: if not is_loopback:
return JSONResponse({"error": "forbidden"}, status_code=403) return JSONResponse({"error": "forbidden"}, status_code=403)
# Sloppak in-pack file (feedpak full-mix): /api/sloppak/<fn>/file/<rel>.
# Both segments arrive percent-encoded (built with urllib quote() in the
# highway payload); decode before handing to the shared resolver, which
# re-applies all containment guards on the decoded values.
slop_match = re.fullmatch(r"/api/sloppak/([^?#]+)/file/([^?#]+)", url)
if slop_match:
from urllib.parse import unquote
result = _resolve_sloppak_local_file(
unquote(slop_match.group(1)), unquote(slop_match.group(2))
)
if isinstance(result, tuple):
error, status = result
return JSONResponse({"error": error}, status_code=status)
return JSONResponse({"path": str(result)})
# Accept only simple /audio/<filename> — no scheme, no host, no query/fragment # Accept only simple /audio/<filename> — no scheme, no host, no query/fragment
if not re.fullmatch(r"/audio/[^?#]+", url): if not re.fullmatch(r"/audio/[^?#]+", url):
return JSONResponse({"error": "invalid url"}, status_code=400) return JSONResponse({"error": "invalid url"}, status_code=400)
+280 -10
View File
@@ -4864,6 +4864,47 @@ window.jucePlayer = jucePlayer;
// (a network blip on /api/audio-local-path, an isAudioRunning() race // (a network blip on /api/audio-local-path, an isAudioRunning() race
// during a device restart) are deliberately NOT memoised so they retry. // during a device restart) are deliberately NOT memoised so they retry.
let _rerouteRejectedUrl = null; let _rerouteRejectedUrl = null;
// Exclusive-style output backends silence every other client on the
// endpoint — including our own <audio> element. The share mode IS the
// JUCE output device type: "Windows Audio (Exclusive Mode)" is a
// hardcoded, unlocalised JUCE type name; ASIO drivers typically hold
// the endpoint exclusively too. "Windows Audio (Low Latency Mode)" is
// shared and must NOT match.
function _isExclusiveOutputType(t) {
return t === 'Windows Audio (Exclusive Mode)' || t === 'ASIO';
}
// [feedpak-route] diagnostics: log the raw outputType string once per
// value change (this runs on a 350ms poll — logging every tick would
// flood the diagnostics buffer).
let _loggedOutputType;
async function _outputIsExclusive() {
if (typeof juceApi.getCurrentDevice !== 'function') {
if (_loggedOutputType !== '<no-getCurrentDevice>') {
_loggedOutputType = '<no-getCurrentDevice>';
console.warn('[feedpak-route] juceApi.getCurrentDevice missing — cannot detect exclusive output');
}
return false;
}
try {
const dev = await juceApi.getCurrentDevice();
const t = dev?.outputType || dev?.type || '';
const excl = _isExclusiveOutputType(t);
if (t !== _loggedOutputType) {
_loggedOutputType = t;
console.log('[feedpak-route] outputType=', JSON.stringify(t), '→ exclusive=', excl);
}
return excl;
} catch (e) {
if (_loggedOutputType !== '<getCurrentDevice-failed>') {
_loggedOutputType = '<getCurrentDevice-failed>';
console.warn('[feedpak-route] getCurrentDevice failed:', e);
}
return false;
}
}
// highway.js's initial song-load routing consults this for the same
// feedpak-under-exclusive decision the watcher makes below.
window._juceOutputIsExclusive = _outputIsExclusive;
// Returns true when window._currentSongAudio no longer references the exact // Returns true when window._currentSongAudio no longer references the exact
// snapshot object captured at reroute entry — i.e. the song was swapped (or // snapshot object captured at reroute entry — i.e. the song was swapped (or
// cleared) mid-flight. Staleness is detected by object-reference identity, // cleared) mid-flight. Staleness is detected by object-reference identity,
@@ -4904,8 +4945,12 @@ window.jucePlayer = jucePlayer;
audio.pause(); audio.pause();
try { try {
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(url)}`); const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(url)}`);
if (!res.ok) throw new Error('HTTP ' + res.status); if (!res.ok) {
console.warn('[feedpak-route] audio-local-path HTTP', res.status, 'for', url);
throw new Error('HTTP ' + res.status);
}
const { path } = await res.json(); const { path } = await res.json();
console.log('[feedpak-route] audio-local-path resolved:', (typeof path === 'string' && path.split(/[\\/]/).pop()) || '<missing>');
if (_isStale(songAudio)) return 'stale'; // song changed mid-fetch if (_isStale(songAudio)) return 'stale'; // song changed mid-fetch
const ok = await juceApi.loadBackingTrack(path); const ok = await juceApi.loadBackingTrack(path);
if (ok === false) { if (ok === false) {
@@ -5123,8 +5168,12 @@ window.jucePlayer = jucePlayer;
async function _reevaluateJuceRouting() { async function _reevaluateJuceRouting() {
if (_rerouteInFlight) return; if (_rerouteInFlight) return;
const songAudio = window._currentSongAudio; const songAudio = window._currentSongAudio;
// Only /audio/ songs are JUCE-routable; sloppak stems stay on HTML5. // /audio/ songs are always JUCE-routable. A feedpak full-mix
if (!songAudio || !songAudio.juceEligible) return; // (single-mix pack, no stems) is routable ONLY under an
// exclusive-style output — in shared mode it must stay on HTML5 so
// the stem mixer / WebAudio path keeps working. Sloppak stem URLs
// are never routable (per-stem mix can't ride a single transport).
if (!songAudio || (!songAudio.juceEligible && !songAudio.feedpakFullMix)) return;
// Don't race highway.js's own initial song-load routing: it owns // Don't race highway.js's own initial song-load routing: it owns
// _juceMode until _juceRoutingPromise settles. Re-running our switch // _juceMode until _juceRoutingPromise settles. Re-running our switch
// concurrently would double-call loadBackingTrack for the same URL. // concurrently would double-call loadBackingTrack for the same URL.
@@ -5142,13 +5191,30 @@ window.jucePlayer = jucePlayer;
try { running = await juceApi.isAudioRunning(); } try { running = await juceApi.isAudioRunning(); }
catch (_) { return; } catch (_) { return; }
if (_isStale(songAudio)) return; // song changed during IPC if (_isStale(songAudio)) return; // song changed during IPC
if (!!running === !!window._juceMode) return; // routing already consistent // Eligibility is evaluated per tick, not snapshotted at song load:
// the output share mode can change mid-song (device switch in the
const wantJuce = running && !window._juceMode; // Audio Engine panel), and a feedpak full-mix must follow it —
// exclusive → ride the engine; back to shared → return to HTML5.
let eligible = !!songAudio.juceEligible;
if (!eligible && songAudio.feedpakFullMix && running) {
eligible = await _outputIsExclusive();
if (_isStale(songAudio)) return; // song changed during IPC
}
const wantJuce = !!(running && eligible);
// [feedpak-route] diagnostics: one line per decision change (the
// watcher polls at 350ms; steady state must not spam the buffer).
const _decision = 'running=' + running + ' eligible=' + eligible
+ ' feedpakFullMix=' + !!songAudio.feedpakFullMix
+ ' juceMode=' + !!window._juceMode + ' url=' + songAudio.url;
if (_decision !== window._lastFeedpakRouteDecision) {
window._lastFeedpakRouteDecision = _decision;
console.log('[feedpak-route] watcher:', _decision);
}
if (wantJuce === !!window._juceMode) return; // routing already consistent
// Don't keep retrying a track JUCE explicitly rejected. // Don't keep retrying a track JUCE explicitly rejected.
if (wantJuce && songAudio.url === _rerouteRejectedUrl) return; if (wantJuce && songAudio.url === _rerouteRejectedUrl) return;
if (running) { if (wantJuce) {
const outcome = await _switchHtml5ToJuce(songAudio); const outcome = await _switchHtml5ToJuce(songAudio);
// Memoise ONLY an explicit hard JUCE reject. A successful // Memoise ONLY an explicit hard JUCE reject. A successful
// switch clears the memo; a 'stale' abort (song changed // switch clears the memo; a 'stale' abort (song changed
@@ -5163,9 +5229,10 @@ window.jucePlayer = jucePlayer;
// outcome === 'stale': leave _rerouteRejectedUrl as-is. // outcome === 'stale': leave _rerouteRejectedUrl as-is.
} else { } else {
await _switchJuceToHtml5(songAudio); await _switchJuceToHtml5(songAudio);
// The engine just stopped. Clear any hard-reject memo so a // The engine stopped (or a feedpak's output left exclusive
// later engine restart re-evaluates the track at least once — // mode). Clear any hard-reject memo so a later engine restart
// the rejection may have been a transient device/decoder state. // or mode change re-evaluates the track at least once — the
// rejection may have been a transient device/decoder state.
_rerouteRejectedUrl = null; _rerouteRejectedUrl = null;
} }
} catch (e) { } catch (e) {
@@ -5197,6 +5264,209 @@ window.jucePlayer = jucePlayer;
}, 350); }, 350);
})(); })();
// Renderer-audio bus feeder (desktop Phase 2): when the engine holds the
// output endpoint in an exclusive-style mode, Chromium cannot reach the
// device, so any song audio still played by the renderer goes silent. The
// Phase 1 watcher above already migrates what a single-file transport can
// carry (loose /audio/ songs, feedpak full-mixes) onto the native backing
// transport. This feeder covers the rest — the stems plugin's multi-stem
// WebAudio graph, plus <audio>-element songs the native transport could not
// take (e.g. a codec loadBackingTrack rejected).
//
// Mechanism: capture the renderer-side master with an AudioWorklet tap,
// re-point the owning AudioContext at a null sink so it keeps rendering
// without a device, and push ~10 ms chunks over IPC into the engine's
// renderer bus, where they are mixed into the exclusive output like a
// backing track (~10-20 ms added latency on song audio only; the guitar
// monitoring path is untouched). Validated by the fix12 tester spike:
// null-sink rendering works, clocks hold (drift → 0), no overflow.
//
// Docker sphere: window.feedBackDesktop is undefined → this whole block is
// inert. Shared-mode desktop: the bus stays disabled (no double audio) and
// captured contexts keep/regain their default sink.
(function _installRendererBusFeeder() {
const api = window.feedBackDesktop?.audio;
if (!api || typeof api.setRendererBus !== 'function'
|| typeof api.pushRendererAudio !== 'function') return;
const TAP_WORKLET = `
class FeedbackBusTap extends AudioWorkletProcessor {
process(inputs) {
const inp = inputs[0];
if (inp && inp[0]) {
const L = inp[0], R = inp[1] || inp[0];
const out = new Float32Array(L.length * 2);
for (let i = 0; i < L.length; i++) { out[i*2] = L[i]; out[i*2+1] = R[i]; }
this.port.postMessage(out, [out.buffer]);
}
return true;
}
}
registerProcessor('feedback-bus-tap', FeedbackBusTap);
`;
const _tapModuleUrl = URL.createObjectURL(new Blob([TAP_WORKLET], { type: 'application/javascript' }));
const _tapModuleLoaded = new WeakSet(); // AudioContexts with the module added
// One tap per captured graph. `active` gates the push (the worklet keeps
// running when inactive — it's silent bookkeeping, not audio).
function _makeTap(ctx) {
const state = { node: null, active: false, batch: [], batchFrames: 0 };
state.attach = async (sourceNode) => {
if (!_tapModuleLoaded.has(ctx)) {
await ctx.audioWorklet.addModule(_tapModuleUrl);
_tapModuleLoaded.add(ctx);
}
if (!state.node) {
state.node = new AudioWorkletNode(ctx, 'feedback-bus-tap', { numberOfInputs: 1, channelCount: 2 });
const BATCH = Math.round(ctx.sampleRate / 100); // ~10 ms
state.node.port.onmessage = (e) => {
if (!state.active) { state.batch = []; state.batchFrames = 0; return; }
state.batch.push(e.data);
state.batchFrames += e.data.length / 2;
if (state.batchFrames >= BATCH) {
const merged = new Float32Array(state.batchFrames * 2);
let o = 0;
for (const c of state.batch) { merged.set(c, o); o += c.length; }
api.pushRendererAudio(merged, ctx.sampleRate);
state.batch = []; state.batchFrames = 0;
}
};
}
sourceNode.connect(state.node);
// No onward connection: the tap is a sink-side observer; audibility
// in shared mode comes from the graph's own destination path.
};
state.detach = (sourceNode) => {
state.active = false;
state.batch = []; state.batchFrames = 0;
if (state.node && sourceNode) {
try { sourceNode.disconnect(state.node); } catch (_) { /* already gone */ }
}
};
return state;
}
// ── Core <audio> element capture ─────────────────────────────────────────
// createMediaElementSource permanently reroutes the element into its
// context, so it is created lazily — only the first time an exclusive
// device actually needs it — and never torn down. From then on the element
// always plays through _elCtx; sink toggling routes it to the speakers
// (shared mode) or the null sink + bus (exclusive mode).
let _elCtx = null, _elSource = null, _elTap = null;
async function _ensureElementCapture() {
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);
}
// ── Engagement state machine ─────────────────────────────────────────────
// 'off' | 'element' | 'stems'
let _mode = 'off';
let _stemsGraph = null; // { context, masterNode } snapshot while engaged
let _stemsTap = null;
const _stemsTaps = new WeakMap(); // context → tap (stems ctx is reused across songs)
let _busy = false;
async function _setSink(ctx, exclusive) {
if (typeof ctx.setSinkId !== 'function') throw new Error('setSinkId unsupported');
await ctx.setSinkId(exclusive ? { type: 'none' } : '');
if (ctx.state !== 'running') await ctx.resume().catch(() => {});
}
async function _disengage() {
if (_mode === 'off') return;
const prev = _mode;
_mode = 'off';
try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ }
if (prev === 'element' && _elCtx) {
_elTap.active = false;
await _setSink(_elCtx, false).catch(() => {});
} else if (prev === 'stems' && _stemsGraph) {
if (_stemsTap) _stemsTap.detach(_stemsGraph.masterNode);
await _setSink(_stemsGraph.context, false).catch(() => {});
_stemsGraph = null; _stemsTap = null;
}
console.log('[renderer-bus] disengaged (' + prev + ')');
}
async function _engageStems(graph) {
await _setSink(graph.context, true);
let tap = _stemsTaps.get(graph.context);
if (!tap) { tap = _makeTap(graph.context); _stemsTaps.set(graph.context, tap); }
await tap.attach(graph.masterNode);
await api.setRendererBus(true, 1.0);
tap.active = true;
_stemsGraph = graph; _stemsTap = tap;
_mode = 'stems';
console.log('[renderer-bus] engaged: stems graph → engine bus');
}
async function _engageElement() {
await _ensureElementCapture();
await _setSink(_elCtx, true);
await api.setRendererBus(true, 1.0);
_elTap.active = true;
_mode = 'element';
console.log('[renderer-bus] engaged: <audio> element → engine bus');
}
async function _reevaluate() {
if (_busy) return;
_busy = true;
try {
let running = false, exclusive = false;
try {
running = await api.isAudioRunning();
} catch (_) { /* engine unreachable → treat as not running */ }
if (running) {
// Reuse the Phase 1 predicate installed by the routing watcher
// (getCurrentDevice + exclusive-type check with change-logged
// diagnostics). Fail closed if it is somehow absent.
exclusive = !!(await window._juceOutputIsExclusive?.());
}
// The stems plugin publishes its live graph while a multi-stem
// song is loaded (and removes it on teardown).
const stems = (window.feedBack || window.slopsmith)?.stems?.audioGraph || null;
// Element songs: a song is loaded, it is NOT riding the native
// transport (Phase 1 owns those), and the stems graph is not the
// player. Covers native-transport rejects (codec) in exclusive
// mode — without this they would be silent.
const songAudio = window._currentSongAudio;
const elementSong = !!songAudio && !window._juceMode && !stems;
let want = 'off';
if (running && exclusive) {
if (stems) want = 'stems';
else if (elementSong) want = 'element';
}
const stemsGraphChanged = _mode === 'stems' && stems !== _stemsGraph;
if (want !== _mode || stemsGraphChanged) {
await _disengage();
if (want === 'stems') await _engageStems(stems);
else if (want === 'element') await _engageElement();
}
} catch (e) {
console.warn('[renderer-bus] reevaluate failed (will retry):', e);
_mode = 'off';
} finally {
_busy = false;
}
}
// Same cadence/rationale as the routing watcher above. Also re-check on
// visibility return so a device switch made while hidden is reconciled.
setInterval(() => { if (!document.hidden) void _reevaluate(); }, 500);
document.addEventListener('visibilitychange', () => { if (!document.hidden) void _reevaluate(); });
window._reevaluateRendererBus = _reevaluate;
})();
// Desktop JUCE backing uses an empty <audio> element; plugins such as Section Map // Desktop JUCE backing uses an empty <audio> element; plugins such as Section Map
// still seek via audio.currentTime / pause / play. Mirror those onto jucePlayer // still seek via audio.currentTime / pause / play. Mirror those onto jucePlayer
// while _juceMode is active. Same-tick pause+seek coalesce into a single seek // while _juceMode is active. Same-tick pause+seek coalesce into a single seek
+3 -1
View File
@@ -9,6 +9,8 @@
const SCHEMA = 'feedBack.audio_effects.diagnostics.v1'; const SCHEMA = 'feedBack.audio_effects.diagnostics.v1';
const PLAN_SCHEMA = 'feedBack.audio_effects.chain_plan.v1'; const PLAN_SCHEMA = 'feedBack.audio_effects.chain_plan.v1';
// Pre-rebrand plugins (rig_builder <= 2.9.x) still send the old schema id — accept it as an alias.
const LEGACY_PLAN_SCHEMA = 'slopsmith.audio_effects.chain_plan.v1';
const OWNER_ID = 'core.audio.effects'; const OWNER_ID = 'core.audio.effects';
const DEFAULT_ROUTE_KEY = 'desktop-main'; const DEFAULT_ROUTE_KEY = 'desktop-main';
const DEFAULT_TIMEOUT_MS = 2000; const DEFAULT_TIMEOUT_MS = 2000;
@@ -734,7 +736,7 @@
const errors = []; const errors = [];
const source = _plainObject(rawPlan); const source = _plainObject(rawPlan);
const schema = _string(source.schema || source.version, PLAN_SCHEMA); const schema = _string(source.schema || source.version, PLAN_SCHEMA);
if (schema !== PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema'); if (schema !== PLAN_SCHEMA && schema !== LEGACY_PLAN_SCHEMA && schema !== '1') errors.push('Unsupported chain plan schema');
const planRoute = _safeRoute(source.routeKey || source.route || routeKey); const planRoute = _safeRoute(source.routeKey || source.route || routeKey);
if (planRoute !== routeKey) errors.push('Chain plan route does not match selected route'); if (planRoute !== routeKey) errors.push('Chain plan route does not match selected route');
const providerId = _safeId(source.providerId || provider.providerId, provider.providerId); const providerId = _safeId(source.providerId || provider.providerId, provider.providerId);
+58 -5
View File
@@ -3409,21 +3409,57 @@ function createHighway() {
if (msg.audio_url) { if (msg.audio_url) {
const audio = document.getElementById('audio'); const audio = document.getElementById('audio');
const audioFilename = msg.audio_url.split('/').pop(); const audioFilename = msg.audio_url.split('/').pop();
// Only attempt JUCE routing for /audio/ URLs — sloppak stems // /audio/ URLs are always JUCE-routable. A feedpak full-mix
// (/api/sloppak/…) are not resolvable via audio-local-path. // (single-mix pack: original audio, no stems) is routable too,
// but ONLY under an exclusive-style output — the actual
// exclusive check happens at routing time (app.js watcher /
// the async block below), not here, because the share mode
// can change while the song is loaded. Sloppak stem URLs are
// never routable.
const isAudioUrl = msg.audio_url.startsWith('/audio/'); const isAudioUrl = msg.audio_url.startsWith('/audio/');
// "Full mix" covers BOTH single-mix pack shapes:
// - stem-less packs (original_audio: in the manifest,
// audio_url == original_audio_url), and
// - single-stem packs (stems: [full.ogg] only) — the server
// puts the full mix in the stems list, has_original_audio
// is false, and audio_url points at the one stem. With one
// stem there is no per-stem mix to preserve, so routing it
// natively loses nothing. Real multi-stem (>1) stays out
// until Phase 2.
const isFeedpakFullMix = !isAudioUrl
&& msg.audio_url.startsWith('/api/sloppak/')
&& ((!!msg.has_original_audio && !msg.has_stems)
|| (msg.stems || []).length === 1);
// Record the loaded song's audio so app.js can re-route it // Record the loaded song's audio so app.js can re-route it
// between the HTML5 and JUCE paths if the audio engine is // between the HTML5 and JUCE paths if the audio engine is
// started/stopped after the song is already loaded. Set this // started/stopped after the song is already loaded. Set this
// unconditionally (not just on reload): when alreadyLoaded is // unconditionally (not just on reload): when alreadyLoaded is
// true the watcher must still see correct, current metadata. // true the watcher must still see correct, current metadata.
window._currentSongAudio = { url: msg.audio_url, juceEligible: isAudioUrl }; window._currentSongAudio = {
url: msg.audio_url,
juceEligible: isAudioUrl,
feedpakFullMix: isFeedpakFullMix,
};
const alreadyLoaded = window._juceMode const alreadyLoaded = window._juceMode
? window._juceAudioUrl === msg.audio_url ? window._juceAudioUrl === msg.audio_url
: (audio.src && audio.src.includes(audioFilename)); : (audio.src && audio.src.includes(audioFilename));
// [feedpak-route] diagnostics: every eligibility input in one
// line — shows up in the exported diagnostics bundle. If
// has_stems is true the pack is multi-stem and Phase 1
// deliberately does not route it (Phase 2 work).
console.log('[feedpak-route] song-load:',
'url=', msg.audio_url,
'isAudioUrl=', isAudioUrl,
'isFeedpakFullMix=', isFeedpakFullMix,
'has_stems=', !!msg.has_stems,
'stems=', (msg.stems || []).length,
'has_original_audio=', !!msg.has_original_audio,
'format=', msg.format,
'alreadyLoaded=', alreadyLoaded,
'juceApi=', !!window.feedBackDesktop?.audio);
if (!alreadyLoaded) { if (!alreadyLoaded) {
const juceApi = window.feedBackDesktop?.audio; const juceApi = window.feedBackDesktop?.audio;
if (isAudioUrl && juceApi) { if ((isAudioUrl || isFeedpakFullMix) && juceApi) {
// Run JUCE routing off the critical message-processing chain // Run JUCE routing off the critical message-processing chain
// so subsequent notes/chords/ready messages aren't blocked // so subsequent notes/chords/ready messages aren't blocked
// waiting for IPC + HTTP round-trips. The 'ready' handler // waiting for IPC + HTTP round-trips. The 'ready' handler
@@ -3466,7 +3502,24 @@ function createHighway() {
clearTimeout(barrierTimer); clearTimeout(barrierTimer);
if (gen !== _wsGen) return; // navigated away during the wait if (gen !== _wsGen) return; // navigated away during the wait
} }
if (await juceApi.isAudioRunning()) { // Feedpak full-mix rides the engine ONLY under an
// exclusive-style output (shared mode falls through to
// the HTML5 fallback below, keeping the WebAudio path
// fully working). /audio/ songs route whenever the
// engine runs, as before. If the share mode changes
// later, the app.js watcher re-evaluates and migrates.
let routeToJuce = await juceApi.isAudioRunning();
console.log('[feedpak-route] initial-load: engineRunning=', routeToJuce);
if (routeToJuce) {
if (gen !== _wsGen) return; // stale
if (isFeedpakFullMix) {
const exclFn = window._juceOutputIsExclusive;
routeToJuce = !!(await exclFn?.());
console.log('[feedpak-route] initial-load: feedpak exclusive check →',
routeToJuce, '(predicate installed=', typeof exclFn === 'function', ')');
}
}
if (routeToJuce) {
if (gen !== _wsGen) return; // stale if (gen !== _wsGen) return; // stale
const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(audioUrl)}`); const res = await fetch(`/api/audio-local-path?url=${encodeURIComponent(audioUrl)}`);
if (!res.ok) throw new Error('HTTP ' + res.status); if (!res.ok) throw new Error('HTTP ' + res.status);
+83 -1
View File
@@ -40,7 +40,7 @@ function extractWatcherIIFE(src) {
// Build a sandbox with fakes and run the watcher IIFE inside it. Returns the // Build a sandbox with fakes and run the watcher IIFE inside it. Returns the
// sandbox so tests can drive window._reevaluateJuceRouting and inspect state. // sandbox so tests can drive window._reevaluateJuceRouting and inspect state.
function makeSandbox({ isAudioRunning, loadBackingTrack }) { function makeSandbox({ isAudioRunning, loadBackingTrack, outputType = 'Windows Audio' }) {
const calls = { loadBackingTrack: [], jucePlay: 0, jucePause: 0, audioPlay: 0 }; const calls = { loadBackingTrack: [], jucePlay: 0, jucePause: 0, audioPlay: 0 };
const audio = { const audio = {
@@ -65,6 +65,7 @@ function makeSandbox({ isAudioRunning, loadBackingTrack }) {
const juceApi = { const juceApi = {
isAudioRunning: () => Promise.resolve(isAudioRunning()), isAudioRunning: () => Promise.resolve(isAudioRunning()),
loadBackingTrack: (p) => { calls.loadBackingTrack.push(p); return Promise.resolve(loadBackingTrack()); }, loadBackingTrack: (p) => { calls.loadBackingTrack.push(p); return Promise.resolve(loadBackingTrack()); },
getCurrentDevice: () => Promise.resolve({ outputType: typeof outputType === 'function' ? outputType() : outputType }),
getBackingDuration: () => Promise.resolve(180), getBackingDuration: () => Promise.resolve(180),
seekBacking: () => Promise.resolve(), seekBacking: () => Promise.resolve(),
startBacking: () => Promise.resolve(), startBacking: () => Promise.resolve(),
@@ -152,6 +153,87 @@ test('non-JUCE-eligible song (sloppak stems) is never rerouted', async () => {
assert.equal(sb.__calls.loadBackingTrack.length, 0); assert.equal(sb.__calls.loadBackingTrack.length, 0);
}); });
test('feedpak full-mix + exclusive output → migrates to JUCE', async () => {
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: 'Windows Audio (Exclusive Mode)',
});
sb.window._juceMode = false;
sb.window._currentSongAudio = {
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
juceEligible: false,
feedpakFullMix: true,
};
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'feedpak full-mix rides the engine under exclusive output');
assert.equal(sb.__calls.loadBackingTrack.length, 1);
});
test('feedpak full-mix + ASIO output → migrates to JUCE', async () => {
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: 'ASIO',
});
sb.window._juceMode = false;
sb.window._currentSongAudio = {
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
juceEligible: false,
feedpakFullMix: true,
};
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'ASIO is exclusive-style; feedpak rides the engine');
});
test('feedpak full-mix + shared output → stays on HTML5 (stem mixer untouched)', async () => {
for (const shared of ['Windows Audio', 'Windows Audio (Low Latency Mode)', 'DirectSound']) {
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: shared,
});
sb.window._juceMode = false;
sb.window._currentSongAudio = {
url: '/api/sloppak/song.sloppak/file/stems/full.ogg',
juceEligible: false,
feedpakFullMix: true,
};
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, false, `stays on HTML5 for shared type "${shared}"`);
assert.equal(sb.__calls.loadBackingTrack.length, 0);
}
});
test('feedpak on JUCE + output leaves exclusive mode → migrates back to HTML5', async () => {
let type = 'Windows Audio (Exclusive Mode)';
const sb = makeSandbox({
isAudioRunning: () => true,
loadBackingTrack: () => true,
outputType: () => type,
});
const url = '/api/sloppak/song.sloppak/file/stems/full.ogg';
sb.window._juceMode = true;
sb.window._juceAudioUrl = url;
sb.window._currentSongAudio = { url, juceEligible: false, feedpakFullMix: true };
// Still exclusive: routing is consistent, no switch.
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, true, 'consistent while exclusive');
// Device switched to shared mid-song: must return to HTML5.
type = 'Windows Audio';
await sb.window._reevaluateJuceRouting();
assert.equal(sb.window._juceMode, false, 'returned to HTML5 after leaving exclusive mode');
assert.equal(sb.audio.src, url, 'HTML5 element re-pointed at the song');
});
test('JUCE hard-reject is memoised → not retried on the next poll', async () => { test('JUCE hard-reject is memoised → not retried on the next poll', async () => {
const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false }); const sb = makeSandbox({ isAudioRunning: () => true, loadBackingTrack: () => false });
sb.window._juceMode = false; sb.window._juceMode = false;
+195
View File
@@ -0,0 +1,195 @@
// Behavioral tests for the renderer-audio bus feeder in static/app.js.
//
// The feeder (an IIFE, `_installRendererBusFeeder`) captures renderer-side
// song audio (stems-plugin WebAudio master, or the core <audio> element) and
// pushes it into the desktop engine's renderer bus while the output device is
// exclusive-style — the Phase 2 path for audio the native backing transport
// cannot carry. These tests extract that IIFE from source and exercise
// `window._reevaluateRendererBus` against fakes, covering: stems engagement
// under exclusive output, disengagement on return to shared mode, inertness
// in shared mode / while the native transport owns the song, and the
// element-capture fallback.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js');
function extractFeederIIFE(src) {
const marker = '(function _installRendererBusFeeder() {';
const start = src.indexOf(marker);
assert.ok(start !== -1, 'feeder IIFE not found in app.js');
const openBrace = src.indexOf('{', start);
let depth = 1;
let i = openBrace + 1;
while (i < src.length && depth > 0) {
const ch = src[i];
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
assert.ok(depth === 0, 'unbalanced braces in feeder IIFE');
const tail = src.slice(i, i + 5);
assert.match(tail, /^\)\(\)/, 'feeder IIFE not immediately invoked');
return src.slice(start, i) + ')();';
}
function makeFakeContext(sampleRate = 48000) {
const ctx = {
sampleRate,
state: 'running',
sinkIdCalls: [],
destination: { isDestination: true },
setSinkId(v) { this.sinkIdCalls.push(v); return Promise.resolve(); },
resume() { this.state = 'running'; return Promise.resolve(); },
audioWorklet: { addModule: () => Promise.resolve() },
createMediaElementSource(el) {
this.mediaSourceEl = el;
return { connect() {}, disconnect() {} };
},
};
return ctx;
}
function makeSandbox({ isAudioRunning = () => true, exclusive = () => true } = {}) {
const calls = { setRendererBus: [], pushRendererAudio: [] };
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]); },
};
class FakeWorkletNode {
constructor() { this.port = { onmessage: null }; }
connect() {}
disconnect() {}
}
const sandbox = {
console: { log() {}, warn() {}, error() {} },
URL: { createObjectURL: () => 'blob:tap', revokeObjectURL() {} },
Blob: class { constructor() {} },
AudioWorkletNode: FakeWorkletNode,
AudioContext: function () { const c = makeFakeContext(); sandbox.__createdContexts.push(c); return c; },
WeakSet, WeakMap, Promise, Float32Array, Math,
setInterval: () => 0,
document: {
hidden: false,
addEventListener() {},
getElementById: () => sandbox.__audioEl,
},
__createdContexts: [],
__audioEl: { id: 'audio' },
__calls: calls,
window: null,
};
sandbox.window = {
feedBackDesktop: { audio: api },
_juceOutputIsExclusive: () => Promise.resolve(exclusive()),
_juceMode: false,
_currentSongAudio: null,
feedBack: { stems: {} },
};
sandbox.globalThis = sandbox;
const src = fs.readFileSync(APP_JS, 'utf8');
vm.createContext(sandbox);
vm.runInContext(extractFeederIIFE(src), sandbox);
assert.equal(typeof sandbox.window._reevaluateRendererBus, 'function',
'feeder must expose window._reevaluateRendererBus');
return sandbox;
}
function makeStemsGraph() {
return {
context: makeFakeContext(),
masterNode: { connect() {}, disconnect() {} },
};
}
test('stems graph + exclusive output → bus enabled, stems ctx null-sinked', async () => {
const sb = makeSandbox({ exclusive: () => true });
const graph = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = graph;
await sb.window._reevaluateRendererBus();
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');
});
test('output returns to shared → bus disabled, sink restored', async () => {
let excl = true;
const sb = makeSandbox({ exclusive: () => excl });
const graph = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = graph;
await sb.window._reevaluateRendererBus();
excl = false;
await sb.window._reevaluateRendererBus();
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled');
assert.equal(graph.context.sinkIdCalls.at(-1), '', 'default sink restored');
});
test('stems graph + shared output → feeder stays off (no double audio)', async () => {
const sb = makeSandbox({ exclusive: () => false });
sb.window.feedBack.stems.audioGraph = makeStemsGraph();
await sb.window._reevaluateRendererBus();
assert.equal(sb.__calls.setRendererBus.length, 0, 'bus never touched in shared mode');
});
test('element song + exclusive → element captured into bus', 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();
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 () => {
const sb = makeSandbox({ exclusive: () => true });
sb.window._currentSongAudio = { url: '/audio/song.ogg' };
sb.window._juceMode = true;
await sb.window._reevaluateRendererBus();
assert.equal(sb.__calls.setRendererBus.length, 0, 'native transport owns the song');
assert.equal(sb.__createdContexts.length, 0, 'no capture context created');
});
test('stems graph replaced mid-engagement → re-engages on the new graph', async () => {
const sb = makeSandbox({ exclusive: () => true });
const g1 = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = g1;
await sb.window._reevaluateRendererBus();
const g2 = makeStemsGraph();
sb.window.feedBack.stems.audioGraph = g2;
await sb.window._reevaluateRendererBus();
assert.equal(g2.context.sinkIdCalls.at(-1)?.type, 'none', 'new graph null-sinked');
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [true, 1.0], 're-enabled for new graph');
});
test('engine stops → bus disabled', async () => {
let running = true;
const sb = makeSandbox({ isAudioRunning: () => running, exclusive: () => true });
sb.window.feedBack.stems.audioGraph = makeStemsGraph();
await sb.window._reevaluateRendererBus();
running = false;
await sb.window._reevaluateRendererBus();
assert.deepEqual(sb.__calls.setRendererBus.at(-1), [false, 0], 'bus disabled after engine stop');
});
+90 -4
View File
@@ -103,12 +103,98 @@ def test_returns_404_for_nonexistent_file(client_and_server):
assert "error" in r.json() assert "error" in r.json()
# ── rejected non-/audio/ inputs ─────────────────────────────────────────────── # ── sloppak URLs (feedpak full-mix, desktop exclusive-mode routing) ──────────
def test_rejects_sloppak_url(client_and_server): @pytest.fixture()
def dlc_client(tmp_path, monkeypatch):
"""Loopback TestClient with a temp DLC_DIR for sloppak resolution."""
dlc = tmp_path / "dlc"
dlc.mkdir()
monkeypatch.setenv("DLC_DIR", str(dlc))
monkeypatch.setenv("CONFIG_DIR", str(tmp_path / "cfg"))
monkeypatch.setenv("FEEDBACK_SYNC_STARTUP", "1")
sys.modules.pop("server", None)
server = importlib.import_module("server")
# Module-level sloppak source-dir cache survives re-import; clear it so a
# prior test's filename key can't shadow this test's temp DLC_DIR.
server.sloppak_mod._source_cache.clear()
monkeypatch.setattr(server, "load_plugins", lambda *a, **kw: None)
monkeypatch.setattr(server, "startup_scan", lambda: None)
static_tmp = tmp_path / "static"
static_tmp.mkdir()
monkeypatch.setattr(server, "STATIC_DIR", static_tmp)
tc = TestClient(server.app, client=("127.0.0.1", 50000))
try:
yield tc, server, dlc
finally:
tc.close()
meta_db = getattr(server, "meta_db", None)
conn = getattr(meta_db, "conn", None)
if conn is not None:
getattr(__import__("sys").modules.get("server"), "_join_background_db_threads", lambda: None)()
conn.close()
def _make_sloppak(dlc, name="song.sloppak"):
"""Create a minimal directory-form sloppak with a full-mix file."""
pak = dlc / name
(pak / "stems").mkdir(parents=True)
(pak / "stems" / "full.ogg").write_bytes(b"OggS-fake")
return pak
def test_sloppak_url_resolves_to_local_path(dlc_client):
tc, _server, dlc = dlc_client
pak = _make_sloppak(dlc)
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/song.sloppak/file/stems/full.ogg"},
)
assert r.status_code == 200, r.text
assert r.json()["path"] == str((pak / "stems" / "full.ogg").resolve())
def test_sloppak_url_percent_encoded_segments_decode(dlc_client):
tc, _server, dlc = dlc_client
_make_sloppak(dlc, name="My Song.sloppak")
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/My%20Song.sloppak/file/stems/full.ogg"},
)
assert r.status_code == 200, r.text
def test_sloppak_url_rel_traversal_is_403(dlc_client):
tc, _server, dlc = dlc_client
_make_sloppak(dlc)
(dlc / "secret.txt").write_text("top secret")
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/song.sloppak/file/..%2Fsecret.txt"},
)
assert r.status_code == 403, r.text
def test_sloppak_url_filename_traversal_is_403(dlc_client):
tc, _server, _dlc = dlc_client
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/..%2F..%2F..%2Fetc/file/passwd"},
)
assert r.status_code == 403, r.text
def test_sloppak_url_without_dlc_configured_is_404(client_and_server):
"""No DLC_DIR in the base fixture — resolver reports 'not configured'."""
client, _ = client_and_server client, _ = client_and_server
r = client.get("/api/audio-local-path", params={"url": "/api/sloppak/mysong/file/stems/full.ogg"}) r = client.get(
assert r.status_code == 400 "/api/audio-local-path",
params={"url": "/api/sloppak/mysong/file/stems/full.ogg"},
)
assert r.status_code == 404
# ── rejected non-/audio/ inputs ───────────────────────────────────────────────
def test_rejects_empty_url(client_and_server): def test_rejects_empty_url(client_and_server):