Streamer mix outputs (PR1): one stream bus → a 2nd output device (#49)

* feat(audio): streamer mix outputs — one stream bus to a 2nd output device (PR1)

Built-in routing so a streamer can send a separate mix (game ± their guitar
tone) to a second output device for OBS/Discord capture, while still monitoring
locally — no VoiceMeeter/Reaper. PR1 of the design in docs/streamer-mix-outputs.md.

Architecture: this inverts the engine's proven Phase-2 multi-INPUT-device pattern
to the output side. A new StreamSink = its own AudioDeviceManager + drain callback
+ packed drop-oldest SPSC ring (a mirror of InputDeviceSlot). The PRODUCER is the
main output path (both the duplex callback and the split audioOutputCallback): it
snapshots the guitar monitor mix BEFORE backing is added, then composes the stream
submix (includeGuitar ? guitar : 0) + (includeBacking ? backing : 0) × gain and
packs it into the sink ring. The CONSUMER (streamSinkCallback) drains the ring to
the second device. Backing is rendered once on the master clock and fanned to the
stream ring (never re-advances the transport / touches backingLock). Default off →
zero behaviour change; the sink reopens across restarts (reopenDesiredStreamSink,
mirroring reopenDesiredExtraInputs).

Surface: NodeAddon setStreamOutputDevice/clearStreamOutput/setStreamBus/
setStreamBusGain/getStreamSinkLevel/isStreamOutputActive/getStreamUnderflowCount
→ audio:* IPC → preload → a new "Streaming & Extra Outputs" section on the Audio
page (device picker, game/guitar toggles, gain, a meter mirroring what OBS/Discord
receives; persisted to localStorage). v1 rejects a sample-rate-mismatched sink with
a clear error (async SRC is PR3).

Scope (PR1): ONE stream bus = game ± the guitar monitor mix. Per-source A/B mixes
(re-amped DI vs wet as separate OBS tracks) and per-bus mute that lets a local
monitor-kill (#47) NOT silence the stream are PR2 (see the doc). No virtual driver
shipped — route to a spare output / virtual cable / Go-Live capture.

NOT compiled or run on the author's box — this is native C++ (AudioEngine /
NodeAddon) that needs a desktop build. Renderer JS verified with node --check;
TS bridge/preload are additive (AudioModule is an index type so the calls
typecheck). Draft pending a build + a tester pass (see the PR checklist).

Refs got-feedback/feedBack-desktop#48 (tracking), #46/#47 (audio-engine family).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbexxfTt8q2tAn436MqGWF

* fix(audio): harden streamer-mix sink lifecycle/RT-safety (review on PR #49)

Addresses the P0/P1/P2/P3 findings from the Codex + manual review.

P0 — shutdown UAF: ~AudioEngine() never tore down the stream sink, and
StreamSink declared `manager` before `callback`/`ring`, so the manager
could be destroyed after the callback/ring it drives. stopAudio() now
closes the sink (and the dtor calls stopAudio()), and `manager` is
declared LAST so it destructs first even if a teardown path is missed.

P1 — stopAudio() ignored the sink: the 2nd output device kept running and
underflowing while "stopped". It now closes via closeStreamSinkDevice()
(intent preserved → startAudio() reopens, like extra inputs).

P1 — split-path producer buffers could realloc under a live callback:
streamGuitarScratch/streamMixScratch are now sized to a fixed capacity
(>= the ring) so a same/smaller-block device restart on either clock never
reallocates them mid-use.

P1 — split path read backingBuffer OUTSIDE backingLock (duplex held it):
composeAndPushStreamMix in audioOutputCallback now runs inside the lock
scope, so backingBuffer is read under the lock that guards its resize.

P1 — live setStreamOutputDevice() broke the SPSC single-writer invariant:
streamSinkAboutToStart() resets the ring while the producer might still be
writing. It now clears `active` before reconfiguring so the producer
stops, and only re-arms after a clean open.

P1 — failed open left stale state: a shared `fail()` path now closes the
device and drops the desired intent, so a deterministic failure (e.g. SR
mismatch) isn't retried every start and never reports active with no
device. The renderer keeps its own persisted choice.

P2 — streamSinkStopped() was empty: now marks the sink inactive (and
clears the meter) on an unplanned device loss, preserving intent.

P2 — no ring-capacity guard on the duplex path: composeAndPushStreamMix
skips (and counts) a block larger than the ring instead of wrapping.

P2 — gain NaN/Inf + bridge bool coercion: native sanitizeStreamGain()
(finite, clamped 0..8); the TS bridge requires real booleans (no
Boolean("false")===true) and a finite gain.

P3 — drop-oldest now counted via streamSink.overflowCount, exposed as
getStreamOverflowCount() through the addon/bridge/preload (mirrors
underflow) for drift diagnosis.

Still NOT compiled here (needs a desktop build). TS typechecks clean
(tsc --noEmit); renderer node --check clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(audio): make stream scratch fixed-capacity; document reconfig tail

Follow-up to the review-fix commit, closing the two residual edge cases
from the Codex re-review:

- Producer scratch (streamGuitarScratch/streamMixScratch) is now sized to a
  FIXED capacity == the ring and never grown with the block size. Oversized
  blocks are already skipped by the capacity guard, so a fixed cap is
  sufficient and means the buffers allocate exactly once — they can never
  realloc under a live split-mode producer for ANY later/hotplug block size
  (previously a larger restart block could still realloc).

- Reworded the setStreamOutputDevice() comment to stop overstating the
  active=false barrier: it prevents NEW producer pushes, but a block already
  in flight can finish one push before the (much slower) device reopen drives
  streamSinkAboutToStart's ring reset. Net worst case is one imperfect block
  on the stream bus (never the local monitor) during a manual device switch —
  atomic, no data race, no UAF. Documented as a known PR1 limitation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(audio): count oversized stream blocks (capacity guard before scratch guard)

Codex re-review nit: with the fixed-size scratch (== ring), an oversized
block tripped the undersized-scratch guard first and was dropped without
being counted. Check the ring-capacity guard FIRST so oversized duplex
blocks are always counted as stream overflows; keep the scratch guard
after it as cold-start/reconfig defense.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
ChrisBeWithYou
2026-06-29 00:49:25 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 ChrisBeWithYou byrongamatos
parent 0eabbceb73
commit 06c68262a9
8 changed files with 967 additions and 0 deletions
+100
View File
@@ -231,6 +231,31 @@ public:
float getBackingLevel() const { return currentBackingLevel.load(); }
void resetPeaks();
// ── Streamer mix output (PR1: one stream bus → one extra output device) ───
// An ADDITIONAL output device carrying an independent submix (game/backing +
// the guitar monitor mix) for OBS/Discord capture, separate from the local
// monitor output. Default off → zero behaviour change. Control-thread only.
// setStreamOutputDevice returns "" on success or an error string.
juce::String setStreamOutputDevice(const juce::String& typeName, const juce::String& deviceName);
void clearStreamOutput();
bool isStreamOutputActive() const { return streamSink.active.load(std::memory_order_acquire); }
juce::String getStreamOutputDeviceName() const { return streamSink.desiredDeviceName; }
// Bus content: include the backing/game, include the guitar monitor mix, and a
// linear output gain. All atomic — safe to set live. Gain is sanitised
// (finite, clamped 0..8) so a NaN/Inf from JS can never reach the stream ring.
void setStreamBus(bool includeBacking, bool includeGuitar, float gain)
{
streamBusIncludeBacking.store(includeBacking, std::memory_order_relaxed);
streamBusIncludeGuitar.store(includeGuitar, std::memory_order_relaxed);
streamBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed);
}
void setStreamBusGain(float gain) { streamBusGain.store(sanitizeStreamGain(gain), std::memory_order_relaxed); }
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
// frames were skipped. Exposed alongside underflow for stream drift diagnosis.
uint64_t getStreamOverflowCount() const { return streamSink.overflowCount.load(std::memory_order_relaxed); }
// Latency
double getLatencyMs() const;
@@ -614,5 +639,80 @@ private:
std::array<std::atomic<uint64_t>, kOutputRingFrames>& ring,
std::atomic<uint64_t>& writeIndex);
// ── Streamer mix output sink (PR1) ───────────────────────────────────────
// A second OUTPUT AudioDeviceManager on its OWN clock that drains a dedicated
// SPSC ring fed by the main output path's composed stream submix. This mirrors
// the InputDeviceSlot pattern INVERTED to the output side: the PRODUCER is the
// primary/output callback (composeAndPushStreamMix), the CONSUMER is this extra
// output device's callback (streamSinkCallback). Default off → no behaviour change.
struct StreamSinkCallback : juce::AudioIODeviceCallback
{
AudioEngine* engine = nullptr;
void audioDeviceIOCallbackWithContext(const float* const* inputData, int numInputChannels,
float* const* outputData, int numOutputChannels,
int numSamples,
const juce::AudioIODeviceCallbackContext&) override
{
juce::ignoreUnused(inputData, numInputChannels);
if (engine) engine->streamSinkCallback(outputData, numOutputChannels, numSamples);
}
void audioDeviceAboutToStart(juce::AudioIODevice* d) override { if (engine) engine->streamSinkAboutToStart(d); }
void audioDeviceStopped() override { if (engine) engine->streamSinkStopped(); }
};
struct StreamSink
{
StreamSinkCallback callback;
std::array<std::atomic<uint64_t>, kOutputRingFrames> ring{};
std::atomic<uint64_t> writeIndex{0};
std::atomic<uint64_t> readIndex{0};
std::atomic<uint64_t> underflowCount{0};
std::atomic<uint64_t> overflowCount{0};
std::atomic<bool> active{false};
std::atomic<double> sampleRate{48000.0};
std::atomic<int> blockSize{256};
std::vector<float> pullScratchL, pullScratchR; // sized in streamSinkAboutToStart
bool callbackRegistered = false;
bool initialised = false;
// Declared LAST so it DESTRUCTS FIRST (members tear down in reverse
// declaration order): the manager's dtor closes the device and detaches
// `callback` while `callback`/`ring` are still alive — no use-after-free
// even if an explicit teardown path is ever missed. stopAudio() /
// closeStreamSinkDevice() also tear it down explicitly before this.
juce::AudioDeviceManager manager;
// Persistent INTENT (control thread only): the device the user chose.
// Survives a stop/restart so reopenDesiredStreamSink() can re-open it.
juce::String desiredTypeName;
juce::String desiredDeviceName;
};
StreamSink streamSink;
std::atomic<bool> streamBusIncludeBacking{true};
std::atomic<bool> streamBusIncludeGuitar{true};
std::atomic<float> streamBusGain{1.0f};
std::atomic<float> streamSinkLevel{0.0f};
// Producer-side scratch (written by the primary/output callback): the guitar
// monitor-mix snapshot (pre-backing) and the composed stream submix. Sized in
// audioDeviceAboutToStart / audioOutputAboutToStart alongside the other scratch.
juce::AudioBuffer<float> streamGuitarScratch;
juce::AudioBuffer<float> streamMixScratch;
// Clamp a requested stream gain to a finite, sane range so a NaN/Inf (or a
// wild value) from the JS bridge can never be packed into the stream ring.
static float sanitizeStreamGain(float g) { return std::isfinite(g) ? juce::jlimit(0.0f, 8.0f, g) : 0.0f; }
void streamSinkCallback(float* const* outputData, int numOutputChannels, int numSamples);
void streamSinkAboutToStart(juce::AudioIODevice* device);
void streamSinkStopped();
void reopenDesiredStreamSink();
// Detach + close the stream-sink device but KEEP desiredTypeName/Name, so a
// stopAudio()/startAudio() cycle re-opens it (intent survives, like extra
// 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).
void composeAndPushStreamMix(const juce::AudioBuffer<float>& guitarMix,
const juce::AudioBuffer<float>* backingBuf,
int backingFrames, float backingVol, int numSamples);
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(AudioEngine)
};