feat(audio): loopback feeder mode — all app audio under exclusive/ASIO#865
Conversation
📝 WalkthroughWalkthroughThe renderer-bus feeder adds whole-application loopback capture for exclusive output, falls back to stems or element capture when unavailable, hardens element initialization, expands teardown handling, and adds coverage for loopback, fallback, mute, retry, and collision scenarios. ChangesRenderer bus routing
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Engine
participant RendererBusFeeder
participant MediaDevices
participant RendererBus
Engine->>RendererBusFeeder: report exclusive running state
RendererBusFeeder->>MediaDevices: request whole-app loopback capture
MediaDevices-->>RendererBusFeeder: stream or capture error
alt Loopback succeeds
RendererBusFeeder->>RendererBus: forward captured audio
else Loopback fails
RendererBusFeeder->>RendererBus: engage stems or element fallback
end
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
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>
…orklet) 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>
1db9acd to
8f53ea1
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@static/app.js`:
- Around line 5603-5610: Update the failure handling around the surface-mode
engage flow, including _engageStems() and _engageElement(), so any error after
switching to the null sink restores the original surface sink before leaving the
outer catch. Ensure failed engages cannot leave the context muted, while
preserving the existing _mode reset, renderer-bus cleanup, and loopback teardown
behavior.
🪄 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: 613060c1-d2a2-4e7e-be7e-c40dad3c1df4
📒 Files selected for processing (2)
static/app.jstests/js/renderer_bus_feeder.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
- tests/js/renderer_bus_feeder.test.js
| } catch (e) { | ||
| console.warn('[renderer-bus] reevaluate failed (will retry):', e); | ||
| _mode = 'off'; | ||
| // A partial engage may have left the bus enabled with no producer | ||
| // and the page muted — undo both so a failed tick can't strand | ||
| // audio in silence until the next successful engage. | ||
| try { await api.setRendererBus(false, 0); } catch (_) { /* engine gone */ } | ||
| await _teardownLoopback().catch(() => {}); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Inspect surface-mode engage functions for sink flips that precede possible throws
fd -t f 'app.js' static | xargs -I{} ast-grep outline {} --items all --match '_engage(Stems|Element)|_setSink' 2>/dev/null
rg -nP -C6 'async function _engage(Stems|Element)\s*\(' static/app.jsRepository: got-feedBack/feedBack
Length of output: 1301
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Read the engage/disengage and failure-handling sections with line numbers.
sed -n '5465,5535p' static/app.js | cat -n
printf '\n-----\n'
sed -n '5580,5625p' static/app.js | cat -n
printf '\n-----\n'
# Inspect sink helper and any self-cleanup around element capture.
rg -n -C4 '_setSink|_ensureElementCapture|_teardownLoopback|_disengage' static/app.jsRepository: got-feedBack/feedBack
Length of output: 11077
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the exact failure path and the surrounding state transitions.
sed -n '5478,5528p' static/app.js | cat -n
printf '\n-----\n'
sed -n '5590,5615p' static/app.js | cat -nRepository: got-feedBack/feedBack
Length of output: 3947
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path('static/app.js')
text = p.read_text()
for needle in ['async function _disengage', 'async function _engageStems', 'async function _engageElement', 'async function _ensureElementCapture', '_mode = \'off\'', '_teardownLoopback']:
idx = text.find(needle)
if idx != -1:
start = max(0, idx - 300)
end = min(len(text), idx + 1200)
print(f"\n=== {needle} ===\n")
print(text[start:end])
PYRepository: got-feedBack/feedBack
Length of output: 9378
Restore the sink on failed surface-mode engage
_engageStems() and _engageElement() flip their context to the null sink before later awaits can throw. If that happens, the outer catch only sets _mode = 'off' and tears down loopback, so the surface context can stay stranded on the null sink until a later successful engage. Clean up the surface-mode sink in the failure path, or make the engage helpers self-revert on error.
🤖 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 5603 - 5610, Update the failure handling around
the surface-mode engage flow, including _engageStems() and _engageElement(), so
any error after switching to the null sink restores the original surface sink
before leaving the outer catch. Ensure failed engages cannot leave the context
muted, while preserving the existing _mode reset, renderer-bus cleanup, and
loopback teardown behavior.
Stacked on #852 (diag branch). Fixes the tester-confirmed gaps from the 2026-07-11 log analysis:
Loopback mode (new preferred)
One
getDisplayMediaframe-audio capture covers every sound the app makes — song, previews, UI — for the whole exclusive/ASIO session (engages even with no song loaded). Desktop main answers with the app's own frame (frame-scoped). Local playback silenced viasuppressLocalAudioPlayback, page-mute IPC fallback otherwise. Sticky fallback to the existing stems/element surface modes when capture is unavailable (old desktop main, permission denied; Docker sphere never reaches the feeder).Element-capture poisoning fix (tester log, lines 985/997)
createMediaElementSource(#audio)collides with highway_3d's analyser tap (one-shot per element →InvalidStateError);_elCtxwas assigned before the throw, so every later tick died withTypeError: Cannot set properties of nullwhile the song kept playing on the default WASAPI device. Now: module state assigned only after the whole chain succeeds, context closed on failure, failed engage disables the bus + tears down loopback.Companion
Desktop PR
fix/asio-all-app-audio(display-media handler, page-mute IPC, streamer-mix engine fix).Verification
12/12 sandbox tests (5 new: loopback engage without song, loopback preferred over stems, page-mute fallback + unmute on disengage, sticky fallback on denial, collision retry without poisoning). Needs tester run on real ASIO hardware.
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes