mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-11 04:44:11 +00:00
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:
co-authored by
Claude Opus 4.8
ChrisBeWithYou
byrongamatos
parent
0eabbceb73
commit
06c68262a9
@@ -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)
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user