From eeb83cbdbc356847b7bc746da41b56fb238b2b42 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Mon, 13 Jul 2026 23:38:56 +0200 Subject: [PATCH] =?UTF-8?q?refactor(audio):=20extract=20PackedStereoRing?= =?UTF-8?q?=20=E2=80=94=20one=20SPSC=20ring=20template=20(phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (src/audio/engine/PackedStereoRing.h). The template owns the storage, power-of-two/lock-free asserts, pack/unpack, producer publish, reset, the w --- src/audio/AudioEngine.cpp | 158 +++++----------- src/audio/AudioEngine.h | 68 ++----- src/audio/engine/PackedStereoRing.h | 143 +++++++++++++++ tests/engine_units/CMakeLists.txt | 7 + .../engine_units/packed_stereo_ring_test.cpp | 171 ++++++++++++++++++ 5 files changed, 383 insertions(+), 164 deletions(-) create mode 100644 src/audio/engine/PackedStereoRing.h create mode 100644 tests/engine_units/packed_stereo_ring_test.cpp diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index 8f4d881..8100f15 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -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& 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 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 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& buf, int numSamples, - std::array, kOutputRingFrames>& ring, - std::atomic& 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 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& 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& 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& 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& 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); diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index 7aa975a..3382ade 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -1,6 +1,7 @@ #pragma once #include "SourceChain.h" #include "GainSanitize.h" +#include "engine/PackedStereoRing.h" #include "BackingLeveler.h" #include "signalsmith-stretch.h" #include @@ -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, 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 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::is_always_lock_free, - "outputPendingRing requires lock-free atomic 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(l); - const uint32_t ri = std::bit_cast(r); - return (static_cast(ri) << 32) | static_cast(li); - } - static inline void unpackLR(uint64_t v, float& l, float& r) noexcept - { - l = std::bit_cast(static_cast(v & 0xFFFFFFFFu)); - r = std::bit_cast(static_cast(v >> 32)); - } + slopsmith::PackedStereoRing 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, kRendererBusFrames> rendererBusRing{}; - std::atomic rendererBusWriteIndex{0}; - std::atomic rendererBusReadIndex{0}; + slopsmith::PackedStereoRing rendererBusRing; std::atomic rendererBusPushedFrames{0}; std::atomic rendererBusConsumedFrames{0}; std::atomic rendererBusUnderflowCount{0}; @@ -637,8 +609,6 @@ private: // in about-to-start next to the stream scratches (same no-realloc rule). juce::AudioBuffer rendererBusPullScratch; - std::atomic outputRingWriteIndex{0}; - std::atomic outputRingReadIndex{0}; std::atomic outputUnderflowCount{0}; std::atomic inputOverflowCount{0}; @@ -687,9 +657,7 @@ private: { juce::AudioDeviceManager manager; InputSlotCallback callback; - std::array, kOutputRingFrames> ring{}; - std::atomic writeIndex{0}; - std::atomic readIndex{0}; + slopsmith::PackedStereoRing ring; std::atomic overflowCount{0}; std::atomic active{false}; // a device is bound + running std::atomic sampleRate{48000.0}; @@ -733,10 +701,6 @@ private: int mixSourcesForDevice(int deviceKey, const float* const* inputData, int numInputChannels, juce::AudioBuffer& mixBuf, juce::AudioBuffer& monitorScratch, int effectiveOutputChannels, int numSamples); - // Pack a stereo block into a packed-uint64 SPSC ring (producer side). - void packStereoIntoRing(const juce::AudioBuffer& buf, int numSamples, - std::array, kOutputRingFrames>& ring, - std::atomic& 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, kOutputRingFrames> ring{}; - std::atomic writeIndex{0}; - std::atomic readIndex{0}; + slopsmith::PackedStereoRing ring; std::atomic underflowCount{0}; std::atomic overflowCount{0}; std::atomic active{false}; diff --git a/src/audio/engine/PackedStereoRing.h b/src/audio/engine/PackedStereoRing.h new file mode 100644 index 0000000..fe560b9 --- /dev/null +++ b/src/audio/engine/PackedStereoRing.h @@ -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 +#include +#include +#include + +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(l); + const uint32_t ri = std::bit_cast(r); + return (static_cast(ri) << 32) | static_cast(li); +} +inline void unpackLR(uint64_t v, float& l, float& r) noexcept +{ + l = std::bit_cast(static_cast(v & 0xFFFFFFFFu)); + r = std::bit_cast(static_cast(v >> 32)); +} + +template +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 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::is_always_lock_free, + "PackedStereoRing requires lock-free atomic 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, NFrames> slots{}; + std::atomic writeIndex{0}; + std::atomic 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 diff --git a/tests/engine_units/CMakeLists.txt b/tests/engine_units/CMakeLists.txt index 71e8632..88447f1 100644 --- a/tests/engine_units/CMakeLists.txt +++ b/tests/engine_units/CMakeLists.txt @@ -5,3 +5,10 @@ add_executable(gain_sanitize_test gain_sanitize_test.cpp) target_compile_features(gain_sanitize_test PRIVATE cxx_std_17) add_test(NAME gain_sanitize COMMAND gain_sanitize_test) + +# PackedStereoRing uses std::bit_cast (C++20) and std::thread. +add_executable(packed_stereo_ring_test packed_stereo_ring_test.cpp) +target_compile_features(packed_stereo_ring_test PRIVATE cxx_std_20) +find_package(Threads REQUIRED) +target_link_libraries(packed_stereo_ring_test PRIVATE Threads::Threads) +add_test(NAME packed_stereo_ring COMMAND packed_stereo_ring_test) diff --git a/tests/engine_units/packed_stereo_ring_test.cpp b/tests/engine_units/packed_stereo_ring_test.cpp new file mode 100644 index 0000000..a83a651 --- /dev/null +++ b/tests/engine_units/packed_stereo_ring_test.cpp @@ -0,0 +1,171 @@ +// Phase 1 unit tests for PackedStereoRing (docs/audio-engine-tlc.md §5): +// pack/unpack round-trip, wrap + drop-oldest lap, w +#include +#include +#include +#include +#include + +using slopsmith::PackedStereoRing; +using slopsmith::packLR; +using slopsmith::unpackLR; + +static void testPackRoundTrip() +{ + const float values[] = { 0.0f, -0.0f, 1.0f, -1.0f, 3.14159f, 1e-30f, -1e30f }; + for (float l : values) + for (float r : values) + { + float ol, orr; + unpackLR(packLR(l, r), ol, orr); + // Bit-exact round trip (including -0.0f). + assert(std::memcmp(&ol, &l, 4) == 0 && std::memcmp(&orr, &r, 4) == 0); + } +} + +static void testPushPullBasic() +{ + PackedStereoRing<64> ring; + float L[16], R[16]; + for (int i = 0; i < 16; ++i) { L[i] = (float) i; R[i] = (float) -i; } + ring.push(L, R, 16); + + uint64_t r = ring.readIndex.load(); + const uint64_t w = ring.writeIndex.load(); + assert(w - r == 16); + for (int i = 0; i < 16; ++i) + { + float l, rr; + ring.readFrame(r + (uint64_t) i, l, rr); + assert(l == (float) i && rr == (float) -i); + } + ring.commitRead(r + 16); + assert(ring.writeIndex.load() - ring.readIndex.load() == 0); +} + +static void testLapCatchUp() +{ + PackedStereoRing<64> ring; + float L[64], R[64]; + // Push 3 laps' worth without consuming: consumer must catch up to newest + // full ring, exactly once per drain regardless of how far it was lapped. + for (int block = 0; block < 3; ++block) + { + for (int i = 0; i < 64; ++i) { L[i] = (float) (block * 64 + i); R[i] = 0.0f; } + ring.push(L, R, 64); + } + uint64_t r = ring.readIndex.load(); + const uint64_t w = ring.writeIndex.load(); + assert(w - r == 192); + const bool lapped = ring.catchUpIfLapped(r, w); + assert(lapped); + assert(w - r == 64); // newest full ring only + float l, rr; + ring.readFrame(r, l, rr); + assert(l == 128.0f); // oldest surviving frame = start of last lap + // Not lapped anymore: second call is a no-op. + assert(!ring.catchUpIfLapped(r, w)); +} + +static void testResyncAfterReset() +{ + PackedStereoRing<64> ring; + float L[32] = {}, R[32] = {}; + ring.push(L, R, 32); + ring.commitRead(20); + uint64_t r = ring.readIndex.load(); + // A stop raced in and reset the indices; consumer still holds r == 20. + ring.resetIndices(); + const uint64_t w = ring.writeIndex.load(); + ring.resyncIfIndicesReset(r, w); + assert(r == 0 && w == 0); // treated as empty, no wrapped (w - r) monster +} + +// Producer at one block size laps a slower consumer at another; every frame +// the consumer reads must have L == -R (the producer invariant), proving the +// packed single-atomic store never tears a frame. +static void testConcurrentTearFreedom() +{ + PackedStereoRing<256> ring; + std::atomic stop{false}; + std::atomic laps{0}; + + std::thread producer([&] { + float L[48], R[48]; + uint64_t n = 0; + while (!stop.load(std::memory_order_relaxed)) + { + for (int i = 0; i < 48; ++i) + { + const float v = (float) ((n + (uint64_t) i) & 0xFFFFF); + L[i] = v; R[i] = -v; + } + ring.push(L, R, 48); + n += 48; + } + }); + + uint64_t checked = 0; + while (checked < 2'000'000) + { + 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)) laps.fetch_add(1); + const uint64_t avail = w - r; + const int pull = (int) (avail < 32 ? avail : 32); + for (int i = 0; i < pull; ++i) + { + float l, rr; + ring.readFrame(r + (uint64_t) i, l, rr); + assert(l == -rr && "L/R tear: channels from different frames"); + } + ring.commitRead(r + (uint64_t) pull); + checked += (uint64_t) pull; + } + stop.store(true); + producer.join(); + std::printf("packed_stereo_ring: tear-check ok (%llu frames, %llu laps)\n", + (unsigned long long) checked, (unsigned long long) laps.load()); + assert(laps.load() > 0 && "stress never lapped — laps path untested, tune sizes"); +} + +// The split output path pulls min(outSamples, avail) into scratch but +// consumes min(numSamples, avail) so a scratch-clamped block doesn't +// accumulate ring/output-clock skew. Pin that index arithmetic. +static void testPullVsConsumeSkew() +{ + PackedStereoRing<64> ring; + float L[40], R[40]; + for (int i = 0; i < 40; ++i) { L[i] = (float) i; R[i] = 0.0f; } + ring.push(L, R, 40); + + const int numSamples = 40; // device block + const int outSamples = 32; // scratch-clamped + uint64_t r = ring.readIndex.load(); + const uint64_t w = ring.writeIndex.load(); + const uint64_t avail = w - r; + const int pull = (int) (avail < (uint64_t) outSamples ? avail : (uint64_t) outSamples); + const int consume = (int) (avail < (uint64_t) numSamples ? avail : (uint64_t) numSamples); + assert(pull == 32 && consume == 40); + ring.commitRead(r + (uint64_t) consume); + assert(ring.writeIndex.load() - ring.readIndex.load() == 0); // no skew left queued +} + +int main() +{ + testPackRoundTrip(); + testPushPullBasic(); + testLapCatchUp(); + testResyncAfterReset(); + testPullVsConsumeSkew(); + testConcurrentTearFreedom(); + std::puts("packed_stereo_ring: all cases passed"); + return 0; +}