refactor(audio): extract PackedStereoRing — one SPSC ring template (phase 1)

Replaces the four hand-maintained copies of the packed-LR SPSC design
(split-mode output ring, per-InputDeviceSlot rings, stream-sink ring,
renderer-bus ring) with slopsmith::PackedStereoRing<NFrames>
(src/audio/engine/PackedStereoRing.h). The template owns the storage,
power-of-two/lock-free asserts, pack/unpack, producer publish, reset, the
w<r resync, and the lapped catch-up; per-site consumer policy (pull-vs-
consume skew, renderer prime/fill-clamp) stays verbatim at the call sites.

Pure code move per the TLC plan — no behavior change; the renderer bus's
control-thread readIndex write on disable (deep-read §4) is deliberately
preserved and gets its flush-flag fix in phase 2.

Unit-tested in tests/engine_units/packed_stereo_ring_test.cpp: threaded
tear-freedom under lapping (2M frames), drop-oldest catch-up, index-reset
resync, pull-vs-consume skew.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-13 23:38:56 +02:00
co-authored by Claude Fable 5
parent 3e449c0318
commit eeb83cbdbc
5 changed files with 383 additions and 164 deletions
+47 -111
View File
@@ -458,8 +458,8 @@ AudioEngine::DeviceMetrics AudioEngine::getDeviceMetrics() const
// (w - r) larger than capacity. Clamp uint64 → int via the
// capacity ceiling so the consumer-facing field never overflows
// or goes negative.
const uint64_t w = outputRingWriteIndex.load(std::memory_order_acquire);
const uint64_t r = outputRingReadIndex.load(std::memory_order_acquire);
const uint64_t w = outputRing.writeIndex.load(std::memory_order_acquire);
const uint64_t r = outputRing.readIndex.load(std::memory_order_acquire);
const uint64_t fill = (w >= r) ? (w - r) : 0;
m.outputRingFillFrames = (int) std::min(fill, (uint64_t) kOutputRingFrames);
}
@@ -1157,12 +1157,9 @@ AudioEngine::DeviceConfigResult AudioEngine::applySplitSetup(const DeviceConfig&
fprintf(stderr, "[AudioEngine] Split mode configured: inSr=%.0f inBs=%d outSr=%.0f outBs=%d\n",
inSr, inBs, outSr, outBs);
outputRingWriteIndex.store(0, std::memory_order_relaxed);
outputRingReadIndex.store(0, std::memory_order_relaxed);
outputRing.reset();
outputUnderflowCount.store(0, std::memory_order_relaxed);
inputOverflowCount.store(0, std::memory_order_relaxed);
for (auto& slot : outputPendingRing)
slot.store(0u, std::memory_order_relaxed);
source0().prepareMonitorChain(inSr, inBs);
@@ -1184,10 +1181,7 @@ void AudioEngine::teardownSplitMode()
try { outputDeviceManager.closeAudioDevice(); }
catch (...) { fprintf(stderr, "[AudioEngine] teardownSplitMode: output close threw\n"); }
outputRingWriteIndex.store(0, std::memory_order_relaxed);
outputRingReadIndex.store(0, std::memory_order_relaxed);
for (auto& slot : outputPendingRing)
slot.store(0u, std::memory_order_relaxed);
outputRing.reset();
}
// ── Audio Control ─────────────────────────────────────────────────────────────
@@ -1894,8 +1888,7 @@ void AudioEngine::audioDeviceStopped()
// (an extra-device callback could still be mid-block).
reclaimPendingReleases();
}
outputRingWriteIndex.store(0, std::memory_order_relaxed);
outputRingReadIndex.store(0, std::memory_order_relaxed);
outputRing.resetIndices();
currentBackingLevel.store(0.0f);
// Note on split-mode lifecycle: we deliberately do NOT detach the
@@ -1904,7 +1897,7 @@ void AudioEngine::audioDeviceStopped()
// doesn't re-add the output callback, so detaching would break
// automatic recovery (output stays silent until a manual reconfigure).
// While input is down, the guitar/DSP side of the output goes silent
// (no producer feeding outputPendingRing, so the consumer's underflow
// (no producer feeding outputRing, so the consumer's underflow
// branch zero-fills), but the backing track keeps playing — the output
// callback mixes backingTransport independently of ring state. That's
// intentional UX: a user unplugging their interface mid-song doesn't
@@ -2061,7 +2054,8 @@ void AudioEngine::composeAndPushStreamMix(const juce::AudioBuffer<float>& guitar
streamMixScratch.getMagnitude(1, 0, numSamples));
streamSinkLevel.store(peak, std::memory_order_relaxed);
packStereoIntoRing(streamMixScratch, numSamples, streamSink.ring, streamSink.writeIndex);
streamSink.ring.push(streamMixScratch.getReadPointer(0),
streamMixScratch.getReadPointer(1), numSamples);
}
void AudioEngine::streamSinkCallback(float* const* outputData, int numOutputChannels, int numSamples)
@@ -2071,35 +2065,29 @@ void AudioEngine::streamSinkCallback(float* const* outputData, int numOutputChan
juce::AudioBuffer<float> buffer(outputData, numOutputChannels, numSamples);
buffer.clear();
constexpr uint64_t kMask = (uint64_t) kOutputRingFrames - 1;
constexpr uint64_t kCap = (uint64_t) kOutputRingFrames;
const int scratchCap = (int) streamSink.pullScratchL.size();
const int outSamples = juce::jmin(numSamples, scratchCap);
uint64_t r = streamSink.readIndex.load(std::memory_order_relaxed);
const uint64_t w = streamSink.writeIndex.load(std::memory_order_acquire);
if (w < r) { r = w; streamSink.readIndex.store(r, std::memory_order_relaxed); }
if ((w - r) > kCap)
{
r = w - kCap;
streamSink.readIndex.store(r, std::memory_order_relaxed);
auto& ring = streamSink.ring;
uint64_t r = ring.readIndex.load(std::memory_order_relaxed);
const uint64_t w = ring.writeIndex.load(std::memory_order_acquire);
ring.resyncIfIndicesReset(r, w);
if (ring.catchUpIfLapped(r, w))
streamSink.overflowCount.fetch_add(1, std::memory_order_relaxed);
}
const uint64_t available = w - r;
const int pullCount = juce::jmin(outSamples, (int) available);
const int consumeCount = juce::jmin(numSamples, (int) available);
const int copyChannels = juce::jmin(numOutputChannels, 2);
for (int i = 0; i < pullCount; ++i)
{
const uint64_t slot = (r + (uint64_t) i) & kMask;
float l, rr;
unpackLR(streamSink.ring[(size_t) slot].load(std::memory_order_relaxed), l, rr);
ring.readFrame(r + (uint64_t) i, l, rr);
buffer.setSample(0, i, l);
if (copyChannels > 1) buffer.setSample(1, i, rr);
}
if (pullCount < outSamples)
streamSink.underflowCount.fetch_add(1, std::memory_order_relaxed);
streamSink.readIndex.store(r + (uint64_t) consumeCount, std::memory_order_release);
ring.commitRead(r + (uint64_t) consumeCount);
}
void AudioEngine::streamSinkAboutToStart(juce::AudioIODevice* device)
@@ -2113,11 +2101,9 @@ void AudioEngine::streamSinkAboutToStart(juce::AudioIODevice* device)
const int cap = juce::jmax(bs, 2048);
if ((int) streamSink.pullScratchL.size() < cap) streamSink.pullScratchL.assign((size_t) cap, 0.0f);
if ((int) streamSink.pullScratchR.size() < cap) streamSink.pullScratchR.assign((size_t) cap, 0.0f);
streamSink.writeIndex.store(0, std::memory_order_relaxed);
streamSink.readIndex.store(0, std::memory_order_relaxed);
streamSink.ring.reset();
streamSink.underflowCount.store(0, std::memory_order_relaxed);
streamSink.overflowCount.store(0, std::memory_order_relaxed);
for (auto& v : streamSink.ring) v.store(0, std::memory_order_relaxed);
}
void AudioEngine::streamSinkStopped()
@@ -2295,7 +2281,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext(
const bool duplex = duplexMode.load(std::memory_order_relaxed);
// Duplex writes outputData directly. Split runs DSP into a private 2-channel
// scratch and pushes the result to outputPendingRing for OutputCallback.
// scratch and pushes the result to outputRing for OutputCallback.
juce::AudioBuffer<float> buffer;
if (duplex)
{
@@ -2419,7 +2405,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext(
// Split: push processed stereo (pre-backing, pre-output-gain) into the
// primary ring. OutputCallback adds backing + output gain on its own clock
// and sums every extra-device ring alongside this one.
packStereoIntoRing(buffer, numSamples, outputPendingRing, outputRingWriteIndex);
outputRing.push(buffer.getReadPointer(0), buffer.getReadPointer(1), numSamples);
}
// Body done — this callback is no longer processing a source. Pairs with
@@ -2483,28 +2469,6 @@ int AudioEngine::mixSourcesForDevice(int deviceKey, const float* const* inputDat
return activeCount;
}
void AudioEngine::packStereoIntoRing(const juce::AudioBuffer<float>& buf, int numSamples,
std::array<std::atomic<uint64_t>, kOutputRingFrames>& ring,
std::atomic<uint64_t>& writeIndex)
{
// Strict SPSC: the producer (one device callback) only ever writes writeIndex;
// the consumer (audioOutputCallback) is the sole writer of the paired
// readIndex. Drop-oldest = letting writeIndex lap the buffer; the consumer
// advances readIndex when it observes (w - r) > cap. The single packed store
// prevents an L/R tear when the producer wraps mid-callback (relaxed because
// ordering is established by the release on writeIndex below).
constexpr uint64_t kMask = (uint64_t) kOutputRingFrames - 1;
const uint64_t w = writeIndex.load(std::memory_order_relaxed);
const float* L = buf.getReadPointer(0);
const float* R = buf.getReadPointer(1);
for (int i = 0; i < numSamples; ++i)
{
const uint64_t slot = (w + (uint64_t) i) & kMask;
ring[slot].store(packLR(L[i], R[i]), std::memory_order_relaxed);
}
writeIndex.store(w + (uint64_t) numSamples, std::memory_order_release);
}
void AudioEngine::extraInputCallback(int slot, const float* const* inputData, int numInputChannels, int numSamples)
{
if (slot < 0 || slot >= kMaxExtraInputDevices) return;
@@ -2521,7 +2485,7 @@ void AudioEngine::extraInputCallback(int slot, const float* const* inputData, in
juce::AudioBuffer<float> mix;
mix.setDataToReferTo(s.fanScratch.getArrayOfWritePointers(), 2, numSamples);
mixSourcesForDevice(s.deviceKey, inputData, numInputChannels, mix, s.monitorScratch, 2, numSamples);
packStereoIntoRing(mix, numSamples, s.ring, s.writeIndex);
s.ring.push(mix.getReadPointer(0), mix.getReadPointer(1), numSamples);
callbacksInFlight[(size_t) s.deviceKey].fetch_sub(1, std::memory_order_acq_rel);
}
@@ -2547,9 +2511,7 @@ void AudioEngine::extraInputAboutToStart(int slot, juce::AudioIODevice* device)
s.monitorScratch.setSize(2, cap, false, false, true);
s.fanScratch.clear();
s.monitorScratch.clear();
s.writeIndex.store(0, std::memory_order_relaxed);
s.readIndex.store(0, std::memory_order_relaxed);
for (auto& v : s.ring) v.store(0, std::memory_order_relaxed);
s.ring.reset();
// Capture-latency correction: the renderer's playhead is aligned to the PRIMARY
// device's input latency, but this extra device captures with a different
@@ -2617,8 +2579,7 @@ void AudioEngine::extraInputStopped(int slot)
// the next add/remove.
reclaimPendingReleases();
}
s.writeIndex.store(0, std::memory_order_relaxed);
s.readIndex.store(0, std::memory_order_relaxed);
s.ring.resetIndices();
}
int AudioEngine::activeExtraInputCount() const
@@ -2905,9 +2866,6 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/,
if (numOutputChannels <= 0)
return;
constexpr uint64_t kMask = (uint64_t) kOutputRingFrames - 1;
constexpr uint64_t kCap = (uint64_t) kOutputRingFrames;
if ((int) outputPullScratchL.size() < numSamples && audiodiag::firstN(audiodiag::outputOversized))
fprintf(stderr, "[diag] output callback OVERSIZED block: numSamples=%d > scratch=%d\n",
numSamples, (int) outputPullScratchL.size());
@@ -2919,27 +2877,15 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/,
const int scratchCap = (int) outputPullScratchL.size();
const int outSamples = juce::jmin(numSamples, scratchCap);
uint64_t r = outputRingReadIndex.load(std::memory_order_relaxed);
const uint64_t w = outputRingWriteIndex.load(std::memory_order_acquire);
uint64_t r = outputRing.readIndex.load(std::memory_order_relaxed);
const uint64_t w = outputRing.writeIndex.load(std::memory_order_acquire);
// If audioDeviceStopped() raced between our two loads and reset both
// indices to 0, we can observe w < r. Treat that as an empty ring
// and resync — without this, the unsigned (w - r) wraps into a huge
// positive value and falls into the catch-up branch reading stale slots.
if (w < r)
{
r = w;
outputRingReadIndex.store(r, std::memory_order_relaxed);
}
// Catch up if the producer has lapped (drop-oldest is achieved via this
// single-writer consumer-side advance, not a producer-side write to r).
if ((w - r) > kCap)
{
r = w - kCap;
outputRingReadIndex.store(r, std::memory_order_relaxed);
// Resync if audioDeviceStopped() raced between our two loads and reset
// the indices; catch up (drop-oldest) if the producer lapped — both moves
// live on PackedStereoRing now, same semantics as before.
outputRing.resyncIfIndicesReset(r, w);
if (outputRing.catchUpIfLapped(r, w))
inputOverflowCount.fetch_add(1, std::memory_order_relaxed);
}
const uint64_t available = w - r;
const int pullCount = juce::jmin(outSamples, (int) available);
@@ -2954,11 +2900,10 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/,
for (int i = 0; i < pullCount; ++i)
{
const uint64_t slot = (r + (uint64_t) i) & kMask;
// Single atomic load → atomic unpack of both channels (matches the
// producer's packed store) so L and R always belong to the same frame.
float l, rr;
unpackLR(outputPendingRing[slot].load(std::memory_order_relaxed), l, rr);
outputRing.readFrame(r + (uint64_t) i, l, rr);
outputPullScratchL[(size_t) i] = l;
outputPullScratchR[(size_t) i] = rr;
}
@@ -2971,7 +2916,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/,
}
outputUnderflowCount.fetch_add(1, std::memory_order_relaxed);
}
outputRingReadIndex.store(r + (uint64_t) consumeCount, std::memory_order_release);
outputRing.commitRead(r + (uint64_t) consumeCount);
buffer.clear();
const int copyChannels = juce::jmin(numOutputChannels, 2);
@@ -2989,27 +2934,22 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/,
for (auto& s : extraInputs)
{
if (! s.active.load(std::memory_order_acquire)) continue;
uint64_t er = s.readIndex.load(std::memory_order_relaxed);
const uint64_t ew = s.writeIndex.load(std::memory_order_acquire);
if (ew < er) { er = ew; s.readIndex.store(er, std::memory_order_relaxed); }
if ((ew - er) > kCap)
{
er = ew - kCap;
s.readIndex.store(er, std::memory_order_relaxed);
uint64_t er = s.ring.readIndex.load(std::memory_order_relaxed);
const uint64_t ew = s.ring.writeIndex.load(std::memory_order_acquire);
s.ring.resyncIfIndicesReset(er, ew);
if (s.ring.catchUpIfLapped(er, ew))
s.overflowCount.fetch_add(1, std::memory_order_relaxed);
}
const uint64_t eAvail = ew - er;
const int ePull = juce::jmin(outSamples, (int) eAvail);
const int eConsume = juce::jmin(numSamples, (int) eAvail);
for (int i = 0; i < ePull; ++i)
{
const uint64_t slot = (er + (uint64_t) i) & kMask;
float l, rr;
unpackLR(s.ring[(size_t) slot].load(std::memory_order_relaxed), l, rr);
s.ring.readFrame(er + (uint64_t) i, l, rr);
buffer.addSample(0, i, l);
if (copyChannels > 1) buffer.addSample(1, i, rr);
}
s.readIndex.store(er + (uint64_t) eConsume, std::memory_order_release);
s.ring.commitRead(er + (uint64_t) eConsume);
}
// Stream sink (producer, split clock): snapshot the full guitar mix (primary
@@ -3100,8 +3040,7 @@ bool AudioEngine::pushRendererAudio(const float* interleavedLR, int frames, doub
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);
uint64_t w = rendererBusRing.beginWrite();
// Linear resample source→device rate on this (IPC) thread. `pos` is the
// fractional read position into the incoming chunk; index -1 refers to the
@@ -3121,9 +3060,7 @@ bool AudioEngine::pushRendererAudio(const float* interleavedLR, int frames, doub
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);
rendererBusRing.stageFrame(w, l0 + (l1 - l0) * frac, r0 + (r1 - r0) * frac);
++w;
++written;
pos += step;
@@ -3135,7 +3072,7 @@ bool AudioEngine::pushRendererAudio(const float* interleavedLR, int frames, doub
// 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);
rendererBusRing.publish(w);
rendererBusPushedFrames.fetch_add(written, std::memory_order_relaxed);
return true;
}
@@ -3146,9 +3083,8 @@ int AudioEngine::pullRendererBus(juce::AudioBuffer<float>& dest, int numSamples)
// 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);
const uint64_t w = rendererBusRing.writeIndex.load(std::memory_order_acquire);
uint64_t r = rendererBusRing.readIndex.load(std::memory_order_relaxed);
if (w - r > (uint64_t) kRendererBusFrames)
{
// Producer lapped us — drop-oldest to the newest full ring.
@@ -3176,7 +3112,7 @@ int AudioEngine::pullRendererBus(juce::AudioBuffer<float>& dest, int numSamples)
{
if (avail < (uint64_t) kRendererBusPrimeFrames)
{
rendererBusReadIndex.store(r, std::memory_order_release);
rendererBusRing.commitRead(r);
return 0;
}
rendererBusPrimed = true;
@@ -3187,7 +3123,7 @@ int AudioEngine::pullRendererBus(juce::AudioBuffer<float>& dest, int numSamples)
// 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);
rendererBusRing.commitRead(w);
return 0;
}
@@ -3198,11 +3134,11 @@ int AudioEngine::pullRendererBus(juce::AudioBuffer<float>& dest, int numSamples)
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);
rendererBusRing.readFrame(r + (uint64_t) i, l, rr);
dl[i] = l * g;
dr[i] = rr * g;
}
rendererBusReadIndex.store(r + (uint64_t) pull, std::memory_order_release);
rendererBusRing.commitRead(r + (uint64_t) pull);
rendererBusConsumedFrames.fetch_add((uint64_t) pull, std::memory_order_relaxed);
return pull;
}
@@ -3214,8 +3150,8 @@ AudioEngine::RendererBusMetrics AudioEngine::getRendererBusMetrics() const
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);
const uint64_t w = rendererBusRing.writeIndex.load(std::memory_order_acquire);
const uint64_t r = rendererBusRing.readIndex.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);
+15 -53
View File
@@ -1,6 +1,7 @@
#pragma once
#include "SourceChain.h"
#include "GainSanitize.h"
#include "engine/PackedStereoRing.h"
#include "BackingLeveler.h"
#include "signalsmith-stretch.h"
#include <juce_audio_devices/juce_audio_devices.h>
@@ -276,8 +277,8 @@ public:
{
// 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),
rendererBusRing.readIndex.store(
rendererBusRing.writeIndex.load(std::memory_order_acquire),
std::memory_order_release);
rendererBusPrimed.store(false, std::memory_order_relaxed);
}
@@ -382,7 +383,7 @@ private:
SourceChain& source0() { return *sources[0]; }
const SourceChain& source0() const { return *sources[0]; }
// Input-device callback. In duplex it writes outputData directly; in split
// it pushes processed stereo into outputPendingRing for OutputCallback.
// it pushes processed stereo into outputRing for OutputCallback.
void audioDeviceIOCallbackWithContext(const float* const* inputData,
int numInputChannels,
float* const* outputData,
@@ -402,7 +403,7 @@ private:
// backingTransport && backingPlaying.
int renderBackingBlockLocked(int numSamples);
// Split-mode only: drains outputPendingRing, mixes backing, writes to device.
// Split-mode only: drains outputRing, mixes backing, writes to device.
void audioOutputCallback(const float* const* inputData,
int numInputChannels,
float* const* outputData,
@@ -560,43 +561,16 @@ private:
// scratch now live on SourceChain — one set per input source. See
// SourceChain.h for the full lock-free / power-of-two / cold-start rationale.
// Split-mode SPSC ring (unused in duplex). Each slot packs one stereo frame
// (L+R floats) into a single 64-bit atomic so the consumer reads both
// channels in one indivisible load — without packing, the producer's two
// separate atomic stores could interleave with the consumer's two loads
// during a drop-oldest wrap, surfacing as L_new+R_old (or vice versa)
// sample tears. ~85 ms @ 48 kHz — absorbs clock drift over typical sessions.
// Split-mode SPSC ring (unused in duplex). Packed-LR single-atomic frames
// — see engine/PackedStereoRing.h for the tear/lock-free rationale (moved
// there in TLC phase 1). ~85 ms @ 48 kHz — absorbs clock drift over
// typical sessions.
static constexpr int kOutputRingFrames = 4096;
std::array<std::atomic<uint64_t>, kOutputRingFrames> outputPendingRing{};
static_assert((kOutputRingFrames & (kOutputRingFrames - 1)) == 0,
"kOutputRingFrames must be a power of two for mask wraparound");
// RT-thread reads + writes touch these slots, so a lock-based fallback
// would risk priority inversion + audible dropouts. On the platforms we
// ship (x86_64 + arm64 across Linux/macOS/Windows) atomic<uint64_t> is
// always lock-free; this assert turns a regression into a build error
// instead of a silent latency degradation if a future platform port
// breaks the assumption.
static_assert(std::atomic<uint64_t>::is_always_lock_free,
"outputPendingRing requires lock-free atomic<uint64_t> for RT safety");
static_assert(sizeof(float) == 4,
"outputPendingRing pack/unpack assumes 32-bit float");
// Pack/unpack helpers — std::bit_cast (C++20) is constexpr + alias-safe.
static inline uint64_t packLR(float l, float r) noexcept
{
const uint32_t li = std::bit_cast<uint32_t>(l);
const uint32_t ri = std::bit_cast<uint32_t>(r);
return (static_cast<uint64_t>(ri) << 32) | static_cast<uint64_t>(li);
}
static inline void unpackLR(uint64_t v, float& l, float& r) noexcept
{
l = std::bit_cast<float>(static_cast<uint32_t>(v & 0xFFFFFFFFu));
r = std::bit_cast<float>(static_cast<uint32_t>(v >> 32));
}
slopsmith::PackedStereoRing<kOutputRingFrames> outputRing;
// ── 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
// Same packed-LR SPSC design as outputRing. Sized generously
// (~1.5 s @ 48 kHz — vs outputRing'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;
@@ -608,9 +582,7 @@ private:
// 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};
slopsmith::PackedStereoRing<kRendererBusFrames> rendererBusRing;
std::atomic<uint64_t> rendererBusPushedFrames{0};
std::atomic<uint64_t> rendererBusConsumedFrames{0};
std::atomic<uint64_t> rendererBusUnderflowCount{0};
@@ -637,8 +609,6 @@ private:
// 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};
std::atomic<uint64_t> outputUnderflowCount{0};
std::atomic<uint64_t> inputOverflowCount{0};
@@ -687,9 +657,7 @@ private:
{
juce::AudioDeviceManager manager;
InputSlotCallback callback;
std::array<std::atomic<uint64_t>, kOutputRingFrames> ring{};
std::atomic<uint64_t> writeIndex{0};
std::atomic<uint64_t> readIndex{0};
slopsmith::PackedStereoRing<kOutputRingFrames> ring;
std::atomic<uint64_t> overflowCount{0};
std::atomic<bool> active{false}; // a device is bound + running
std::atomic<double> sampleRate{48000.0};
@@ -733,10 +701,6 @@ private:
int mixSourcesForDevice(int deviceKey, const float* const* inputData, int numInputChannels,
juce::AudioBuffer<float>& mixBuf, juce::AudioBuffer<float>& monitorScratch,
int effectiveOutputChannels, int numSamples);
// Pack a stereo block into a packed-uint64 SPSC ring (producer side).
void packStereoIntoRing(const juce::AudioBuffer<float>& buf, int numSamples,
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
@@ -761,9 +725,7 @@ private:
struct StreamSink
{
StreamSinkCallback callback;
std::array<std::atomic<uint64_t>, kOutputRingFrames> ring{};
std::atomic<uint64_t> writeIndex{0};
std::atomic<uint64_t> readIndex{0};
slopsmith::PackedStereoRing<kOutputRingFrames> ring;
std::atomic<uint64_t> underflowCount{0};
std::atomic<uint64_t> overflowCount{0};
std::atomic<bool> active{false};
+143
View File
@@ -0,0 +1,143 @@
#pragma once
// PackedStereoRing — the ONE packed-LR SPSC ring (audio-engine TLC, plan
// phase 1). Replaces the three hand-maintained copies of the same design:
// the split-mode outputPendingRing, each InputDeviceSlot's ring, the stream
// sink's ring, and the renderer-audio bus ring.
//
// Design (moved verbatim from AudioEngine.h — see git history for the
// original per-site comments):
//
// Each slot packs one stereo frame (L+R floats) into a single 64-bit atomic
// so the consumer reads both channels in one indivisible load — without
// packing, the producer's two separate atomic stores could interleave with
// the consumer's two loads during a drop-oldest wrap, surfacing as
// L_new+R_old (or vice versa) sample tears.
//
// Strict SPSC: the producer (one device/IPC thread) only ever writes
// writeIndex; the consumer is the sole writer of readIndex. Drop-oldest =
// letting writeIndex lap the buffer; the consumer advances readIndex when it
// observes (w - r) > capacity. Ordering is established by the release store
// on writeIndex (producer) / readIndex (consumer); slot stores/loads are
// relaxed.
//
// The indices and slots are deliberately PUBLIC: the renderer bus keeps its
// bespoke prefill-gate / fill-clamp consumer policy, and phase-2 units bind
// the members directly. The helpers below own only the ritual moves every
// site repeats: producer publish, reset, the w<r resync after an index
// reset, and the lapped catch-up with its overflow counter.
#include <array>
#include <atomic>
#include <bit>
#include <cstdint>
namespace slopsmith {
// Pack/unpack helpers — std::bit_cast (C++20) is constexpr + alias-safe.
inline uint64_t packLR(float l, float r) noexcept
{
const uint32_t li = std::bit_cast<uint32_t>(l);
const uint32_t ri = std::bit_cast<uint32_t>(r);
return (static_cast<uint64_t>(ri) << 32) | static_cast<uint64_t>(li);
}
inline void unpackLR(uint64_t v, float& l, float& r) noexcept
{
l = std::bit_cast<float>(static_cast<uint32_t>(v & 0xFFFFFFFFu));
r = std::bit_cast<float>(static_cast<uint32_t>(v >> 32));
}
template <int NFrames>
struct PackedStereoRing
{
static_assert((NFrames & (NFrames - 1)) == 0,
"ring capacity must be a power of two for mask wraparound");
// RT-thread reads + writes touch these slots, so a lock-based fallback
// would risk priority inversion + audible dropouts. On the platforms we
// ship (x86_64 + arm64 across Linux/macOS/Windows) atomic<uint64_t> is
// always lock-free; this assert turns a regression into a build error
// instead of a silent latency degradation if a future platform port
// breaks the assumption.
static_assert(std::atomic<uint64_t>::is_always_lock_free,
"PackedStereoRing requires lock-free atomic<uint64_t> for RT safety");
static_assert(sizeof(float) == 4, "pack/unpack assumes 32-bit float");
static constexpr uint64_t kMask = (uint64_t) NFrames - 1;
static constexpr uint64_t kCap = (uint64_t) NFrames;
static constexpr int kFrames = NFrames;
std::array<std::atomic<uint64_t>, NFrames> slots{};
std::atomic<uint64_t> writeIndex{0};
std::atomic<uint64_t> readIndex{0};
// ── Producer side ─────────────────────────────────────────────────────
// Publish a stereo block (drop-oldest by lapping; consumer catches up).
void push(const float* L, const float* R, int numSamples) noexcept
{
const uint64_t w = writeIndex.load(std::memory_order_relaxed);
for (int i = 0; i < numSamples; ++i)
slots[(size_t) ((w + (uint64_t) i) & kMask)].store(packLR(L[i], R[i]),
std::memory_order_relaxed);
writeIndex.store(w + (uint64_t) numSamples, std::memory_order_release);
}
// Frame-at-a-time producer path (renderer-bus resampler): stage frames at
// monotonically increasing indices from beginWrite(), then publish once.
uint64_t beginWrite() const noexcept { return writeIndex.load(std::memory_order_relaxed); }
void stageFrame(uint64_t index, float l, float r) noexcept
{
slots[(size_t) (index & kMask)].store(packLR(l, r), std::memory_order_relaxed);
}
void publish(uint64_t newWriteIndex) noexcept
{
writeIndex.store(newWriteIndex, std::memory_order_release);
}
// ── Consumer side ─────────────────────────────────────────────────────
void readFrame(uint64_t index, float& l, float& r) const noexcept
{
unpackLR(slots[(size_t) (index & kMask)].load(std::memory_order_relaxed), l, r);
}
void commitRead(uint64_t newReadIndex) noexcept
{
readIndex.store(newReadIndex, std::memory_order_release);
}
// The two ritual guards at the top of every drain, exactly as each site
// wrote them by hand. `r` is the consumer's working copy of readIndex.
//
// If a stop/reset raced between the consumer's two index loads and reset
// both indices to 0, the consumer can observe w < r. Treat that as an
// empty ring and resync — without this, the unsigned (w - r) wraps into a
// huge positive value and falls into the catch-up branch reading stale
// slots.
void resyncIfIndicesReset(uint64_t& r, uint64_t w) noexcept
{
if (w < r) { r = w; readIndex.store(r, std::memory_order_relaxed); }
}
// Catch up if the producer has lapped (drop-oldest is achieved via this
// single-writer consumer-side advance, not a producer-side write to r).
// Returns true when a lap was consumed so the caller can bump its counter.
bool catchUpIfLapped(uint64_t& r, uint64_t w) noexcept
{
if ((w - r) > kCap)
{
r = w - kCap;
readIndex.store(r, std::memory_order_relaxed);
return true;
}
return false;
}
// ── Lifecycle (control/device-management threads only) ────────────────
void resetIndices() noexcept
{
writeIndex.store(0, std::memory_order_relaxed);
readIndex.store(0, std::memory_order_relaxed);
}
void reset() noexcept
{
resetIndices();
for (auto& v : slots) v.store(0, std::memory_order_relaxed);
}
};
} // namespace slopsmith