mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-10 21:34:09 +00:00
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:
co-authored by
Claude Fable 5
parent
6349ed4c5f
commit
c71aa7c82f
+59
-19
@@ -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
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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: ((
|
||||
|
||||
Reference in New Issue
Block a user