feat(audio): ASIO/exclusive — renderer-bus in streamer mix, loopback plumbing, cache clear (#98)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-11 18:22:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 6349ed4c5f
commit c71aa7c82f
5 changed files with 149 additions and 27 deletions
+59 -19
View File
@@ -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<float>& guitarMix,
const juce::AudioBuffer<float>* backingBuf,
int backingFrames, float backingVol, int numSamples)
int backingFrames, float backingVol,
const juce::AudioBuffer<float>* 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<float>& 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<float>& buffer, int numSamples, int mixChannels)
int AudioEngine::pullRendererBus(juce::AudioBuffer<float>& 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<float>& 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<float>& 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
+20 -5
View File
@@ -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<float>& 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<float>& 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<float> rendererBusPullScratch;
std::atomic<uint64_t> outputRingWriteIndex{0};
std::atomic<uint64_t> 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<float>& guitarMix,
const juce::AudioBuffer<float>* backingBuf,
int backingFrames, float backingVol, int numSamples);
int backingFrames, float backingVol,
const juce::AudioBuffer<float>* rendererBuf,
int rendererFrames, int numSamples);
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioEngine)
};
+22 -1
View File
@@ -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}`);
+41 -2
View File
@@ -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
+7
View File
@@ -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<boolean> => 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<boolean> =>
ipcRenderer.invoke('audio:setPageMuted', muted),
setDeviceType: (typeName: string) => ipcRenderer.invoke('audio:setDeviceType', typeName),
setOutputDeviceType: (typeName: string) => ipcRenderer.invoke('audio:setOutputDeviceType', typeName),
setDevice: ((