From 845255e40436f68750814d28c61da9e29abee768 Mon Sep 17 00:00:00 2001 From: OmikronApex <45161725+OmikronApex@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:17:57 +0200 Subject: [PATCH 1/2] fix(audio-effects): accept pre-rebrand chain plan schema as alias (#816) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * docs(changelog): note loop-api bridge throttle fix Co-Authored-By: Claude Fable 5 * 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 --------- Co-authored-by: Claude Fable 5 --- static/capabilities/audio-effects.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/capabilities/audio-effects.js b/static/capabilities/audio-effects.js index 467e09c..4d0d915 100644 --- a/static/capabilities/audio-effects.js +++ b/static/capabilities/audio-effects.js @@ -9,6 +9,8 @@ const SCHEMA = 'feedBack.audio_effects.diagnostics.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 DEFAULT_ROUTE_KEY = 'desktop-main'; const DEFAULT_TIMEOUT_MS = 2000; @@ -734,7 +736,7 @@ const errors = []; const source = _plainObject(rawPlan); 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); if (planRoute !== routeKey) errors.push('Chain plan route does not match selected route'); const providerId = _safeId(source.providerId || provider.providerId, provider.providerId); From 1b3178037b809c27b3fad80d511b5f0b0520af6d Mon Sep 17 00:00:00 2001 From: OmikronApex <45161725+OmikronApex@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:18:00 +0200 Subject: [PATCH 2/2] 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 --- server.py | 83 +++++++++++++++--------- static/app.js | 87 ++++++++++++++++++++++--- static/highway.js | 63 +++++++++++++++++-- tests/js/juce_engine_reroute.test.js | 84 ++++++++++++++++++++++++- tests/test_audio_local_path.py | 94 ++++++++++++++++++++++++++-- 5 files changed, 362 insertions(+), 49 deletions(-) diff --git a/server.py b/server.py index d0910cc..c80118a 100644 --- a/server.py +++ b/server.py @@ -12941,53 +12941,57 @@ _extract_cache = {} # filename -> (tmp_dir, song, timestamp) _extract_cache_lock = threading.Lock() -@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.).""" +def _resolve_sloppak_local_file(filename: str, rel_path: str): + """Resolve a file inside a sloppak to its on-disk path. + + 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() if not dlc: - return JSONResponse({"error": "not configured"}, 404) - # `filename` is an attacker-controlled `:path` param. Contain it under - # DLC_DIR before it reaches the resolver, which does a bare - # `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. + return ("not configured", 404) + # `filename` is caller-controlled. Contain it under DLC_DIR before it + # reaches the resolver (see serve_sloppak_file for the traversal rationale). resolved = _resolve_dlc_path(dlc, filename) if resolved is None: - return JSONResponse({"error": "forbidden"}, 403) - # Confine the endpoint to actual sloppak bundles. Without this, a - # contained-but-non-sloppak `filename` (e.g. `.` → DLC_DIR itself, or - # 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. + return ("forbidden", 403) + # Confine to actual sloppak bundles — otherwise any plain subdirectory + # would become a read-any-file-under-DLC_DIR source. if not sloppak_mod.is_sloppak(resolved): - return JSONResponse({"error": "not found"}, 404) - # Canonicalise the cache key against the resolved path so equivalent - # URL forms of the same sloppak (e.g. `A/../B/x.sloppak` vs - # `B/x.sloppak`) converge on one `_source_cache` entry instead of - # fragmenting / re-unpacking — mirrors get_song_info's keying. + return ("not found", 404) + # Canonicalise the cache key against the resolved path so equivalent URL + # forms of the same sloppak converge on one _source_cache entry. try: filename = resolved.relative_to(dlc.resolve()).as_posix() except ValueError: - # safe_join already proved containment, so this is unreachable in - # practice; fail closed rather than fall back to the raw param. - return JSONResponse({"error": "forbidden"}, 403) + # safe_join already proved containment; fail closed regardless. + return ("forbidden", 403) src = sloppak_mod.get_cached_source_dir(filename) if src is None: try: src = sloppak_mod.resolve_source_dir(filename, dlc, SLOPPAK_CACHE_DIR) except Exception: - return JSONResponse({"error": "not found"}, 404) + return ("not found", 404) # Prevent path traversal within the sloppak. target = (src / rel_path).resolve() try: target.relative_to(src.resolve()) except ValueError: - return JSONResponse({"error": "forbidden"}, 403) + return ("forbidden", 403) 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() mt = { ".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") 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/`` where ```` may include subdirectory segments — no scheme, no host, no query string, no fragment. The resolved path must stay inside AUDIO_CACHE_DIR or STATIC_DIR; ``..`` traversal, backslashes, and absolute ``filename`` values are rejected. + Also accepts ``/api/sloppak//file/`` (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 the Electron desktop process (which runs on loopback). Requests from non- loopback clients are rejected with 403. @@ -13929,6 +13939,21 @@ def audio_local_path(url: str, request: Request): is_loopback = client_host == "localhost" if not is_loopback: return JSONResponse({"error": "forbidden"}, status_code=403) + # Sloppak in-pack file (feedpak full-mix): /api/sloppak//file/. + # 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/ — no scheme, no host, no query/fragment if not re.fullmatch(r"/audio/[^?#]+", url): return JSONResponse({"error": "invalid url"}, status_code=400) diff --git a/static/app.js b/static/app.js index dec4187..e70eaad 100644 --- a/static/app.js +++ b/static/app.js @@ -4864,6 +4864,47 @@ window.jucePlayer = jucePlayer; // (a network blip on /api/audio-local-path, an isAudioRunning() race // during a device restart) are deliberately NOT memoised so they retry. let _rerouteRejectedUrl = null; + // Exclusive-style output backends silence every other client on the + // endpoint — including our own