mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-14 04:31:21 +00:00
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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
845255e404
commit
1b3178037b
@@ -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)
|
||||||
|
|||||||
+77
-10
@@ -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) {
|
||||||
|
|||||||
+58
-5
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user