feat(audio): route feedpak full-mix natively under WASAPI-exclusive output (Phase 1) - #824
Conversation
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>
📝 WalkthroughWalkthroughThis PR centralizes sloppak file path resolution into a shared server helper reused by two endpoints, and adds JUCE output-mode detection so feedpak full-mix songs only route to JUCE when the active output is exclusive-style (Exclusive Mode/ASIO), with corresponding test coverage. ChangesServer sloppak path resolution
Estimated code review effort: 3 (Moderate) | ~30 minutes Exclusive-Output JUCE Routing for Feedpak Full-Mix
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant HighwayJS as highway.js
participant AppJS as app.js
participant JuceApi as juceApi
participant Server as server.py
HighwayJS->>HighwayJS: compute isFeedpakFullMix on song_info
HighwayJS->>JuceApi: isAudioRunning()
HighwayJS->>AppJS: read window._juceOutputIsExclusive
AppJS->>JuceApi: getCurrentDevice()
JuceApi-->>AppJS: outputType
AppJS-->>HighwayJS: exclusive boolean
alt exclusive and running
HighwayJS->>AppJS: proceed with JUCE routing
AppJS->>Server: GET /api/audio-local-path (sloppak URL)
Server->>Server: _resolve_sloppak_local_file(filename, rel_path)
Server-->>AppJS: resolved local path or error
AppJS->>JuceApi: loadBackingTrack(localPath)
else not exclusive
HighwayJS->>AppJS: stay on HTML5 playback
end
Possibly related issues
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed: one or more packages not found in the registry. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@server.py`:
- Around line 13946-13956: Update the sloppak URL parsing in the
`/api/audio-local-path` handling so it splits on the first `/file/` delimiter
instead of using the current greedy `re.fullmatch` capture in the `slop_match`
block. Adjust the regex or parsing logic so `_resolve_sloppak_local_file`
receives the correct pak name and relative path even when the in-pack path
contains `file/`, and add a regression test like
`test_sloppak_url_rel_path_can_contain_file_segment` to verify
`/api/sloppak/song.sloppak/file/stems/file/full.ogg` resolves successfully.
- Around line 12965-12975: The cache and path validation in the sloppak serving
flow is too strict on junctioned subfolders and can return stale content from
get_cached_source_dir(). Update the request path logic around
resolved.relative_to(dlc.resolve()) and the src lookup so it stays DLC-relative,
always routes through resolve_source_dir() on each request, and avoids serving
an unpacked directory without revalidating it. Also narrow the broad except in
this block to only the expected resolver failure cases.
In `@static/app.js`:
- Around line 4948-4953: The audio-local-path fetch path in the browser route
treats every non-OK response as transient, so persistent 4xx lookup failures
keep retrying on every poll. Update the logic around the `/api/audio-local-path`
handling so deterministic client errors are marked as a hard reject after HTML5
playback is restored, and only true transient failures continue to be retried.
Use the existing `catch` and the `rejected` memoization flow in `static/app.js`
to locate and wire the fix.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 351550c5-c511-4238-8664-d3e4e744210c
📒 Files selected for processing (5)
server.pystatic/app.jsstatic/highway.jstests/js/juce_engine_reroute.test.jstests/test_audio_local_path.py
| 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) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify the resolver/cache contracts without executing repository code.
rg -n -C4 'def _resolve_dlc_path|def _resolve_sloppak_local_file|def resolve_source_dir|def get_cached_source_dir|test_junction_subfolder_allowed' server.py lib testsRepository: got-feedBack/feedBack
Length of output: 2738
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== server.py around _resolve_sloppak_local_file =="
sed -n '12944,12995p' server.py | cat -n
echo
echo "== lib/sloppak.py resolve_source_dir and cache helper =="
sed -n '136,205p' lib/sloppak.py | cat -n
echo
echo "== tests/test_dlc_junction.py relevant test =="
sed -n '1,140p' tests/test_dlc_junction.py | cat -nRepository: got-feedBack/feedBack
Length of output: 9860
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== all sloppak cache callers =="
rg -n -C3 '_source_cache|get_cached_source_dir|resolve_source_dir\(' lib server.py tests
echo
echo "== sloppak-related file serving paths in server.py =="
rg -n -C3 'sloppak_mod\.(is_sloppak|get_cached_source_dir|resolve_source_dir|_source_cache)' server.pyRepository: got-feedBack/feedBack
Length of output: 13371
Avoid caching past the resolver
resolved.relative_to(dlc.resolve()) rejects valid junctioned DLC subfolders, and get_cached_source_dir() can return an unpacked directory without rechecking mtime/size, so replaced or removed sloppaks can serve stale files. Keep the cache key DLC-relative and go through resolve_source_dir() on each request; narrow the broad except while here.
🧰 Tools
🪛 Ruff (0.15.20)
[warning] 12974-12974: Do not catch blind exception: Exception
(BLE001)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@server.py` around lines 12965 - 12975, The cache and path validation in the
sloppak serving flow is too strict on junctioned subfolders and can return stale
content from get_cached_source_dir(). Update the request path logic around
resolved.relative_to(dlc.resolve()) and the src lookup so it stays DLC-relative,
always routes through resolve_source_dir() on each request, and avoids serving
an unpacked directory without revalidating it. Also narrow the broad except in
this block to only the expected resolver failure cases.
Source: Linters/SAST tools
| 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)}) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Split sloppak URLs at the first /file/ delimiter.
Line 13946’s greedy first capture misparses valid in-pack paths such as stems/file/full.ogg as part of the sloppak filename, returning 404 for an existing file.
Proposed fix and regression test
- slop_match = re.fullmatch(r"/api/sloppak/([^?#]+)/file/([^?#]+)", url)
+ slop_match = re.fullmatch(r"/api/sloppak/([^?#]+?)/file/([^?#]+)", url)def test_sloppak_url_rel_path_can_contain_file_segment(dlc_client):
tc, _server, dlc = dlc_client
pak = _make_sloppak(dlc)
nested = pak / "stems" / "file"
nested.mkdir()
(nested / "full.ogg").write_bytes(b"OggS-fake")
r = tc.get(
"/api/audio-local-path",
params={"url": "/api/sloppak/song.sloppak/file/stems/file/full.ogg"},
)
assert r.status_code == 200, r.text
assert r.json()["path"] == str((nested / "full.ogg").resolve())📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| 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)}) | |
| 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)}) |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@server.py` around lines 13946 - 13956, Update the sloppak URL parsing in the
`/api/audio-local-path` handling so it splits on the first `/file/` delimiter
instead of using the current greedy `re.fullmatch` capture in the `slop_match`
block. Adjust the regex or parsing logic so `_resolve_sloppak_local_file`
receives the correct pak name and relative path even when the in-pack path
contains `file/`, and add a regression test like
`test_sloppak_url_rel_path_can_contain_file_segment` to verify
`/api/sloppak/song.sloppak/file/stems/file/full.ogg` resolves successfully.
| 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(); | ||
| console.log('[feedpak-route] audio-local-path resolved:', (typeof path === 'string' && path.split(/[\\/]/).pop()) || '<missing>'); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Avoid retrying persistent local-path lookup failures every poll.
Line 4950 turns every non-OK /api/audio-local-path response into a transient throw. Since only 'rejected' is memoized later, a persistent 400/403/404 will pause/resume HTML5 and log again every 350ms. Mark deterministic 4xx lookup failures as a hard reject after restoring HTML5 playback.
Proposed direction
if (!res.ok) {
console.warn('[feedpak-route] audio-local-path HTTP', res.status, 'for', url);
- throw new Error('HTTP ' + res.status);
+ const err = new Error('HTTP ' + res.status);
+ err.hardReject = res.status >= 400 && res.status < 500;
+ throw err;
}Then in the existing catch, after restoring/reporting the browser route:
- throw err;
+ if (err && err.hardReject) return 'rejected';
+ throw err;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@static/app.js` around lines 4948 - 4953, The audio-local-path fetch path in
the browser route treats every non-OK response as transient, so persistent 4xx
lookup failures keep retrying on every poll. Update the logic around the
`/api/audio-local-path` handling so deterministic client errors are marked as a
hard reject after HTML5 playback is restored, and only true transient failures
continue to be retried. Use the existing `catch` and the `rejected` memoization
flow in `static/app.js` to locate and wire the fix.
…r exclusive mode (Phase 2) (#828) * 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> * 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> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* 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> * 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> * feat(diag): --debug ASIO routing diagnostics in static bundle Gated on window.feedBackDesktop.audio.debugEnabled() (desktop --debug); inert in the Docker sphere and normal desktop runs. - [asio-diag] getCurrentDevice= full device object on outputType change (catches ASIO drivers reporting a non-'ASIO' type name) - [asio-diag] renderer-bus: full feeder decision vector, change-gated (running/exclusive/stems/juceMode/elementSong/want/mode) - [asio-diag] setSink: every sink flip with ctx state + rate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
…nder exclusive/ASIO (#877) * 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> * 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> * feat(diag): --debug ASIO routing diagnostics in static bundle Gated on window.feedBackDesktop.audio.debugEnabled() (desktop --debug); inert in the Docker sphere and normal desktop runs. - [asio-diag] getCurrentDevice= full device object on outputType change (catches ASIO drivers reporting a non-'ASIO' type name) - [asio-diag] renderer-bus: full feeder decision vector, change-gated (running/exclusive/stems/juceMode/elementSong/want/mode) - [asio-diag] setSink: every sink flip with ctx state + rate Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(audio): loopback feeder mode — all app audio under exclusive/ASIO Tester-confirmed (2026-07-11 log): song previews and other plugin-private audio bypass the per-surface feeder taps and leak to the default WASAPI device under ASIO output. Also confirmed: the element capture path poisons itself when highway_3d already owns #audio's one-shot MediaElementSource (InvalidStateError with _elCtx assigned pre-throw → TypeError every later tick). - New preferred mode 'loopback': one getDisplayMedia frame-audio capture (desktop main answers with the app's own frame) covers song, previews, and UI sounds for the whole exclusive session — engages even with no song loaded. Local playback silenced via suppressLocalAudioPlayback, page-mute IPC fallback otherwise. - Sticky fallback to the existing stems/element surface modes when capture is unavailable (old desktop main, denied, Docker sphere). - Element capture: assign module state only after the whole chain succeeds; close the context on failure — collision now retries clean. - Failed engage now disables the bus and tears down loopback (no more bus-enabled-with-no-producer stranding). - Tests: 12 (5 new — loopback engage/preference/mute-fallback/sticky fallback, collision retry). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(audio): loopback feeder mode — all app audio under exclusive/ASIO Tester-confirmed (2026-07-11 log): song previews and other plugin-private audio bypass the per-surface feeder taps and leak to the default WASAPI device under ASIO output. Also confirmed: the element capture path poisons itself when highway_3d already owns #audio's one-shot MediaElementSource (InvalidStateError with _elCtx assigned pre-throw → TypeError every later tick). - New preferred mode 'loopback': one getDisplayMedia frame-audio capture (desktop main answers with the app's own frame) covers song, previews, and UI sounds for the whole exclusive session — engages even with no song loaded. Local playback silenced via suppressLocalAudioPlayback, page-mute IPC fallback otherwise. - Sticky fallback to the existing stems/element surface modes when capture is unavailable (old desktop main, denied, Docker sphere). - Element capture: assign module state only after the whole chain succeeds; close the context on failure — collision now retries clean. - Failed engage now disables the bus and tears down loopback (no more bus-enabled-with-no-producer stranding). - Tests: 12 (5 new — loopback engage/preference/mute-fallback/sticky fallback, collision retry). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(audio): close loopback capture context on teardown (release tap worklet) The loopback context was reused across engages (_lbCtx || new), but teardown only stopped the stream + deactivated the tap — never closing the context or detaching the worklet node. Each exclusive<->shared switch orphaned a live tap worklet on the long-lived context. Use a fresh context per session and close it on disengage. Adds a test asserting the context is closed on teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(diag): install-time + uncaught-error diagnostics for the reroute chain 2026-07-11 tester log showed the routing watcher and renderer-bus feeder never installed (zero [feedpak-route]/[renderer-bus] lines) plus an uncaught SyntaxError with no source location — nothing in the log said why. New: - global error/unhandledrejection tap logging message + filename:line:col (error events carry the location even for parse errors in other scripts) - explicit install / NOT-installed lines for watcher and feeder (incl. loopback capability probe) - DOMException detail (name/message/stack head) in the feeder retry warn — the console-message forward stringified it to [object DOMException] Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(static): force conditional revalidation on /static (Cache-Control: no-cache) Without Cache-Control Chromium's heuristic freshness (10% of file age) serves /static/app.js from disk cache for hours-to-days without revalidating. Desktop consequence: a new build's window ran the previous build's app.js — the 2026-07-11 ASIO investigation traced 'routing watcher never installed' + a stems module-plugin SyntaxError to exactly this (stale loader predating scriptType support). no-cache keeps caching but revalidates via ETag — unchanged files still cost only a 304. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(diag): gate install-time + uncaught-error [asio-diag] lines on --debug The error tap and install lines from the previous diag commit were unconditional. Now: error/rejection taps check _asioDiagEnabled() at event time; install lines log deferred once the async debugEnabled() resolves true. The NOT-installed anomaly lines stay bridge-gated (window.feedBackDesktop present) instead — a broken bridge can't deliver the debug flag, they fire at most once, and only in the broken state they exist to witness. Docker sphere: fully silent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Byron Gamatos <xasiklas@gmail.com>
Problem
Song playback runs through the Electron renderer (WebAudio /
<audio>). When the desktop engine opens the output endpoint in WASAPI exclusive mode (or via most ASIO drivers), Windows silences every other client on the endpoint — including our own renderer, so songs go silent while guitar monitoring keeps working. Root cause + fix options in #89; this implements Slice 1 (Phase 1 of the native-song-playback plan).What this does
getCurrentDevice().outputType === 'Windows Audio (Exclusive Mode)' || 'ASIO'("Low Latency Mode" is shared and does not match). Evaluated per watcher tick, so mid-song device switches re-route both directions.highway.js): feedpak full-mix songs are now JUCE-routable, gated on exclusive output. Full-mix covers both real-world pack shapes: stem-lessoriginal_audiopacks and single-stem packs (stems: [full.ogg]— what packs in the wild actually use). Multi-stem (>1) packs stay on the WebAudio path until Phase 2. In shared mode nothing changes — the gate is load-bearing so the stem mixer keeps working./api/audio-local-path(server.py): now resolves/api/sloppak/<fn>/file/<rel>URLs to the unpacked cache path, via a_resolve_sloppak_local_file()helper shared withserve_sloppak_fileso both endpoints apply identical containment/traversal guards.[feedpak-route]log lines at each routing decision (transition-gated, not per-tick) — captured by the diagnostics export bundle; already proved out during tester debugging.Docker sphere
Unaffected: all new behavior is behind the
window.feedBackDesktop.audiobridge presence + exclusive-output predicate; pure-browser deployments never hit it.Tests
tests/js/juce_engine_reroute.test.js: +4 cases — exclusive→JUCE, ASIO→JUCE, shared stays HTML5 (incl. Low Latency Mode), mid-song exclusive→shared migrates back.tests/test_audio_local_path.py: +5 cases — sloppak resolution happy path, percent-encoding, rel/filename traversal (403), unconfigured DLC.Companion (desktop repo): pin test for the JUCE type-name strings the predicate depends on.
Refs #89.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes