feat(audio): renderer-audio bus — mix renderer WebAudio master into engine output (Phase 2) (#91)

* fix(audio): prefer same-backend duplex routing

* fix(audio): centre mono input; limit duplex to same-endpoint devices

Two issues found while testing the USB-guitar-cable path on Windows:

1. Centre a mono input. SourceChain::processBlock fell into the
   pass-through branch for a 1-channel input, filling only
   min(inputChannels, outputChannels) = 1 output channel and zeroing the
   rest, so a mono USB guitar cable played out of the left speaker only.
   A single-channel input is now broadcast across every output channel.

2. Only attempt the combined (duplex) device when input and output are
   the SAME physical endpoint. Two different endpoints of the same
   backend (USB cable in + separate speakers out) are independent
   hardware clocks; routing them through one duplex device was unstable
   across the app lifecycle (no audio until an explicit Apply, then
   distortion / dropouts / silent-in-song on navigation). Different
   endpoints now use the split path, whose ring bridges the two clocks.
   Same-endpoint duplex (one interface for in and out) keeps the
   low-latency win. Low latency for the two-device case is a follow-up
   that needs the device-lifecycle work (startup restore + reconfigure
   on navigation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

* fix(audio): probe same-endpoint duplex the same way apply routes it

Startup auto-apply (renderer init) fail-closes on probeDeviceOptionsDual's
`compatible` verdict, but the probe still measured a COMBINED duplex device
for any same-backend pair while setAudioDevices now opens split for
different endpoints. That mismatch made the startup probe describe a config
that isn't the one applied — surfacing as "no audio until I press Apply" for
a USB cable + separate speakers. Gate the probe's duplex path on the same
sameEndpointIntent (same type AND same device) the apply path uses, so a
two-device pair is probed via the split path it will actually run on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu

* fix(audio): never feed chain processors blocks larger than prepared size

WASAPI shared mode can deliver oversized blocks right after a device
start. The NAM core pre-allocates its conv ring/output buffers to the
Reset() maxBufferSize and only asserts (release no-op) on larger blocks;
one oversized block corrupts the conv ring state and garbles all
subsequent audio until the next Reset() — the 'first start heavily
distorted until tone reset / engine restart' bug.

- NAMProcessor::processBlock: process in slices of at most the prepared
  block size.
- SignalChain::process: slice oversized device blocks into prepared-size
  chunks before any slot (VST/NAM/IR) sees them.

See docs/audio-distortion-first-start-investigation.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audio): close stale-format race when processors are added mid-reconfigure

addProcessor/replaceProcessor prepare the incoming processor off the
audio lock on N-API worker threads. A concurrent device reconfigure's
SignalChain::prepare() can't see that processor (not slotted yet), so a
slot could go live prepared at a stale sample rate / block size and stay
wrong until the next device restart — heard as pitch-shifted/garbled
monitoring when a chain loads while the device is being (re)opened
(widest window: WASAPI exclusive mode's slower open).

Re-check the chain's current format under the lock at insert/swap time
and re-prepare if it moved; log the transition to stderr so tester logs
show when the race fired. prepare() now publishes the format under the
lock so the check can't tear.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(effects): align executor plan schema with rebranded capability layer

The rebrand left a three-way schema split: rig_builder sent the old
'slopsmith.audio_effects.chain_plan.v1', the renderer capability layer
validated against the new 'feedBack.…' id (rejecting every plan), and
this executor still expected the old one. Result: every song chain load
fell back to legacy clearChain+loadPreset — a full multi-VST rebuild per
currentSong poll cycle, heard as continuous distortion during playback
(tester logs: 4-6 rebuilds/session, slot IDs into the 90s).

Executor now uses the rebranded id and accepts the legacy one as an
alias (matching the capability layer's new alias), so neither side of
the handoff can break on old plugin bundles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(audio): guard input callback against double registration

Tester main-process log showed two consecutive 'startAudio: duplex=0'
lines: a transient audioDeviceStopped() (WASAPI exclusive opens fire one
mid-start) cleared audioRunning while the input callback stayed
attached, so the second startAudio() re-added it. JUCE then dispatched
the input callback twice per block: DSP ran twice and each block was
pushed into the split ring twice — every sample played twice (half
speed, one octave down, garbled). stopAudio()'s single
removeAudioCallback left the duplicate registration alive, wedging the
engine (restart no longer helped) and keeping the exclusive-mode device
open even after app close.

Mirror the existing outputCallbackRegistered guard for the input side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(audio): diagnostic instrumentation for tester repro builds

Main-process stderr logging on every open lead, RT paths rate-limited
(first-25 per anomaly + ~5s heartbeats per callback clock):

- primary callback re-entrancy (duplicate registration detector)
- oversized blocks on primary/output callbacks, SignalChain slicing,
  NAM chunking (pre-fix corruption trigger visibility)
- ring fill + under/overflow counters (split-mode pacing)
- device lifecycle: aboutToStart/stopped on both managers with sr/bs and
  callback-registration flags; startAudio guard-skip; stopAudio state
- SourceChain.prepare format trace (stale-rate lead)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(audio): drop diagnostic heartbeats, keep anomaly detectors

The 5s ring/format heartbeats served the distortion hunt and are noise
now. Keep the cheap anomaly-only diagnostics (callback re-entrancy,
oversized-block detectors, chain slicing/chunking, stale-format
re-prepare, device lifecycle) — they log only on misbehavior and stay
relevant for the exclusive-mode playback work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: keep investigation notes out of the PR (local working notes)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: pin JUCE WASAPI exclusive device-type name

The shared player bundle (feedBack#824) detects exclusive-style output
by string-matching getCurrentDevice().outputType. The name is hardcoded
in vendored JUCE; a JUCE upgrade renaming it would silently disable the
feedpak-under-exclusive routing. Fail the build instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(audio): renderer-audio bus — mix renderer WebAudio master into engine output (Phase 2)

SPSC packed-LR ring (64K frames) fed over IPC by the renderer, consumed by
whichever output callback is live (duplex or split), mixed like a backing
track before master gain. Producer-side linear resampling with cross-chunk
continuity; drop-oldest on overflow. Prefill gate (~10.7 ms) and fill clamp
(~85 ms → trim to prime target) added from fix12 tester spike data, which
also confirmed clock stability (drift → 0, zero overflow over 8 min).
Off by default — zero behavior change until the renderer enables it.

Exposed as setRendererBus / pushRendererAudio (fire-and-forget IPC,
~100 msgs/s) / getRendererBusMetrics. Spike script included for tester
go/no-go runs. Consumed by the feeder in feedBack#824's Phase 2 follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(audio): gate lifecycle [diag] logs behind SLOPSMITH_SANDBOX_DEBUG

Review follow-up (#86): the six lifecycle diagnostics (stopAudio,
audioDeviceAboutToStart/Stopped, audioOutputAboutToStart/Stopped,
SourceChain::prepare) printed unconditionally while the PR body claimed
they were verbose-gated. Gate them behind the existing
slopsmith_vst_trace::isEnabled() runtime flag (SLOPSMITH_SANDBOX_DEBUG —
already flipped by the app's debug-logging switch, so tester debug runs
still capture them). The RT-path anomaly detectors (primary re-entry,
oversized-block) keep their firstN/anomaly bounds unchanged, as reviewed.

VSTTrace.h now defines NOMINMAX/WIN32_LEAN_AND_MEAN before windows.h so
including it from engine TUs doesn't clobber std::min.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-09 23:03:31 +02:00
committed by GitHub
co-authored by Claude Fable 5 ChrisBeWithYou
parent 2d0dd12abe
commit 31248617a5
6 changed files with 412 additions and 0 deletions
+135
View File
@@ -2372,6 +2372,11 @@ void AudioEngine::audioDeviceIOCallbackWithContext(
streamBackingOn ? &backingBuffer : nullptr,
streamBackingFrames, streamBackingVol, 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));
// Apply output gain
buffer.applyGain(outputGain.load());
@@ -3037,6 +3042,9 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/,
streamBackingFrames, streamBackingVol, numSamples);
}
// Renderer bus (split clock): mixed like backing — before the master gain.
mixRendererBusInto(buffer, numSamples, copyChannels);
buffer.applyGain(outputGain.load());
float peak = 0.0f;
@@ -3046,3 +3054,130 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/,
float prevPeak = outputPeak.load();
if (peak > prevPeak) outputPeak.store(peak);
}
// ── Renderer-audio bus (Phase 2: WebAudio master → engine output) ────────────
bool AudioEngine::pushRendererAudio(const float* interleavedLR, int frames, double sourceRate)
{
if (!rendererBusEnabled.load(std::memory_order_acquire)) return false;
if (interleavedLR == nullptr || frames <= 0) return false;
const double deviceRate = getCurrentSampleRate();
if (deviceRate <= 0.0) return false;
if (!(sourceRate > 0.0)) sourceRate = deviceRate;
constexpr uint64_t kMask = kRendererBusFrames - 1;
uint64_t w = rendererBusWriteIndex.load(std::memory_order_relaxed);
// Linear resample source→device rate on this (IPC) thread. `pos` is the
// fractional read position into the incoming chunk; index -1 refers to the
// carried last frame of the previous chunk so interpolation is continuous
// across pushes. Equal rates degenerate to step == 1.0 (still exact:
// pos stays integral, frac == 0).
const double step = sourceRate / deviceRate;
double pos = rendererBusSrcPos;
uint64_t written = 0;
while (true)
{
const double ip = std::floor(pos);
const int i0 = (int) ip;
if (i0 + 1 >= frames) break; // next chunk continues from here
const float frac = (float) (pos - ip);
const float l0 = (i0 < 0) ? rendererBusPrevL : interleavedLR[(size_t) i0 * 2];
const float r0 = (i0 < 0) ? rendererBusPrevR : interleavedLR[(size_t) i0 * 2 + 1];
const float l1 = interleavedLR[((size_t) i0 + 1) * 2];
const float r1 = interleavedLR[((size_t) i0 + 1) * 2 + 1];
rendererBusRing[(size_t) (w & kMask)].store(
packLR(l0 + (l1 - l0) * frac, r0 + (r1 - r0) * frac),
std::memory_order_relaxed);
++w;
++written;
pos += step;
}
rendererBusSrcPos = pos - (double) frames; // relative to the next chunk
rendererBusPrevL = interleavedLR[((size_t) frames - 1) * 2];
rendererBusPrevR = interleavedLR[((size_t) frames - 1) * 2 + 1];
// Publish. Overflow (producer lapping the consumer) is handled consumer-
// side with drop-oldest — same contract as the extra-input rings — so only
// the consumer ever moves readIndex.
rendererBusWriteIndex.store(w, std::memory_order_release);
rendererBusPushedFrames.fetch_add(written, std::memory_order_relaxed);
return true;
}
void AudioEngine::mixRendererBusInto(juce::AudioBuffer<float>& buffer, int numSamples, int mixChannels)
{
if (!rendererBusEnabled.load(std::memory_order_acquire)) return;
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);
if (w - r > (uint64_t) kRendererBusFrames)
{
// Producer lapped us — drop-oldest to the newest full ring.
r = w - (uint64_t) kRendererBusFrames;
rendererBusOverflowCount.fetch_add(1, std::memory_order_relaxed);
}
uint64_t avail = w - r;
// Fill clamp (spike finding): steady-state drift is near zero, so a fill
// beyond kRendererBusMaxFill only ever means a renderer stall dumped a
// backlog. Trim to the prime target instead of playing the whole tail at
// ~85+ ms behind — a latency reset, not an audible gap.
if (avail > (uint64_t) kRendererBusMaxFillFrames)
{
r = w - (uint64_t) kRendererBusPrimeFrames;
avail = (uint64_t) kRendererBusPrimeFrames;
rendererBusOverflowCount.fetch_add(1, std::memory_order_relaxed);
}
// Prefill gate (spike finding): the warmup underflow burst is the mix
// starting before the ring has a cushion. Consume nothing until the
// producer has built ~10 ms; re-arm the same gate after a real underflow
// so stall recovery is one clean gap, not a ragged refill.
if (!rendererBusPrimed)
{
if (avail < (uint64_t) kRendererBusPrimeFrames)
{
rendererBusReadIndex.store(r, std::memory_order_release);
return;
}
rendererBusPrimed = true;
}
if (avail < (uint64_t) numSamples)
{
// Underflow: emit silence for the whole block (partial blocks blip),
// drop what's buffered, and go back to priming.
rendererBusPrimed = false;
rendererBusUnderflowCount.fetch_add(1, std::memory_order_relaxed);
rendererBusReadIndex.store(w, std::memory_order_release);
return;
}
const int pull = numSamples;
const int chans = juce::jmin(mixChannels, buffer.getNumChannels());
const float g = rendererBusGain.load(std::memory_order_relaxed);
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);
}
rendererBusReadIndex.store(r + (uint64_t) pull, std::memory_order_release);
rendererBusConsumedFrames.fetch_add((uint64_t) pull, std::memory_order_relaxed);
}
AudioEngine::RendererBusMetrics AudioEngine::getRendererBusMetrics() const
{
RendererBusMetrics m;
m.pushedFrames = rendererBusPushedFrames.load(std::memory_order_relaxed);
m.consumedFrames = rendererBusConsumedFrames.load(std::memory_order_relaxed);
m.underflowCount = rendererBusUnderflowCount.load(std::memory_order_relaxed);
m.overflowCount = rendererBusOverflowCount.load(std::memory_order_relaxed);
const uint64_t w = rendererBusWriteIndex.load(std::memory_order_acquire);
const uint64_t r = rendererBusReadIndex.load(std::memory_order_acquire);
m.fillFrames = (int) juce::jmin(w - r, (uint64_t) kRendererBusFrames);
m.capacityFrames = kRendererBusFrames;
m.enabled = rendererBusEnabled.load(std::memory_order_relaxed);
return m;
}
+68
View File
@@ -257,6 +257,40 @@ public:
streamBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed);
}
void setStreamBusGain(float gain) { streamBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); }
// ── Renderer-audio bus (Phase 2: WebAudio master → engine output) ─────────
// The renderer pushes its WebAudio master mix here (via IPC) so song/stem
// audio stays audible when the output device is exclusive-style and the OS
// mixer path is silenced. SPSC: producer is the main-process IPC thread,
// consumer is whichever output callback is live (duplex or split). Default
// off → zero behaviour change.
void setRendererBus(bool enabled, float gain)
{
rendererBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed);
const bool was = rendererBusEnabled.exchange(enabled, std::memory_order_acq_rel);
if (was && !enabled)
{
// Drop buffered audio on disable so a later re-enable starts fresh
// instead of playing a stale tail. Consumer tolerates the jump.
rendererBusReadIndex.store(
rendererBusWriteIndex.load(std::memory_order_acquire),
std::memory_order_release);
rendererBusPrimed.store(false, std::memory_order_relaxed);
}
}
// Interleaved stereo frames at `sourceRate`; linear-resampled to the device
// rate on the producer thread (fractional position + previous frame carried
// across calls). Returns false when the bus is disabled or the engine is
// not running. Drop-oldest on overflow, counted.
bool pushRendererAudio(const float* interleavedLR, int frames, double sourceRate);
struct RendererBusMetrics
{
uint64_t pushedFrames = 0, consumedFrames = 0, underflowCount = 0, overflowCount = 0;
int fillFrames = 0, capacityFrames = 0;
bool enabled = false;
};
RendererBusMetrics getRendererBusMetrics() const;
float getStreamSinkLevel() const { return streamSinkLevel.load(std::memory_order_relaxed); }
uint64_t getStreamUnderflowCount() const { return streamSink.underflowCount.load(std::memory_order_relaxed); }
// Producer overflow (drop-oldest): the consumer fell a full ring behind and
@@ -556,6 +590,40 @@ private:
r = std::bit_cast<float>(static_cast<uint32_t>(v >> 32));
}
// ── Renderer-audio bus ring (see setRendererBus/pushRendererAudio) ───────
// Same packed-LR SPSC design as outputPendingRing. Sized generously
// (~1.5 s @ 48 kHz — vs outputPendingRing's 85 ms) because the producer is
// an IPC thread with scheduling jitter, not another audio callback; the
// consumer trims steady-state fill via the drift clamp in the mix step.
static constexpr int kRendererBusFrames = 65536;
static_assert((kRendererBusFrames & (kRendererBusFrames - 1)) == 0,
"kRendererBusFrames must be a power of two for mask wraparound");
// Prefill gate: consume nothing until the producer has built this cushion
// (~10.7 ms @ 48 kHz); re-armed after every underflow so stall recovery is
// one clean gap. Fill clamp: fill beyond this (~85 ms) means a renderer
// stall dumped a backlog — trim to the prime target, don't play the tail.
static constexpr int kRendererBusPrimeFrames = 512;
static constexpr int kRendererBusMaxFillFrames = 4096;
std::array<std::atomic<uint64_t>, kRendererBusFrames> rendererBusRing{};
std::atomic<uint64_t> rendererBusWriteIndex{0};
std::atomic<uint64_t> rendererBusReadIndex{0};
std::atomic<uint64_t> rendererBusPushedFrames{0};
std::atomic<uint64_t> rendererBusConsumedFrames{0};
std::atomic<uint64_t> rendererBusUnderflowCount{0};
std::atomic<uint64_t> rendererBusOverflowCount{0};
std::atomic<bool> rendererBusEnabled{false};
std::atomic<float> rendererBusGain{1.0f};
// Consumer-side prefill-gate state. Only the live output callback touches
// it, but duplex/split hand-offs cross threads — atomic keeps that safe.
std::atomic<bool> rendererBusPrimed{false};
// Producer-thread-only linear-resampler state (fractional read position
// into the incoming chunk + the previous chunk's last frame for
// 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);
std::atomic<uint64_t> outputRingWriteIndex{0};
std::atomic<uint64_t> outputRingReadIndex{0};
std::atomic<uint64_t> outputUnderflowCount{0};
+56
View File
@@ -1414,6 +1414,59 @@ static Napi::Value SetStreamBusGain(const Napi::CallbackInfo& info)
return info.Env().Undefined();
}
// setRendererBus(enabled:boolean, gain:number)
static Napi::Value SetRendererBus(const Napi::CallbackInfo& info)
{
auto liveEngine = snapshotEngine();
if (liveEngine && info.Length() >= 2 && info[0].IsBoolean() && info[1].IsNumber())
liveEngine->setRendererBus(info[0].As<Napi::Boolean>().Value(),
(float) info[1].As<Napi::Number>().DoubleValue());
return info.Env().Undefined();
}
// pushRendererAudio(interleavedLR:Float32Array, sourceRate:number) -> boolean
// Interleaved stereo (L0 R0 L1 R1 …); sourceRate is the renderer's
// AudioContext sample rate. Returns false when the bus is off / engine down /
// malformed args, so the renderer can stop pushing.
static Napi::Value PushRendererAudio(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto liveEngine = snapshotEngine();
if (!liveEngine || info.Length() < 2 || !info[0].IsTypedArray() || !info[1].IsNumber())
return Napi::Boolean::New(env, false);
auto ta = info[0].As<Napi::TypedArray>();
if (ta.TypedArrayType() != napi_float32_array)
return Napi::Boolean::New(env, false);
auto f32 = info[0].As<Napi::Float32Array>();
const size_t samples = f32.ElementLength();
if (samples < 2)
return Napi::Boolean::New(env, false);
const int frames = (int) (samples / 2);
const bool ok = liveEngine->pushRendererAudio(
f32.Data(), frames, info[1].As<Napi::Number>().DoubleValue());
return Napi::Boolean::New(env, ok);
}
// getRendererBusMetrics() -> {enabled, fillFrames, capacityFrames,
// pushedFrames, consumedFrames,
// underflowCount, overflowCount}
static Napi::Value GetRendererBusMetrics(const Napi::CallbackInfo& info)
{
auto env = info.Env();
auto liveEngine = snapshotEngine();
auto obj = Napi::Object::New(env);
if (!liveEngine) return obj;
const auto m = liveEngine->getRendererBusMetrics();
obj.Set("enabled", m.enabled);
obj.Set("fillFrames", m.fillFrames);
obj.Set("capacityFrames", m.capacityFrames);
obj.Set("pushedFrames", (double) m.pushedFrames);
obj.Set("consumedFrames", (double) m.consumedFrames);
obj.Set("underflowCount", (double) m.underflowCount);
obj.Set("overflowCount", (double) m.overflowCount);
return obj;
}
// getStreamSinkLevel() -> number (peak 0..1+)
static Napi::Value GetStreamSinkLevel(const Napi::CallbackInfo& info)
{
@@ -3565,6 +3618,9 @@ static Napi::Object InitModule(Napi::Env env, Napi::Object exports)
exports.Set("clearStreamOutput", Napi::Function::New(env, ClearStreamOutput));
exports.Set("setStreamBus", Napi::Function::New(env, SetStreamBus));
exports.Set("setStreamBusGain", Napi::Function::New(env, SetStreamBusGain));
exports.Set("setRendererBus", Napi::Function::New(env, SetRendererBus));
exports.Set("pushRendererAudio", Napi::Function::New(env, PushRendererAudio));
exports.Set("getRendererBusMetrics", Napi::Function::New(env, GetRendererBusMetrics));
exports.Set("getStreamSinkLevel", Napi::Function::New(env, GetStreamSinkLevel));
exports.Set("isStreamOutputActive", Napi::Function::New(env, IsStreamOutputActive));
exports.Set("getStreamUnderflowCount", Napi::Function::New(env, GetStreamUnderflowCount));
+25
View File
@@ -862,6 +862,31 @@ export function initAudioBridge(): void {
}
});
// ── Renderer-audio bus (Phase 2: WebAudio master → engine output) ────────
ipcMain.handle('audio:setRendererBus', (_event, enabled: unknown, gain: unknown) => {
if (audio && typeof audio.setRendererBus === 'function') {
const en = enabled === true;
const g = typeof gain === 'number' && Number.isFinite(gain) ? gain : 1.0;
audio.setRendererBus(en, g);
}
});
// Push path uses `on` (fire-and-forget), not `handle`: chunks arrive ~50-100×
// per second and an invoke round-trip per chunk doubles the IPC cost for a
// reply nobody reads. Health is observed via getRendererBusMetrics instead.
ipcMain.on('audio:pushRendererAudio', (_event, chunk: unknown, sourceRate: unknown) => {
if (audio && typeof audio.pushRendererAudio === 'function'
&& chunk instanceof Float32Array && chunk.length >= 2) {
const rate = typeof sourceRate === 'number' && Number.isFinite(sourceRate) ? sourceRate : 0;
audio.pushRendererAudio(chunk, rate);
}
});
ipcMain.handle('audio:getRendererBusMetrics', () => {
if (!audio || typeof audio.getRendererBusMetrics !== 'function') return null;
return audio.getRendererBusMetrics();
});
ipcMain.handle('audio:getStreamSinkLevel', () => {
if (!audio || typeof audio.getStreamSinkLevel !== 'function') return 0;
return audio.getStreamSinkLevel();
+13
View File
@@ -365,6 +365,19 @@ const feedBackDesktopApi = {
ipcRenderer.invoke('audio:setStreamBus', includeBacking, includeGuitar, gain),
setStreamBusGain: (gain: number): Promise<void> =>
ipcRenderer.invoke('audio:setStreamBusGain', gain),
// Renderer-audio bus (Phase 2): feed the WebAudio master into the
// engine output so song audio survives exclusive-mode output.
setRendererBus: (enabled: boolean, gain: number): Promise<void> =>
ipcRenderer.invoke('audio:setRendererBus', enabled, gain),
// Fire-and-forget by design — called ~50-100×/s from the capture
// worklet's drain loop; check getRendererBusMetrics() for health.
pushRendererAudio: (interleavedLR: Float32Array, sourceRate: number): void =>
ipcRenderer.send('audio:pushRendererAudio', interleavedLR, sourceRate),
getRendererBusMetrics: (): Promise<{
enabled: boolean; fillFrames: number; capacityFrames: number;
pushedFrames: number; consumedFrames: number;
underflowCount: number; overflowCount: number;
} | null> => ipcRenderer.invoke('audio:getRendererBusMetrics'),
getStreamSinkLevel: (): Promise<number> => ipcRenderer.invoke('audio:getStreamSinkLevel'),
isStreamOutputActive: (): Promise<boolean> => ipcRenderer.invoke('audio:isStreamOutputActive'),
getStreamUnderflowCount: (): Promise<number> => ipcRenderer.invoke('audio:getStreamUnderflowCount'),