Handoff → @OmikronApex. Follow-up to the WASAPI distortion hunt, picking up the item you scoped out of #86. The docs/native-song-playback-plan.md you referenced never landed on any branch, so this reconstructs the plan from the code — root cause is pinned to a specific gate, with both a cheap-interim and a proper fix. Findings gathered by @ChrisBeWithYou.
Context / status
This tracks the one remaining known issue from the WASAPI distortion hunt (tester: MajorMokoto, RTC + Komplete Audio; reproduced by @OmikronApex). The distortion / digital-static cluster is already fixed and awaiting merge:
#86 explicitly scopes this out: "song audio silent with exclusive output — song playback runs through the renderer; plan exists separately."
Symptom
Exclusive output mode: guitar monitoring is clean, but feedpak song audio is silent (playback pauses / won't advance). Console shows:
The AudioContext encountered an error from the audio device or the WebAudio renderer.
Shared-mode WASAPI works. Tester repro'd on a feedpak (Accept - Metal Heart).
Root cause (confirmed in code)
Device-ownership conflict. The native JUCE engine (guitar in/out + VST/NAM) opens the device in exclusive mode, so it holds the output device exclusively. Chromium's WebAudio / HTML5 <audio> — the renderer path song playback normally uses — then cannot open that same output device → the AudioContext error above → silence.
Why loose-folder songs survive but feedpak songs don't. The core already has a native-backing funnel that migrates song audio onto the engine's own backing transport when the engine is running:
static/app.js → _installJuceEngineRoutingWatcher() / window.jucePlayer → loadBackingTrack / startBacking / seekBacking (desktop src/main/audio-bridge.ts → AudioEngine::loadBackingTrack, a JUCE AudioTransportSource mixed into the engine output — so it plays fine under exclusive mode).
But feedpak/sloppak songs are gated out of that funnel:
static/highway.js:3414 — const isAudioUrl = msg.audio_url.startsWith('/audio/'); … window._currentSongAudio = { url: msg.audio_url, juceEligible: isAudioUrl };
static/app.js:5122-5123 — // Only /audio/ songs are JUCE-routable; sloppak stems stay on HTML5. if (!songAudio || !songAudio.juceEligible) return;
Loose-folder songs are served at /audio/… (single mixed file) → juceEligible = true → routed through JUCE → survive exclusive mode. Sloppak/feedpak songs are served at /api/sloppak/… → juceEligible = false → stay on HTML5/WebAudio → die in exclusive output. feedpak is the primary format, so most real songs hit this.
Why stems were excluded (two reasons):
/api/sloppak/… stem URLs aren't resolvable to a single local file via /api/audio-local-path (what loadBackingTrack needs).
- More fundamentally, stems need per-stem mixing — the stems plugin mutes the core
<audio> and drives its own WebAudio graph with per-stem faders (static/audio-mixer.js:142 "Multi-stem sloppak: the stems plugin mutes the core element … drive that master instead"). A single-file loadBackingTrack transport can't reproduce a multi-stem mix.
Fix options
Option A — native single-mix fallback (small, interim). feedpak always ships stems/full.ogg (per feedpak-spec). Expose a resolvable local path for it and mark the full-mix juceEligible, so in exclusive mode the funnel loads that single mixed file through JUCE. Cost: per-stem mixing is unavailable in exclusive mode only (acceptable degradation — the user gets song audio back). Smallest change; unblocks the common case.
Option B — mix the WebAudio master into the engine output bus (proper, general). Feed the renderer's song/stem master into the engine's output bus so it mixes with guitar and rides out the exclusively-held device. Reuse the existing stream-bus / backing-mix plumbing (setStreamBus(includeBacking, includeGuitar, gain) at src/audio/NodeAddon.cpp:1371, backing mix bus + getBackingLevel). Added latency lands only on song audio (~10–20ms per @OmikronApex's estimate) — the guitar-monitoring path is untouched, and backing latency isn't perceptually critical. Works for all songs including full stem mixing. This is the real fix; shares infrastructure with #48 (streamer mix outputs).
Rejected:
- Exclusive input + shared output — tester confirmed unplayable latency (split-mode output ring floor).
- WASAPI loopback / OS capture — Windows 10+ only, heavier; keep as last resort.
Key pointers
static/highway.js:3414 — juceEligible = audio_url.startsWith('/audio/') (the gate)
static/app.js:5119-5148 — _reevaluateJuceRouting() (the reroute watcher; the juceEligible early-return)
static/app.js:4880-5010 — _switchHtml5ToJuce() (HTML5→JUCE migration; the reject/fallback path)
static/audio-mixer.js:142-147 — stems plugin owns the master when a multi-stem sloppak is loaded
src/audio/AudioEngine.cpp:1248 loadBackingTrack + src/main/audio-bridge.ts:1192 bridge — the native transport the funnel already targets
src/audio/NodeAddon.cpp:1371 setStreamBus — the mix-bus Option B would extend
Related / secondary observations from the session (triage separately)
Context / status
This tracks the one remaining known issue from the WASAPI distortion hunt (tester: MajorMokoto, RTC + Komplete Audio; reproduced by @OmikronApex). The distortion / digital-static cluster is already fixed and awaiting merge:
#86 explicitly scopes this out: "song audio silent with exclusive output — song playback runs through the renderer; plan exists separately."
Symptom
Exclusive output mode: guitar monitoring is clean, but feedpak song audio is silent (playback pauses / won't advance). Console shows:
Shared-mode WASAPI works. Tester repro'd on a feedpak (
Accept - Metal Heart).Root cause (confirmed in code)
Device-ownership conflict. The native JUCE engine (guitar in/out + VST/NAM) opens the device in exclusive mode, so it holds the output device exclusively. Chromium's WebAudio / HTML5
<audio>— the renderer path song playback normally uses — then cannot open that same output device → the AudioContext error above → silence.Why loose-folder songs survive but feedpak songs don't. The core already has a native-backing funnel that migrates song audio onto the engine's own backing transport when the engine is running:
static/app.js→_installJuceEngineRoutingWatcher()/window.jucePlayer→loadBackingTrack/startBacking/seekBacking(desktopsrc/main/audio-bridge.ts→AudioEngine::loadBackingTrack, a JUCEAudioTransportSourcemixed into the engine output — so it plays fine under exclusive mode).But feedpak/sloppak songs are gated out of that funnel:
static/highway.js:3414—const isAudioUrl = msg.audio_url.startsWith('/audio/');…window._currentSongAudio = { url: msg.audio_url, juceEligible: isAudioUrl };static/app.js:5122-5123—// Only /audio/ songs are JUCE-routable; sloppak stems stay on HTML5.if (!songAudio || !songAudio.juceEligible) return;Loose-folder songs are served at
/audio/…(single mixed file) →juceEligible = true→ routed through JUCE → survive exclusive mode. Sloppak/feedpak songs are served at/api/sloppak/…→juceEligible = false→ stay on HTML5/WebAudio → die in exclusive output. feedpak is the primary format, so most real songs hit this.Why stems were excluded (two reasons):
/api/sloppak/…stem URLs aren't resolvable to a single local file via/api/audio-local-path(whatloadBackingTrackneeds).<audio>and drives its own WebAudio graph with per-stem faders (static/audio-mixer.js:142"Multi-stem sloppak: the stems plugin mutes the core element … drive that master instead"). A single-fileloadBackingTracktransport can't reproduce a multi-stem mix.Fix options
Option A — native single-mix fallback (small, interim). feedpak always ships
stems/full.ogg(per feedpak-spec). Expose a resolvable local path for it and mark the full-mixjuceEligible, so in exclusive mode the funnel loads that single mixed file through JUCE. Cost: per-stem mixing is unavailable in exclusive mode only (acceptable degradation — the user gets song audio back). Smallest change; unblocks the common case.Option B — mix the WebAudio master into the engine output bus (proper, general). Feed the renderer's song/stem master into the engine's output bus so it mixes with guitar and rides out the exclusively-held device. Reuse the existing stream-bus / backing-mix plumbing (
setStreamBus(includeBacking, includeGuitar, gain)atsrc/audio/NodeAddon.cpp:1371, backing mix bus +getBackingLevel). Added latency lands only on song audio (~10–20ms per @OmikronApex's estimate) — the guitar-monitoring path is untouched, and backing latency isn't perceptually critical. Works for all songs including full stem mixing. This is the real fix; shares infrastructure with #48 (streamer mix outputs).Rejected:
Key pointers
static/highway.js:3414—juceEligible = audio_url.startsWith('/audio/')(the gate)static/app.js:5119-5148—_reevaluateJuceRouting()(the reroute watcher; thejuceEligibleearly-return)static/app.js:4880-5010—_switchHtml5ToJuce()(HTML5→JUCE migration; the reject/fallback path)static/audio-mixer.js:142-147— stems plugin owns the master when a multi-stem sloppak is loadedsrc/audio/AudioEngine.cpp:1248loadBackingTrack+src/main/audio-bridge.ts:1192bridge — the native transport the funnel already targetssrc/audio/NodeAddon.cpp:1371setStreamBus— the mix-bus Option B would extendRelated / secondary observations from the session (triage separately)