From c71aa7c82fd018906562be2a7e55563012195ffa Mon Sep 17 00:00:00 2001 From: OmikronApex <45161725+OmikronApex@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:22:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(audio):=20ASIO/exclusive=20=E2=80=94=20ren?= =?UTF-8?q?derer-bus=20in=20streamer=20mix,=20loopback=20plumbing,=20cache?= =?UTF-8?q?=20clear=20(#98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(audio): renderer-bus in streamer mix + whole-app loopback plumbing Two tester-confirmed gaps under ASIO/exclusive output: 1. Streamer mix carried guitar only when the song rode the renderer bus: composeAndPushStreamMix mixed guitar + native backing, never the bus. The bus ring is single-consumer, so the consumer step is reworked from mixRendererBusInto (drain+add) to pullRendererBus (drain once into a fixed scratch); both output callbacks then share the pulled block between the device output and the stream submix (rides includeBacking — it IS song audio). 2. Previews/UI sounds bypass the per-surface feeder taps entirely and leak to the default WASAPI device (audible under ASIO, which doesn't silence that endpoint). New plumbing lets the static bundle capture ALL app audio: setDisplayMediaRequestHandler answers with this window's own frame as audio source (frame-scoped — no other apps' audio), plus audio:setPageMuted IPC + preload setPageMuted() as the local-silence fallback when suppressLocalAudioPlayback is unsupported. Co-Authored-By: Claude Fable 5 * feat(diag): engine health metrics on every [asio-diag] line Tester symptom: all audio dead after stopping a song with tones active. The snapshot showed routing state but not whether the engine was still producing. Append volatile fields (outside change-detection): in/out/ backing levels, bus fill, input overflows, output underflows, split-ring fill — outputLevel≈0 with running=true is the silent-engine signature. Co-Authored-By: Claude Fable 5 * fix(main): clear Chromium HTTP cache before first load Testers hop between portable builds sharing one userData dir; an older build's server sent no Cache-Control, so its cached /static/app.js outlived it and silently replaced the new build's renderer code (the fix14 'watcher never installed' log). One cheap clearCache() per launch makes stale-bundle states impossible even against old-server caches. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- src/audio/AudioEngine.cpp | 78 +++++++++++++++++++++++++++++---------- src/audio/AudioEngine.h | 25 ++++++++++--- src/main/audio-bridge.ts | 23 +++++++++++- src/main/main.ts | 43 ++++++++++++++++++++- src/main/preload.ts | 7 ++++ 5 files changed, 149 insertions(+), 27 deletions(-) diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index 378a285..8f4d881 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -1838,6 +1838,7 @@ void AudioEngine::audioDeviceAboutToStart(juce::AudioIODevice* device) constexpr int streamScratchCap = (int) kOutputRingFrames; if (streamGuitarScratch.getNumSamples() < streamScratchCap) streamGuitarScratch.setSize(2, streamScratchCap, false, false, true); if (streamMixScratch.getNumSamples() < streamScratchCap) streamMixScratch.setSize(2, streamScratchCap, false, false, true); + if (rendererBusPullScratch.getNumSamples() < streamScratchCap) rendererBusPullScratch.setSize(2, streamScratchCap, false, false, true); // Prepare each ACTIVE PRIMARY-device source's DSP and reset its rings for a // clean cold start. Inactive pooled chains stay unprepared (no threads). EXTRA- @@ -1929,6 +1930,7 @@ void AudioEngine::audioOutputAboutToStart(juce::AudioIODevice* device) constexpr int streamScratchCap = (int) kOutputRingFrames; if (streamGuitarScratch.getNumSamples() < streamScratchCap) streamGuitarScratch.setSize(2, streamScratchCap, false, false, true); if (streamMixScratch.getNumSamples() < streamScratchCap) streamMixScratch.setSize(2, streamScratchCap, false, false, true); + if (rendererBusPullScratch.getNumSamples() < streamScratchCap) rendererBusPullScratch.setSize(2, streamScratchCap, false, false, true); // NOTE: outputBackingBuffer is sized by audioDeviceAboutToStart() from the // INPUT device's block size — it's the split-input DSP scratch, not an // output-side buffer. Don't touch it here: resizing from the output @@ -1998,7 +2000,9 @@ void AudioEngine::audioOutputStopped() void AudioEngine::composeAndPushStreamMix(const juce::AudioBuffer& guitarMix, const juce::AudioBuffer* backingBuf, - int backingFrames, float backingVol, int numSamples) + int backingFrames, float backingVol, + const juce::AudioBuffer* rendererBuf, + int rendererFrames, int numSamples) { if (! streamSink.active.load(std::memory_order_acquire)) return; // A block larger than the entire ring can't be published atomically (it would @@ -2036,6 +2040,20 @@ void AudioEngine::composeAndPushStreamMix(const juce::AudioBuffer& guitar streamMixScratch.addFrom(ch, 0, *backingBuf, juce::jmin(ch, backingBuf->getNumChannels() - 1), 0, n, backingVol); } + // Renderer-fed song audio (stems / element / loopback riding the renderer + // bus) is song audio for the streamer too — without this the stream mix + // carries guitar only whenever the song bypasses the native transport + // (multi-stem under exclusive/ASIO output). Bus gain is already applied by + // pullRendererBus; only the stream gain below shapes it further. Backing + // transport and renderer bus are mutually exclusive song paths in + // practice, so this never double-carries. + if (ib && rendererBuf != nullptr && rendererFrames > 0) + { + const int n = juce::jmin(rendererFrames, numSamples); + for (int ch = 0; ch < 2; ++ch) + streamMixScratch.addFrom(ch, 0, *rendererBuf, + juce::jmin(ch, rendererBuf->getNumChannels() - 1), 0, n); + } streamMixScratch.applyGain(0, 0, numSamples, gain); streamMixScratch.applyGain(1, 0, numSamples, gain); @@ -2365,17 +2383,25 @@ void AudioEngine::audioDeviceIOCallbackWithContext( currentBackingLevel.store(0.0f); } - // Stream sink: compose + push the stream submix (guitar snapshot + backing) - // BEFORE the local master output gain, so the stream level is independent. + // Renderer bus (duplex clock): pulled ONCE per block into the scratch, + // then shared by the stream submix and the device output — the ring is + // a single-consumer SPSC, so the stream path must not drain it again. + const int rendererFrames = pullRendererBus(rendererBusPullScratch, numSamples); + + // Stream sink: compose + push the stream submix (guitar snapshot + + // backing + renderer-bus song audio) BEFORE the local master output + // gain, so the stream level is independent. if (streamActive) composeAndPushStreamMix(streamGuitarScratch, streamBackingOn ? &backingBuffer : nullptr, - streamBackingFrames, streamBackingVol, numSamples); + streamBackingFrames, streamBackingVol, + rendererFrames > 0 ? &rendererBusPullScratch : nullptr, + rendererFrames, numSamples); - // Renderer bus (duplex clock): mixed like backing — after the stream - // snapshot (the stream submix must not double-carry song audio the - // renderer also feeds), before the master gain. - mixRendererBusInto(buffer, numSamples, juce::jmin(numOutputChannels, 2)); + // Renderer bus into the device output — like backing, before master gain. + if (rendererFrames > 0) + for (int ch = 0; ch < juce::jmin(numOutputChannels, 2); ++ch) + buffer.addFrom(ch, 0, rendererBusPullScratch, ch, 0, rendererFrames); // Apply output gain buffer.applyGain(outputGain.load()); @@ -3031,6 +3057,11 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, currentBackingLevel.store(0.0f); } + // Renderer bus (split clock): pulled ONCE per block into the scratch, + // shared by the stream submix and the device output (single-consumer + // ring — the stream path must not drain it again). + const int rendererFrames = pullRendererBus(rendererBusPullScratch, numSamples); + // Stream sink: compose + push the stream submix before the local master // gain. Done INSIDE the backingLock scope so backingBuffer is read under the // very lock that guards its resize (audio*AboutToStart) — matching the duplex @@ -3039,11 +3070,15 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, if (streamActive) composeAndPushStreamMix(streamGuitarScratch, streamBackingOn ? &backingBuffer : nullptr, - streamBackingFrames, streamBackingVol, numSamples); - } + streamBackingFrames, streamBackingVol, + rendererFrames > 0 ? &rendererBusPullScratch : nullptr, + rendererFrames, numSamples); - // Renderer bus (split clock): mixed like backing — before the master gain. - mixRendererBusInto(buffer, numSamples, copyChannels); + // Renderer bus into the device output — like backing, before master gain. + if (rendererFrames > 0) + for (int ch = 0; ch < juce::jmin(copyChannels, 2); ++ch) + buffer.addFrom(ch, 0, rendererBusPullScratch, ch, 0, rendererFrames); + } buffer.applyGain(outputGain.load()); @@ -3105,9 +3140,12 @@ bool AudioEngine::pushRendererAudio(const float* interleavedLR, int frames, doub return true; } -void AudioEngine::mixRendererBusInto(juce::AudioBuffer& buffer, int numSamples, int mixChannels) +int AudioEngine::pullRendererBus(juce::AudioBuffer& dest, int numSamples) { - if (!rendererBusEnabled.load(std::memory_order_acquire)) return; + if (!rendererBusEnabled.load(std::memory_order_acquire)) return 0; + // Cold start before about-to-start sized the scratch — skip, never alloc + // on the RT thread (same rule as the stream scratches). + if (dest.getNumSamples() < numSamples || dest.getNumChannels() < 2) return 0; constexpr uint64_t kMask = kRendererBusFrames - 1; const uint64_t w = rendererBusWriteIndex.load(std::memory_order_acquire); uint64_t r = rendererBusReadIndex.load(std::memory_order_relaxed); @@ -3139,7 +3177,7 @@ void AudioEngine::mixRendererBusInto(juce::AudioBuffer& buffer, int numSa if (avail < (uint64_t) kRendererBusPrimeFrames) { rendererBusReadIndex.store(r, std::memory_order_release); - return; + return 0; } rendererBusPrimed = true; } @@ -3150,21 +3188,23 @@ void AudioEngine::mixRendererBusInto(juce::AudioBuffer& buffer, int numSa rendererBusPrimed = false; rendererBusUnderflowCount.fetch_add(1, std::memory_order_relaxed); rendererBusReadIndex.store(w, std::memory_order_release); - return; + return 0; } const int pull = numSamples; - const int chans = juce::jmin(mixChannels, buffer.getNumChannels()); const float g = rendererBusGain.load(std::memory_order_relaxed); + float* dl = dest.getWritePointer(0); + float* dr = dest.getWritePointer(1); for (int i = 0; i < pull; ++i) { float l, rr; unpackLR(rendererBusRing[(size_t) ((r + (uint64_t) i) & kMask)].load(std::memory_order_relaxed), l, rr); - buffer.addSample(0, i, l * g); - if (chans > 1) buffer.addSample(1, i, rr * g); + dl[i] = l * g; + dr[i] = rr * g; } rendererBusReadIndex.store(r + (uint64_t) pull, std::memory_order_release); rendererBusConsumedFrames.fetch_add((uint64_t) pull, std::memory_order_relaxed); + return pull; } AudioEngine::RendererBusMetrics AudioEngine::getRendererBusMetrics() const diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index f4c3333..a384ae5 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -621,8 +621,17 @@ private: // interpolation continuity across pushes). double rendererBusSrcPos = 0.0; float rendererBusPrevL = 0.0f, rendererBusPrevR = 0.0f; - // Shared consumer step for the duplex and split output paths. - void mixRendererBusInto(juce::AudioBuffer& buffer, int numSamples, int mixChannels); + // Shared consumer step for the duplex and split output paths: drain one + // block from the renderer-bus ring into `dest` (stereo, bus gain applied, + // dest cleared first). Returns numSamples on success, 0 when gated + // (disabled, priming, underflow, scratch undersized). Single consumer — + // call exactly once per output block; the caller mixes the pulled block + // into the device output AND hands it to composeAndPushStreamMix so the + // streamer submix carries renderer-fed song audio too. + int pullRendererBus(juce::AudioBuffer& dest, int numSamples); + // Scratch for the per-block renderer-bus pull. Fixed capacity, sized once + // in about-to-start next to the stream scratches (same no-realloc rule). + juce::AudioBuffer rendererBusPullScratch; std::atomic outputRingWriteIndex{0}; std::atomic outputRingReadIndex{0}; @@ -794,11 +803,17 @@ private: // inputs). Also the single teardown used by the dtor and clearStreamOutput(). void closeStreamSinkDevice(); // Compose the stream submix from the captured guitar mix + the just-rendered - // backing block and pack it into the stream ring. Called from both output - // callbacks after backing render. `backingBuf` may be null (not playing). + // backing block + the just-pulled renderer-bus block and pack it into the + // stream ring. Called from both output callbacks after backing render. + // `backingBuf` / `rendererBuf` may be null (not playing / bus gated). + // The renderer bus rides the includeBacking flag: it IS song audio, just + // fed from the renderer instead of the native transport (bus gain already + // applied by pullRendererBus). void composeAndPushStreamMix(const juce::AudioBuffer& guitarMix, const juce::AudioBuffer* backingBuf, - int backingFrames, float backingVol, int numSamples); + int backingFrames, float backingVol, + const juce::AudioBuffer* rendererBuf, + int rendererFrames, int numSamples); JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioEngine) }; diff --git a/src/main/audio-bridge.ts b/src/main/audio-bridge.ts index 4740243..c2d1866 100644 --- a/src/main/audio-bridge.ts +++ b/src/main/audio-bridge.ts @@ -381,7 +381,28 @@ export function initAudioBridge(): void { if (snapshot !== lastSnapshot || now - lastLogged > 30000) { lastSnapshot = snapshot; lastLogged = now; - console.log(`[asio-diag] engine: ${snapshot}`); + // Volatile health fields ride along on every logged line + // but stay OUT of the change-detection snapshot (levels + // churn every block — keying on them would log every 2s). + // outputLevel≈0 while running is the "engine produces + // silence" symptom (audio dead after song stop); ring + // fill/underflows separate a stalled callback from a + // silent mix. + let volatilePart = ''; + try { + const lv = audio.getLevels?.() ?? null; + const dm = audio.getDeviceMetrics?.() ?? null; + volatilePart = ' levels=' + JSON.stringify({ + in: +(lv?.inputLevel ?? -1).toFixed(4), + out: +(lv?.outputLevel ?? -1).toFixed(4), + backing: +(audio.getBackingLevel?.() ?? -1).toFixed(4), + busFill: bus?.fillFrames ?? null, + inOverflows: dm?.inputOverflowCount ?? null, + outUnderflows: dm?.outputUnderflowCount ?? null, + outRingFill: dm?.outputRingFillFrames ?? null, + }); + } catch (_) { /* metrics best-effort */ } + console.log(`[asio-diag] engine: ${snapshot}${volatilePart}`); } } catch (e: any) { console.warn(`[asio-diag] snapshot failed: ${e.message}`); diff --git a/src/main/main.ts b/src/main/main.ts index 5c72d2a..12a3803 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -56,7 +56,7 @@ if (process.platform !== 'linux') { } // ────────────────────────────────────────────────────────────────────────── -import { app, BrowserWindow, ipcMain, dialog, shell, session, crashReporter, powerSaveBlocker, systemPreferences, screen } from 'electron'; +import { app, BrowserWindow, ipcMain, dialog, shell, session, crashReporter, powerSaveBlocker, systemPreferences, desktopCapturer, screen } from 'electron'; import * as path from 'path'; import * as fs from 'fs'; import { execFileSync } from 'child_process'; @@ -500,10 +500,49 @@ function createWindow(port: number): void { console.log(`${prefix} ${message}`); }); + // Whole-app audio capture for exclusive-style outputs (ASIO / WASAPI + // exclusive). The renderer-bus feeder in the static bundle calls + // getDisplayMedia({audio, video}) to capture EVERY sound the app makes + // (song, previews, UI) and push it into the engine's renderer bus — + // per-surface taps can't cover plugin-private AudioContexts. Answer the + // request with this window's own frame as the audio source (frame-scoped: + // other applications' audio is NOT captured — a system 'loopback' would + // leak Discord/etc. into the performance mix) and any screen as the + // required-but-unused video track (the feeder stops it immediately). + session.defaultSession.setDisplayMediaRequestHandler((_request, callback) => { + desktopCapturer.getSources({ types: ['screen'] }).then((sources) => { + if (!mainWindow || sources.length === 0) { callback({}); return; } + callback({ video: sources[0], audio: mainWindow.webContents.mainFrame }); + }).catch(() => callback({})); + }); + + // Local-mute companion for the capture above: when the feeder engages + // loopback it must stop the page's audio from ALSO reaching the default + // WASAPI device. Preferred path is the suppressLocalAudioPlayback track + // constraint; this IPC is the fallback when the constraint is + // unsupported. Chromium's capture pipeline taps frame audio before the + // output mute, so a muted page still feeds the captured stream. + ipcMain.handle('audio:setPageMuted', (_event, muted: unknown) => { + if (!mainWindow) return false; + mainWindow.webContents.setAudioMuted(muted === true); + return mainWindow.webContents.isAudioMuted(); + }); + const serverUrl = `http://127.0.0.1:${port}`; + // Clear the Chromium HTTP cache before the first load. The server + // historically sent no Cache-Control on /static, so heuristic freshness + // let a NEW build's window run the PREVIOUS build's app.js from disk + // cache (2026-07-11 ASIO investigation: the whole exclusive-reroute chain + // silently missing). The server now sends no-cache, but testers hop + // between portable builds sharing one userData dir — one cheap clear per + // launch makes stale-bundle states impossible regardless of what an + // older build's server cached. + const clearCachePromise = mainWindow.webContents.session.clearCache() + .catch((e) => console.warn(`[main] clearCache failed (continuing): ${e.message}`)); + // Small delay to ensure server is fully accepting connections, then load - setTimeout(() => mainWindow?.loadURL(serverUrl), 500); + setTimeout(() => { void clearCachePromise.then(() => mainWindow?.loadURL(serverUrl)); }, 500); // Retry loading if the server wasn't reachable yet. Previously this // retried just once, which left the window stuck on Chromium's diff --git a/src/main/preload.ts b/src/main/preload.ts index 3051064..b1f0467 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -216,6 +216,13 @@ const feedBackDesktopApi = { // The static bundle gates its verbose [asio-diag] routing lines on // this so normal runs don't spam the console. debugEnabled: (): Promise => ipcRenderer.invoke('debug:isEnabled'), + // Mute/unmute the whole page's local audio output. Fallback for the + // loopback feeder when the suppressLocalAudioPlayback capture + // constraint is unsupported — a muted page still feeds the captured + // stream (capture taps frame audio before the output mute). Resolves + // with the resulting muted state. + setPageMuted: (muted: boolean): Promise => + ipcRenderer.invoke('audio:setPageMuted', muted), setDeviceType: (typeName: string) => ipcRenderer.invoke('audio:setDeviceType', typeName), setOutputDeviceType: (typeName: string) => ipcRenderer.invoke('audio:setOutputDeviceType', typeName), setDevice: ((