From d8f5784c635fc9a4e7fb1e3ebbd38fb20ccb07c6 Mon Sep 17 00:00:00 2001 From: OmikronApex Date: Tue, 14 Jul 2026 00:19:53 +0200 Subject: [PATCH] refactor(audio): extract BackingPlayer (phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the backing-track cluster — AudioFormatManager/reader/transport, TimeSliceThread read-ahead, signalsmith-stretch state, lock-free speed hand-off, BackingLeveler, playhead caches, and renderBackingBlockLocked — verbatim into src/audio/engine/BackingPlayer.{h,cpp}. Boundary per the plan (§2.4): control-thread lifecycle + non-blocking getters live on the class; the RT mix POLICY (try-lock pattern, RMS metering, volume fader, stream-submix capture) stays in the engine's output callbacks via getLock()/readyLocked()/renderBlockLocked()/renderBuffer() — both callbacks keep holding the try-lock through their stream publish, so the render buffer is never read while prepare() can resize it. The volume fader atomic and level meter stay engine-side. Synthetic-reader unit tests deferred (JUCE-linked, same constraint as StreamSink); covered by the backing play/seek/speed integration surface. Co-Authored-By: Claude Fable 5 --- src/audio/AudioEngine.cpp | 364 ++--------------------------- src/audio/AudioEngine.h | 81 ++----- src/audio/CMakeLists.txt | 1 + src/audio/engine/BackingPlayer.cpp | 325 ++++++++++++++++++++++++++ src/audio/engine/BackingPlayer.h | 121 ++++++++++ 5 files changed, 486 insertions(+), 406 deletions(-) create mode 100644 src/audio/engine/BackingPlayer.cpp create mode 100644 src/audio/engine/BackingPlayer.h diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index e79c83c..2069e79 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -20,9 +20,7 @@ inline bool firstN(std::atomic& c) { return c.fetch_add(1, std::memory } // Hard ceiling on backing playback speed. This drives input buffer sizing and runtime clamp. -static constexpr double kMaxBackingSpeed = 4.0; // Transparent full-speed path — skip the stretcher when rate is effectively 1×. -static constexpr double kBackingSpeedBypassEpsilon = 1.0e-4; // On Windows, ASIO drivers can crash with access violations. // We catch C++ exceptions but can't easily catch SEH in functions with dtors. @@ -31,12 +29,10 @@ static constexpr double kBackingSpeedBypassEpsilon = 1.0e-4; AudioEngine::AudioEngine() { - formatManager.registerBasicFormats(); // Start the backing read-ahead worker so the transport's BufferingAudioSource // always has a live thread to pull decoded audio on. It sleeps while idle and // costs nothing until a track is loaded. - backingReadThread.startThread(); // Construct the full source pool up front so addSource/removeSource never // reassign a pointer the audio thread reads — they only flip `active`. Each @@ -1284,187 +1280,6 @@ void AudioEngine::stopAudio() // ── Backing Track ───────────────────────────────────────────────────────────── -bool AudioEngine::loadBackingTrack(const juce::File& file) -{ - const juce::ScopedLock sl(backingLock); - stopBackingNoLock(); - backingTransport.reset(); - backingSource.reset(); - - const bool exists = file.existsAsFile(); - std::cerr << "[AudioEngine] loadBackingTrack path=" - << file.getFullPathName().toStdString() - << " exists=" << exists - << " size=" << (exists ? (long long)file.getSize() : -1) - << std::endl; - - auto* reader = formatManager.createReaderFor(file); - if (!reader) - { - std::cerr << "[AudioEngine] loadBackingTrack: no reader for ext='" - << file.getFileExtension().toStdString() - << "' (registered formats=" << formatManager.getNumKnownFormats() - << ")" << std::endl; - // Transport/source already reset above; clear cached state so the renderer - // doesn't keep displaying the previous track's position/duration. - cachedBackingPosition.store(0.0); - cachedBackingDuration.store(0.0); - return false; - } - - const double readerSampleRate = reader->sampleRate; - const juce::int64 readerLengthInSamples = reader->lengthInSamples; - const double sr = currentSampleRate.load(std::memory_order_relaxed); - // Backing audio plays through the output device in both modes, so size - // against outputBlockSize. In duplex mode outputBlockSize == inputBlockSize; - // in split mode the output device's clock drives the backing pull. - const int bs = outputBlockSize.load(std::memory_order_relaxed); - - backingSource = std::make_unique(reader, true); - backingTransport = std::make_unique(); - // Read-ahead on backingReadThread so the RT audio thread normally never - // touches the disk or the format codec. Previously this passed - // (…, 0, nullptr, …): with no read-ahead buffer the transport decoded the - // file synchronously inside getNextAudioBlock ON the audio callback, so any - // disk seek / decode spike (worst for compressed formats) blew the block - // budget → underruns heard as glitches or brief mutes while a song plays. - // 32768 source frames ≈ 0.68 s @ 48k of look-ahead absorbs those spikes. - // - // Known residual (accepted): juce::BufferingAudioSource is not fully - // RT-safe — readBufferSection() holds callbackLock across the decode of one - // refill chunk, and the callback's getNextAudioBlock() takes the same lock, - // so the RT thread can still block behind an in-flight chunk decode. The - // window is bounded (JUCE caps chunks at 2048 source frames) and only hit - // when a refill is mid-decode, vs. the old guaranteed full decode on every - // block; a truly lock-free ring would mean replacing the JUCE transport - // stack and isn't worth it here. - // The 4th arg makes AudioTransportSource SRC the file to device rate. - // Stretch always sees device-rate audio so that its presetDefault parameters match. - constexpr int kBackingReadAheadSamples = 32768; - backingTransport->setSource(backingSource.get(), kBackingReadAheadSamples, - &backingReadThread, readerSampleRate); - - // Loading a backing track before the audio device has started leaves - // sr/bs at zero. presetDefault(2, 0.0f) would seed the stretcher with - // undefined internal timing, and prepareToPlay(0, 0) is similarly - // ill-defined. Defer the stretcher + buffer setup; the relevant - // audio*AboutToStart() re-runs the same block once a real sample - // rate / block size are known (audioDeviceAboutToStart for duplex, - // audioOutputAboutToStart for split). - if (sr > 0.0 && bs > 0) - { - // prepareToPlay's first arg is an upper bound on subsequent - // getNextAudioBlock requests, per the juce::AudioSource contract. - // The RT callback can pull ceil(bs * kMaxBackingSpeed) frames in a - // single block when the speed is above 1×, so prepare for that - // worst case — preparing with just `bs` would risk JUCE internal - // buffer overruns/asserts on the first faster-than-1× block. - const int maxInputFrames = (int) std::ceil(bs * kMaxBackingSpeed) + 64; - backingTransport->prepareToPlay(maxInputFrames, sr); - - backingStretch.presetDefault(2, (float) sr); - backingStretch.reset(); - backingStretchLatencySamples.store(backingStretch.outputLatency(), std::memory_order_relaxed); - - backingInputBuffer.setSize(2, maxInputFrames, false, false, true); - backingBuffer.setSize(2, bs, false, false, true); - } - - cachedBackingDuration.store(backingTransport->getLengthInSeconds()); - cachedBackingPosition.store(0.0); - backingHeardPositionSec.store(0.0, std::memory_order_relaxed); - - // Reset the loudness leveler for the new song: clearing the cached sample - // rate forces renderBackingBlockLocked() to re-prepare() it on the next - // block, dropping the previous track's AGC gain + limiter state. Otherwise - // the ~300 ms gain follower would carry over and briefly mis-level the start - // of a much louder/quieter next song. Safe here — loadBackingTrack holds - // backingLock, the same lock the render path runs under. - backingLevelerSr = 0.0; - std::cerr << "[AudioEngine] loadBackingTrack OK sr=" << readerSampleRate - << " len=" << readerLengthInSamples - << std::endl; - return true; -} - -void AudioEngine::setBackingPosition(double seconds) -{ - const juce::ScopedLock sl(backingLock); - if (backingTransport) - { - backingTransport->setPosition(seconds); - backingStretch.reset(); - // Read back the actual position; the transport may clamp (e.g. negative or past EOF). - const double pos = backingTransport->getCurrentPosition(); - cachedBackingPosition.store(pos); - backingHeardPositionSec.store(pos, std::memory_order_relaxed); - } -} - -void AudioEngine::startBacking() -{ - const juce::ScopedLock sl(backingLock); - if (backingTransport) - { - backingTransport->start(); - backingPlaying.store(true); - backingHeardPositionSec.store(backingTransport->getCurrentPosition(), - std::memory_order_relaxed); - } -} - -void AudioEngine::stopBackingNoLock() -{ - if (backingTransport) - { - backingTransport->stop(); - backingStretch.reset(); - backingPlaying.store(false); - } - currentBackingLevel.store(0.0f); -} - -void AudioEngine::stopBacking() -{ - const juce::ScopedLock sl(backingLock); - stopBackingNoLock(); -} - -void AudioEngine::setBackingSpeed(double speed) -{ - if (!std::isfinite(speed) || speed <= 0.0) - { - return; - } - - const double clamped = juce::jlimit(0.01, kMaxBackingSpeed, speed); - // Dead-zone against the last *requested* rate to coalesce rapid slider - // ticks — but never skip a change that crosses the 1× bypass boundary, or a - // request just shy of 1× (e.g. 0.9995 -> 1.0, diff < 0.001) would leave the - // stretcher path engaged when the caller actually asked for transparent - // full speed. - const double prev = backingPendingSpeed.load(std::memory_order_relaxed); - const bool prevBypass = std::abs(prev - 1.0) < kBackingSpeedBypassEpsilon; - const bool newBypass = std::abs(clamped - 1.0) < kBackingSpeedBypassEpsilon; - if (std::abs(clamped - prev) < 0.001 && prevBypass == newBypass) - { - return; - } - - // Lock-free hand-off to the audio thread. Publish the requested rate, then - // raise the pending flag with release so the RT thread is guaranteed to see - // the new rate once it observes the flag. renderBackingBlockLocked() adopts - // the rate and resets the stretcher together, on the audio thread, so: - // * a control-thread caller (e.g. a speed slider at 30-60 Hz) never takes - // backingLock and so never starves the RT tryLock into dropping a block; - // * the new rate is never processed with stale stretch state — the reset - // and the rate adoption happen in the same RT block (see PR #237). - // Multiple updates before the RT consumes them coalesce (latest wins), which - // naturally throttles stretcher resets during a drag. - backingPendingSpeed.store(clamped, std::memory_order_relaxed); - backingSpeedChangePending.store(true, std::memory_order_release); -} - void AudioEngine::resetPeaks() { // Input peak is per-source — clear EVERY active source (getSourceLevels() exposes @@ -1666,122 +1481,6 @@ std::vector AudioEngine::listSources() const return out; } -int AudioEngine::renderBackingBlockLocked(int numSamples) -{ - // Adopt any speed change requested since the last block (set lock-free by - // setBackingSpeed). Common (no-change) path is a plain acquire load — no - // locked RMW, so the flag's cache line stays shared and isn't bounced to - // this core every callback. Only the rare block that actually consumes a - // change does the exchange (clearing the flag atomically so a concurrent - // setBackingSpeed can't lose an update). The acquire pairs with the - // release-store in setBackingSpeed so the new rate is visible here. Reset - // the stretcher and re-anchor the heard position in the SAME block we adopt - // the rate, so a block is never processed at the new rate with stale stretch - // state. reset() only clears state (no allocation), so it's audio-thread safe. - if (backingSpeedChangePending.load(std::memory_order_acquire)) - { - backingSpeedChangePending.exchange(false, std::memory_order_acquire); - backingSpeed.store(juce::jlimit(0.01, kMaxBackingSpeed, - backingPendingSpeed.load(std::memory_order_relaxed)), - std::memory_order_relaxed); - backingStretch.reset(); - backingHeardPositionSec.store(backingTransport->getCurrentPosition(), - std::memory_order_relaxed); - } - - const double rate = juce::jlimit(0.01, kMaxBackingSpeed, backingSpeed.load(std::memory_order_relaxed)); - - // Defensive clamp: the buffers are sized in audioDeviceAboutToStart() / - // audioOutputAboutToStart() from the device's nominal block size, but a - // callback can deliver a larger numSamples on a device-reconfig race. Drop - // the excess frames silently rather than reading/writing past the allocated - // span; the next callback after reconfig arrives at the new nominal size. - const int outCap = backingBuffer.getNumSamples(); - const int inCap = backingInputBuffer.getNumSamples(); - const int outSamples = juce::jmin(numSamples, outCap); - const double sr = currentSampleRate.load(std::memory_order_relaxed); - const bool bypassStretch = std::abs(rate - 1.0) < kBackingSpeedBypassEpsilon; - - int sourceFramesPulled = 0; - - if (bypassStretch) - { - // 1× — direct transport read, no phase-vocoder path. (The transport - // still sample-rate-converts the file to the device rate, so this is - // "no time-stretch", not necessarily bit-perfect.) - backingBuffer.clear(0, outSamples); - juce::AudioSourceChannelInfo info(&backingBuffer, 0, outSamples); - backingTransport->getNextAudioBlock(info); - sourceFramesPulled = outSamples; - } - else - { - // Slow/fast path — pull only the source frames needed for this output - // block (output * rate), then stretch in-process to fill outSamples. - const int inputFrames = juce::jmin((int) std::ceil(outSamples * rate), inCap); - - backingInputBuffer.clear(0, inputFrames); - juce::AudioSourceChannelInfo info(&backingInputBuffer, 0, inputFrames); - backingTransport->getNextAudioBlock(info); - sourceFramesPulled = inputFrames; - - backingBuffer.clear(0, outSamples); - - const float* const* inPtrs = backingInputBuffer.getArrayOfReadPointers(); - float* const* outPtrs = backingBuffer.getArrayOfWritePointers(); - backingStretch.process(inPtrs, inputFrames, outPtrs, outSamples); - } - - const double transportPos = backingTransport->getCurrentPosition(); - if (sr > 0.0 && sourceFramesPulled > 0) - { - // Accumulate the heard (source) position, but clamp to the transport's - // actual position. sourceFramesPulled is the requested block size; a - // short read (e.g. at EOF, where the transport returns fewer real frames - // and zero-pads) would otherwise advance the playhead past the true - // source point and report progress beyond the track duration before - // backingPlaying flips false. getCurrentPosition() stays clamped to the - // real source position. - double heard = backingHeardPositionSec.load(std::memory_order_relaxed) - + static_cast(sourceFramesPulled) / sr; - heard = juce::jmin(heard, transportPos); - backingHeardPositionSec.store(heard, std::memory_order_relaxed); - - // Bypass reads straight from the transport — no phase-vocoder output - // latency to compensate for. Only the stretch path adds latency. - const double latencyInputSec = bypassStretch - ? 0.0 - : (backingStretchLatencySamples.load(std::memory_order_relaxed) * rate) / sr; - cachedBackingPosition.store(juce::jmax(0.0, heard - latencyInputSec)); - } - else - { - // currentSampleRate is transiently 0 during device teardown/reconfig. - // We can't accumulate (no Hz to divide by), so anchor both the heard - // accumulator and the published playhead to the real transport position - // rather than leaving a stale value visible to the UI. - backingHeardPositionSec.store(transportPos, std::memory_order_relaxed); - cachedBackingPosition.store(juce::jmax(0.0, transportPos)); - } - - // Sync the flag if transport stopped at EOF. - if (!backingTransport->isPlaying()) - backingPlaying.store(false); - - // Normalize the backing track to a consistent target loudness (-12 LUFS) - // BEFORE the mixer's backing-volume fader is applied (later in the RT - // callback), so every song sits at the same level while the fader still - // attenuates it. Standard BS.1770 K-weighting (full-mix music) + a brickwall - // limiter to keep boosted peaks safe. RT-safe (no allocation). - if (outSamples > 0 && sr > 0.0) - { - if (sr != backingLevelerSr) { backingLeveler.prepare(sr); backingLevelerSr = sr; } - backingLeveler.process(backingBuffer, outSamples, -12.0f); - } - - return outSamples; -} - // setNoiseGate / setTonePolishEnabled are now inline forwarders to sources[0] // (see AudioEngine.h). @@ -1853,22 +1552,7 @@ void AudioEngine::audioDeviceAboutToStart(juce::AudioIODevice* device) // plays on, and pulls from backingTransport at the output device's // block size. if (duplexMode.load(std::memory_order_relaxed)) - { - const juce::ScopedLock sl(backingLock); - if (backingTransport) - { - // See loadBackingTrack() for why prepareToPlay uses maxInputFrames - // rather than bs: the RT callback can pull ceil(bs * kMaxBackingSpeed) - // frames in a single block at faster-than-1× speeds. - const int maxInputFrames = (int) std::ceil(bs * kMaxBackingSpeed) + 64; - backingTransport->prepareToPlay(maxInputFrames, sr); - backingStretch.presetDefault(2, (float) sr); - backingStretch.reset(); - backingStretchLatencySamples.store(backingStretch.outputLatency(), std::memory_order_relaxed); - backingInputBuffer.setSize(2, maxInputFrames, false, false, true); - backingBuffer.setSize(2, bs, false, false, true); - } - } + backing.prepare(sr, bs); } void AudioEngine::audioDeviceStopped() @@ -1957,25 +1641,9 @@ void AudioEngine::audioOutputAboutToStart(juce::AudioIODevice* device) // device-side rate change (sleep/resume, format change) would leave // currentSampleRate stuck at the input-side seed value. if (sr > 0.0) currentSampleRate.store(sr, std::memory_order_relaxed); - { - const juce::ScopedLock sl(backingLock); - if (backingTransport && sr > 0.0 && bs > 0) - { - // Mirror loadBackingTrack() / audioDeviceAboutToStart() — the - // output device drives backing playback in split mode, so this - // is where the stretcher gets sized for that side. prepareToPlay - // upper-bounds future getNextAudioBlock requests, and the - // RT callback can pull ceil(bs * kMaxBackingSpeed) at faster - // speeds. - const int maxInputFrames = (int) std::ceil(bs * kMaxBackingSpeed) + 64; - backingTransport->prepareToPlay(maxInputFrames, sr); - backingStretch.presetDefault(2, (float) sr); - backingStretch.reset(); - backingStretchLatencySamples.store(backingStretch.outputLatency(), std::memory_order_relaxed); - backingInputBuffer.setSize(2, maxInputFrames, false, false, true); - backingBuffer.setSize(2, bs, false, false, true); - } - } + // The output device drives backing playback in split mode, so this is + // where the stretcher gets sized for that side. + backing.prepare(sr, bs); } void AudioEngine::audioOutputStopped() @@ -2097,17 +1765,17 @@ void AudioEngine::audioDeviceIOCallbackWithContext( streamGuitarScratch.copyFrom(ch, 0, buffer, juce::jmin(ch, buffer.getNumChannels() - 1), 0, numSamples); - const juce::ScopedTryLock sl(backingLock); - if (sl.isLocked() && backingTransport && backingPlaying.load()) + const juce::ScopedTryLock sl(backing.getLock()); + if (sl.isLocked() && backing.readyLocked()) { - const int outSamples = renderBackingBlockLocked(numSamples); + const int outSamples = backing.renderBlockLocked(numSamples); const float bVol = backingVolume.load(); streamBackingFrames = outSamples; streamBackingVol = bVol; streamBackingOn = true; const int mixChannels = juce::jmin(numOutputChannels, 2); float backingLevelSq = 0.0f; for (int ch = 0; ch < mixChannels; ++ch) { - const float* const src = backingBuffer.getReadPointer(ch); + const float* const src = backing.renderBuffer().getReadPointer(ch); float sumSquares = 0.0f; for (int i = 0; i < outSamples; ++i) sumSquares += src[i] * src[i]; @@ -2116,7 +1784,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext( ? std::sqrt(sumSquares / outSamples) * bVol : 0.0f; backingLevelSq += channelRms * channelRms; - buffer.addFrom(ch, 0, backingBuffer, ch, 0, outSamples, bVol); + buffer.addFrom(ch, 0, backing.renderBuffer(), ch, 0, outSamples, bVol); } currentBackingLevel.store((mixChannels > 0) ? std::sqrt(backingLevelSq / mixChannels) @@ -2137,7 +1805,7 @@ void AudioEngine::audioDeviceIOCallbackWithContext( // gain, so the stream level is independent. if (streamActive) streamSink.publish(streamGuitarScratch, - streamBackingOn ? &backingBuffer : nullptr, + streamBackingOn ? &backing.renderBuffer() : nullptr, streamBackingFrames, streamBackingVol, rendererFrames > 0 ? &rendererBusPullScratch : nullptr, rendererFrames, numSamples); @@ -2722,11 +2390,11 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, juce::jmin(ch, buffer.getNumChannels() - 1), 0, numSamples); { - const juce::ScopedTryLock sl(backingLock); - if (sl.isLocked() && backingTransport && backingPlaying.load()) + const juce::ScopedTryLock sl(backing.getLock()); + if (sl.isLocked() && backing.readyLocked()) { // Shared with the duplex path so the two callbacks can't drift. - const int backingOut = renderBackingBlockLocked(numSamples); + const int backingOut = backing.renderBlockLocked(numSamples); const float bVol = backingVolume.load(); streamBackingFrames = backingOut; streamBackingVol = bVol; streamBackingOn = true; // RMS, computed identically to the duplex path so getBackingLevel() @@ -2735,7 +2403,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, float backingLevelSq = 0.0f; for (int ch = 0; ch < copyChannels; ++ch) { - const float* const src = backingBuffer.getReadPointer(ch); + const float* const src = backing.renderBuffer().getReadPointer(ch); float sumSquares = 0.0f; for (int i = 0; i < backingOut; ++i) sumSquares += src[i] * src[i]; @@ -2744,7 +2412,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, ? std::sqrt(sumSquares / backingOut) * bVol : 0.0f; backingLevelSq += channelRms * channelRms; - buffer.addFrom(ch, 0, backingBuffer, ch, 0, backingOut, bVol); + buffer.addFrom(ch, 0, backing.renderBuffer(), ch, 0, backingOut, bVol); } currentBackingLevel.store((copyChannels > 0) ? std::sqrt(backingLevelSq / copyChannels) @@ -2767,7 +2435,7 @@ void AudioEngine::audioOutputCallback(const float* const* /*inputData*/, // pointer and never touch backingBuffer, so there is nothing to protect. if (streamActive) streamSink.publish(streamGuitarScratch, - streamBackingOn ? &backingBuffer : nullptr, + streamBackingOn ? &backing.renderBuffer() : nullptr, streamBackingFrames, streamBackingVol, rendererFrames > 0 ? &rendererBusPullScratch : nullptr, rendererFrames, numSamples); diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index aab50a0..f74d05b 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -5,6 +5,7 @@ #include "engine/EngineState.h" #include "engine/RendererBus.h" #include "engine/StreamSink.h" +#include "engine/BackingPlayer.h" #include "BackingLeveler.h" #include "signalsmith-stretch.h" #include @@ -223,17 +224,26 @@ public: // renderer exposes a per-preset toggle. void setTonePolishEnabled(bool enabled) { source0().setTonePolishEnabled(enabled); } - // Backing track + // Backing track — transport moved to engine/BackingPlayer (TLC phase 3); + // the volume fader + level meter stay engine-side (mix policy). void setBackingVolume(float vol) { backingVolume.store(slopsmith::sanitizeMasterGain(vol)); } - bool loadBackingTrack(const juce::File& file); - void setBackingPosition(double seconds); - void startBacking(); - void stopBacking(); - void setBackingSpeed(double speed); - // Non-blocking reads — do not acquire backingLock and never block the audio callback - bool isBackingPlaying() const { return backingPlaying.load(); } - double getBackingPosition() const { return cachedBackingPosition.load(); } - double getBackingDuration() const { return cachedBackingDuration.load(); } + bool loadBackingTrack(const juce::File& file) + { + currentBackingLevel.store(0.0f); + return backing.load(file); + } + void setBackingPosition(double seconds) { backing.setPosition(seconds); } + void startBacking() { backing.start(); } + void stopBacking() + { + backing.stop(); + currentBackingLevel.store(0.0f); + } + void setBackingSpeed(double speed) { backing.setSpeed(speed); } + // Non-blocking reads — never acquire the backing lock / block the audio callback + bool isBackingPlaying() const { return backing.isPlaying(); } + double getBackingPosition() const { return backing.getPosition(); } + double getBackingDuration() const { return backing.getDuration(); } // Metering (read from any thread — atomic). Input level/peak are per-source // (sources[0]); output level/peak are the post-mix master, engine-global. @@ -377,16 +387,6 @@ private: const juce::AudioIODeviceCallbackContext& context) override; void audioDeviceAboutToStart(juce::AudioIODevice* device) override; void audioDeviceStopped() override; - void stopBackingNoLock(); // caller holds backingLock - - // Renders one block of the backing track into backingBuffer (1x bypass or - // phase-vocoder stretch), advances backingHeardPositionSec / - // cachedBackingPosition, and clears backingPlaying at EOF. Returns the - // number of output frames written (== jmin(numSamples, backingBuffer cap)). - // Shared by the duplex and split output callbacks so the two paths can't - // drift. Precondition: caller holds backingLock and has verified - // backingTransport && backingPlaying. - int renderBackingBlockLocked(int numSamples); // Split-mode only: drains outputRing, mixes backing, writes to device. void audioOutputCallback(const float* const* inputData, @@ -478,15 +478,9 @@ private: // sourcesMutex (or is the device-stop path, where the callback is gone). void reclaimPendingReleases(); - juce::AudioFormatManager formatManager; - // Master output (post-mix) — engine-global, not per-source. std::atomic outputGain{1.0f}; std::atomic backingVolume{0.8f}; - // Per-song loudness normalizer for the backing track (applied in - // renderBackingBlockLocked, pre-fader). Owned + driven by the audio thread. - BackingLeveler backingLeveler; - double backingLevelerSr = 0.0; std::atomic currentOutputLevel{0.0f}; // Per-block RMS of the backing-track mix bus, written by the audio thread // and read on the main/JS thread via getBackingLevel(). Computed after the @@ -495,38 +489,9 @@ private: std::atomic currentBackingLevel{0.0f}; std::atomic outputPeak{0.0f}; - // Backing track - // Read-ahead worker that fills the transport's buffer off the audio thread - // (see loadBackingTrack). Declared BEFORE backingTransport so it is destroyed - // AFTER it — the transport's BufferingAudioSource holds a pointer to this - // thread and must be torn down before the thread goes away. - juce::TimeSliceThread backingReadThread { "BackingReadAhead" }; - std::unique_ptr backingSource; - std::unique_ptr backingTransport; - signalsmith::stretch::SignalsmithStretch backingStretch; - juce::AudioBuffer backingInputBuffer; // pulled from transport at device rate - juce::AudioBuffer backingBuffer; // stretch output, mixed into device buffer - std::atomic backingStretchLatencySamples{0}; - std::atomic backingPlaying{false}; - std::atomic cachedBackingPosition{0.0}; - std::atomic cachedBackingDuration{0.0}; - // Heard playhead: accumulates the source frames consumed each block, then - // clamped to backingTransport->getCurrentPosition() so a short read at EOF - // can't push it past the real source point. cachedBackingPosition is this - // value minus the stretcher output latency (zero on the 1x bypass path). - std::atomic backingHeardPositionSec{0.0}; - // Active playback rate. Mutated ONLY by the audio thread (in - // renderBackingBlockLocked), coupled with the stretcher reset, so a block - // is never processed at a new rate with stale stretch state. - std::atomic backingSpeed{1.0}; - // Lock-free speed hand-off: setBackingSpeed (control thread) publishes the - // requested rate here and raises backingSpeedChangePending; the audio - // thread adopts it on the next block. Avoids the control thread blocking on - // backingLock and starving the RT tryLock (which would drop a backing block - // mid-slider-drag). - std::atomic backingPendingSpeed{1.0}; - std::atomic backingSpeedChangePending{false}; - juce::CriticalSection backingLock; + // Backing track — transport/stretch/leveler moved to engine/BackingPlayer + // (TLC phase 3). Declared after `state` (bound by reference). + slopsmith::BackingPlayer backing{state}; // audioRunning keeps its historical DEVICE-STATE semantics (isAudioRunning // compat pin); the intent half is state.userWantsAudio — see EngineState.h. diff --git a/src/audio/CMakeLists.txt b/src/audio/CMakeLists.txt index 7e6175e..9da8379 100644 --- a/src/audio/CMakeLists.txt +++ b/src/audio/CMakeLists.txt @@ -7,6 +7,7 @@ set(AUDIO_SOURCES TonePolish.cpp AudioEngine.cpp engine/StreamSink.cpp + engine/BackingPlayer.cpp SourceChain.cpp SignalChain.cpp VSTHost.cpp diff --git a/src/audio/engine/BackingPlayer.cpp b/src/audio/engine/BackingPlayer.cpp new file mode 100644 index 0000000..c6a0161 --- /dev/null +++ b/src/audio/engine/BackingPlayer.cpp @@ -0,0 +1,325 @@ +// BackingPlayer implementation — moved verbatim from AudioEngine.cpp (TLC +// plan phase 3 / §2.4); member names lose their backing prefixes, logic is +// unchanged. See BackingPlayer.h for the boundary rationale. + +#include "BackingPlayer.h" + +#include +#include + +namespace slopsmith { + +bool BackingPlayer::load(const juce::File& file) +{ + const juce::ScopedLock sl(lock); + stopNoLock(); + transport.reset(); + readerSource.reset(); + + const bool exists = file.existsAsFile(); + std::cerr << "[AudioEngine] loadBackingTrack path=" + << file.getFullPathName().toStdString() + << " exists=" << exists + << " size=" << (exists ? (long long) file.getSize() : -1) + << std::endl; + + auto* reader = formatManager.createReaderFor(file); + if (!reader) + { + std::cerr << "[AudioEngine] loadBackingTrack: no reader for ext='" + << file.getFileExtension().toStdString() + << "' (registered formats=" << formatManager.getNumKnownFormats() + << ")" << std::endl; + // Transport/source already reset above; clear cached state so the renderer + // doesn't keep displaying the previous track's position/duration. + cachedPosition.store(0.0); + cachedDuration.store(0.0); + return false; + } + + const double readerSampleRate = reader->sampleRate; + const juce::int64 readerLengthInSamples = reader->lengthInSamples; + const double sr = state.currentSampleRate.load(std::memory_order_relaxed); + // Backing audio plays through the output device in both modes, so size + // against outputBlockSize. In duplex mode outputBlockSize == inputBlockSize; + // in split mode the output device's clock drives the backing pull. + const int bs = state.outputBlockSize.load(std::memory_order_relaxed); + + readerSource = std::make_unique(reader, true); + transport = std::make_unique(); + // Read-ahead on readThread so the RT audio thread normally never touches + // the disk or the format codec. Previously this passed (…, 0, nullptr, …): + // with no read-ahead buffer the transport decoded the file synchronously + // inside getNextAudioBlock ON the audio callback, so any disk seek / + // decode spike (worst for compressed formats) blew the block budget → + // underruns heard as glitches or brief mutes while a song plays. + // 32768 source frames ≈ 0.68 s @ 48k of look-ahead absorbs those spikes. + // + // Known residual (accepted): juce::BufferingAudioSource is not fully + // RT-safe — readBufferSection() holds callbackLock across the decode of one + // refill chunk, and the callback's getNextAudioBlock() takes the same lock, + // so the RT thread can still block behind an in-flight chunk decode. The + // window is bounded (JUCE caps chunks at 2048 source frames) and only hit + // when a refill is mid-decode, vs. the old guaranteed full decode on every + // block; a truly lock-free ring would mean replacing the JUCE transport + // stack and isn't worth it here. + // The 4th arg makes AudioTransportSource SRC the file to device rate. + // Stretch always sees device-rate audio so that its presetDefault parameters match. + constexpr int kReadAheadSamples = 32768; + transport->setSource(readerSource.get(), kReadAheadSamples, + &readThread, readerSampleRate); + + // Loading a backing track before the audio device has started leaves + // sr/bs at zero. presetDefault(2, 0.0f) would seed the stretcher with + // undefined internal timing, and prepareToPlay(0, 0) is similarly + // ill-defined. Defer the stretcher + buffer setup; the relevant + // audio*AboutToStart() re-runs the same block (via prepare()) once a real + // sample rate / block size are known. + if (sr > 0.0 && bs > 0) + { + // prepareToPlay's first arg is an upper bound on subsequent + // getNextAudioBlock requests, per the juce::AudioSource contract. + // The RT callback can pull ceil(bs * kMaxSpeed) frames in a single + // block when the speed is above 1×, so prepare for that worst case — + // preparing with just `bs` would risk JUCE internal buffer + // overruns/asserts on the first faster-than-1× block. + const int maxInputFrames = (int) std::ceil(bs * kMaxSpeed) + 64; + transport->prepareToPlay(maxInputFrames, sr); + + stretch.presetDefault(2, (float) sr); + stretch.reset(); + stretchLatencySamples.store(stretch.outputLatency(), std::memory_order_relaxed); + + inputBuffer.setSize(2, maxInputFrames, false, false, true); + outputBuffer.setSize(2, bs, false, false, true); + } + + cachedDuration.store(transport->getLengthInSeconds()); + cachedPosition.store(0.0); + heardPositionSec.store(0.0, std::memory_order_relaxed); + + // Reset the loudness leveler for the new song: clearing the cached sample + // rate forces renderBlockLocked() to re-prepare() it on the next block, + // dropping the previous track's AGC gain + limiter state. Otherwise the + // ~300 ms gain follower would carry over and briefly mis-level the start + // of a much louder/quieter next song. Safe here — load holds the lock, + // the same lock the render path runs under. + levelerSr = 0.0; + std::cerr << "[AudioEngine] loadBackingTrack OK sr=" << readerSampleRate + << " len=" << readerLengthInSamples + << std::endl; + return true; +} + +void BackingPlayer::setPosition(double seconds) +{ + const juce::ScopedLock sl(lock); + if (transport) + { + transport->setPosition(seconds); + stretch.reset(); + // Read back the actual position; the transport may clamp (e.g. negative or past EOF). + const double pos = transport->getCurrentPosition(); + cachedPosition.store(pos); + heardPositionSec.store(pos, std::memory_order_relaxed); + } +} + +void BackingPlayer::start() +{ + const juce::ScopedLock sl(lock); + if (transport) + { + transport->start(); + playing.store(true); + heardPositionSec.store(transport->getCurrentPosition(), + std::memory_order_relaxed); + } +} + +void BackingPlayer::stopNoLock() +{ + if (transport) + { + transport->stop(); + stretch.reset(); + playing.store(false); + } +} + +void BackingPlayer::stop() +{ + const juce::ScopedLock sl(lock); + stopNoLock(); +} + +void BackingPlayer::setSpeed(double newSpeed) +{ + if (!std::isfinite(newSpeed) || newSpeed <= 0.0) + { + return; + } + + const double clamped = juce::jlimit(0.01, kMaxSpeed, newSpeed); + // Dead-zone against the last *requested* rate to coalesce rapid slider + // ticks — but never skip a change that crosses the 1× bypass boundary, or a + // request just shy of 1× (e.g. 0.9995 -> 1.0, diff < 0.001) would leave the + // stretcher path engaged when the caller actually asked for transparent + // full speed. + const double prev = pendingSpeed.load(std::memory_order_relaxed); + const bool prevBypass = std::abs(prev - 1.0) < kSpeedBypassEpsilon; + const bool newBypass = std::abs(clamped - 1.0) < kSpeedBypassEpsilon; + if (std::abs(clamped - prev) < 0.001 && prevBypass == newBypass) + { + return; + } + + // Lock-free hand-off to the audio thread. Publish the requested rate, then + // raise the pending flag with release so the RT thread is guaranteed to see + // the new rate once it observes the flag. renderBlockLocked() adopts the + // rate and resets the stretcher together, on the audio thread, so: + // * a control-thread caller (e.g. a speed slider at 30-60 Hz) never takes + // the lock and so never starves the RT tryLock into dropping a block; + // * the new rate is never processed with stale stretch state — the reset + // and the rate adoption happen in the same RT block (see PR #237). + // Multiple updates before the RT consumes them coalesce (latest wins), which + // naturally throttles stretcher resets during a drag. + pendingSpeed.store(clamped, std::memory_order_relaxed); + speedChangePending.store(true, std::memory_order_release); +} + +void BackingPlayer::prepare(double sr, int bs) +{ + const juce::ScopedLock sl(lock); + if (transport && sr > 0.0 && bs > 0) + { + // See load() for why prepareToPlay uses maxInputFrames rather than bs: + // the RT callback can pull ceil(bs * kMaxSpeed) frames in a single + // block at faster-than-1× speeds. + const int maxInputFrames = (int) std::ceil(bs * kMaxSpeed) + 64; + transport->prepareToPlay(maxInputFrames, sr); + stretch.presetDefault(2, (float) sr); + stretch.reset(); + stretchLatencySamples.store(stretch.outputLatency(), std::memory_order_relaxed); + inputBuffer.setSize(2, maxInputFrames, false, false, true); + outputBuffer.setSize(2, bs, false, false, true); + } +} + +int BackingPlayer::renderBlockLocked(int numSamples) +{ + // Adopt any speed change requested since the last block (set lock-free by + // setSpeed). Common (no-change) path is a plain acquire load — no locked + // RMW, so the flag's cache line stays shared and isn't bounced to this + // core every callback. Only the rare block that actually consumes a change + // does the exchange (clearing the flag atomically so a concurrent setSpeed + // can't lose an update). The acquire pairs with the release-store in + // setSpeed so the new rate is visible here. Reset the stretcher and + // re-anchor the heard position in the SAME block we adopt the rate, so a + // block is never processed at the new rate with stale stretch state. + // reset() only clears state (no allocation), so it's audio-thread safe. + if (speedChangePending.load(std::memory_order_acquire)) + { + speedChangePending.exchange(false, std::memory_order_acquire); + speed.store(juce::jlimit(0.01, kMaxSpeed, + pendingSpeed.load(std::memory_order_relaxed)), + std::memory_order_relaxed); + stretch.reset(); + heardPositionSec.store(transport->getCurrentPosition(), + std::memory_order_relaxed); + } + + const double rate = juce::jlimit(0.01, kMaxSpeed, speed.load(std::memory_order_relaxed)); + + // Defensive clamp: the buffers are sized by prepare() from the device's + // nominal block size, but a callback can deliver a larger numSamples on a + // device-reconfig race. Drop the excess frames silently rather than + // reading/writing past the allocated span; the next callback after + // reconfig arrives at the new nominal size. + const int outCap = outputBuffer.getNumSamples(); + const int inCap = inputBuffer.getNumSamples(); + const int outSamples = juce::jmin(numSamples, outCap); + const double sr = state.currentSampleRate.load(std::memory_order_relaxed); + const bool bypassStretch = std::abs(rate - 1.0) < kSpeedBypassEpsilon; + + int sourceFramesPulled = 0; + + if (bypassStretch) + { + // 1× — direct transport read, no phase-vocoder path. (The transport + // still sample-rate-converts the file to the device rate, so this is + // "no time-stretch", not necessarily bit-perfect.) + outputBuffer.clear(0, outSamples); + juce::AudioSourceChannelInfo info(&outputBuffer, 0, outSamples); + transport->getNextAudioBlock(info); + sourceFramesPulled = outSamples; + } + else + { + // Slow/fast path — pull only the source frames needed for this output + // block (output * rate), then stretch in-process to fill outSamples. + const int inputFrames = juce::jmin((int) std::ceil(outSamples * rate), inCap); + + inputBuffer.clear(0, inputFrames); + juce::AudioSourceChannelInfo info(&inputBuffer, 0, inputFrames); + transport->getNextAudioBlock(info); + sourceFramesPulled = inputFrames; + + outputBuffer.clear(0, outSamples); + + const float* const* inPtrs = inputBuffer.getArrayOfReadPointers(); + float* const* outPtrs = outputBuffer.getArrayOfWritePointers(); + stretch.process(inPtrs, inputFrames, outPtrs, outSamples); + } + + const double transportPos = transport->getCurrentPosition(); + if (sr > 0.0 && sourceFramesPulled > 0) + { + // Accumulate the heard (source) position, but clamp to the transport's + // actual position. sourceFramesPulled is the requested block size; a + // short read (e.g. at EOF, where the transport returns fewer real frames + // and zero-pads) would otherwise advance the playhead past the true + // source point and report progress beyond the track duration before + // `playing` flips false. getCurrentPosition() stays clamped to the + // real source position. + double heard = heardPositionSec.load(std::memory_order_relaxed) + + static_cast(sourceFramesPulled) / sr; + heard = juce::jmin(heard, transportPos); + heardPositionSec.store(heard, std::memory_order_relaxed); + + // Bypass reads straight from the transport — no phase-vocoder output + // latency to compensate for. Only the stretch path adds latency. + const double latencyInputSec = bypassStretch + ? 0.0 + : (stretchLatencySamples.load(std::memory_order_relaxed) * rate) / sr; + cachedPosition.store(juce::jmax(0.0, heard - latencyInputSec)); + } + else + { + // currentSampleRate is transiently 0 during device teardown/reconfig. + // We can't accumulate (no Hz to divide by), so anchor both the heard + // accumulator and the published playhead to the real transport position + // rather than leaving a stale value visible to the UI. + heardPositionSec.store(transportPos, std::memory_order_relaxed); + cachedPosition.store(juce::jmax(0.0, transportPos)); + } + + // Sync the flag if transport stopped at EOF. + if (!transport->isPlaying()) + playing.store(false); + + // Normalize the backing track to a consistent target loudness (-12 LUFS) + // BEFORE the mixer's backing-volume fader is applied (later in the RT + // callback), so every song sits at the same level while the fader still + // attenuates it. Standard BS.1770 K-weighting (full-mix music) + a brickwall + // limiter to keep boosted peaks safe. RT-safe (no allocation). + if (outSamples > 0 && sr > 0.0) + { + if (sr != levelerSr) { leveler.prepare(sr); levelerSr = sr; } + leveler.process(outputBuffer, outSamples, -12.0f); + } + + return outSamples; +} + +} // namespace slopsmith diff --git a/src/audio/engine/BackingPlayer.h b/src/audio/engine/BackingPlayer.h new file mode 100644 index 0000000..289cae1 --- /dev/null +++ b/src/audio/engine/BackingPlayer.h @@ -0,0 +1,121 @@ +#pragma once + +// BackingPlayer — the backing-track transport (TLC plan phase 3 / §2.4). +// Moved verbatim from AudioEngine: JUCE AudioFormatReaderSource → +// AudioTransportSource buffered by a TimeSliceThread read-ahead → optional +// signalsmith-stretch phase vocoder for speed change (1× bypass path), the +// per-song BackingLeveler loudness normalizer, and the playhead caches. +// +// Boundary: control-thread lifecycle (load/start/stop/seek/setSpeed) and +// non-blocking cached getters live here; the RT mix POLICY (try-lock, RMS +// metering, volume fader, stream-submix capture) stays in the engine's +// output callbacks, which use the primitives getLock() / readyLocked() / +// renderBlockLocked() / renderBuffer() exactly as they open-coded them +// before. Both callbacks hold the try-lock through their stream publish so +// renderBuffer() is never read while prepare() can resize it. + +#include "EngineState.h" +#include "../BackingLeveler.h" +#include "signalsmith-stretch.h" // resolved via SS_STRETCH_DIR include path + +#include +#include + +#include +#include + +namespace slopsmith { + +class BackingPlayer +{ +public: + static constexpr double kMaxSpeed = 4.0; + // |rate - 1| below this uses the direct transport path (no phase vocoder). + static constexpr double kSpeedBypassEpsilon = 1.0e-4; + + explicit BackingPlayer(EngineState& engineState) : state(engineState) + { + formatManager.registerBasicFormats(); + readThread.startThread(); + } + + // ── Control thread ──────────────────────────────────────────────────── + bool load(const juce::File& file); + void setPosition(double seconds); + void start(); + void stop(); + void setSpeed(double speed); + + // Non-blocking reads — do not acquire the lock, never block the audio + // callback. + bool isPlaying() const { return playing.load(); } + double getPosition() const { return cachedPosition.load(); } + double getDuration() const { return cachedDuration.load(); } + + // Re-prepare the transport + stretcher + buffers at a (new) device format. + // Call from the about-to-start hook that owns backing playback (duplex: + // input manager; split: output manager). No-op when nothing is loaded. + void prepare(double sr, int bs); + + // ── RT primitives (output callbacks) ────────────────────────────────── + // Usage pattern (unchanged from the open-coded version): + // const juce::ScopedTryLock sl(backing.getLock()); + // if (sl.isLocked() && backing.readyLocked()) { + // const int n = backing.renderBlockLocked(numSamples); + // ... mix backing.renderBuffer() with the fader, meter RMS ... + // } + juce::CriticalSection& getLock() { return lock; } + bool readyLocked() const { return transport != nullptr && playing.load(); } + // Renders one block (1× bypass or phase-vocoder stretch) into the render + // buffer, advances heard/cached playheads, runs the loudness leveler, and + // clears `playing` at EOF. Returns output frames written + // (== jmin(numSamples, render-buffer cap)). Precondition: caller holds + // the lock and has verified readyLocked(). + int renderBlockLocked(int numSamples); + const juce::AudioBuffer& renderBuffer() const { return outputBuffer; } + +private: + void stopNoLock(); + + EngineState& state; + + juce::AudioFormatManager formatManager; + + // Read-ahead worker that fills the transport's buffer off the audio thread + // (see load()). Declared BEFORE transport so it is destroyed AFTER it — + // the transport's BufferingAudioSource holds a pointer to this thread and + // must be torn down before the thread goes away. + juce::TimeSliceThread readThread { "BackingReadAhead" }; + std::unique_ptr readerSource; + std::unique_ptr transport; + signalsmith::stretch::SignalsmithStretch stretch; + juce::AudioBuffer inputBuffer; // pulled from transport at device rate + juce::AudioBuffer outputBuffer; // stretch output, mixed by the callbacks + std::atomic stretchLatencySamples{0}; + std::atomic playing{false}; + std::atomic cachedPosition{0.0}; + std::atomic cachedDuration{0.0}; + // Heard playhead: accumulates the source frames consumed each block, then + // clamped to transport->getCurrentPosition() so a short read at EOF can't + // push it past the real source point. cachedPosition is this value minus + // the stretcher output latency (zero on the 1× bypass path). + std::atomic heardPositionSec{0.0}; + // Active playback rate. Mutated ONLY by the audio thread (in + // renderBlockLocked), coupled with the stretcher reset, so a block is + // never processed at a new rate with stale stretch state. + std::atomic speed{1.0}; + // Lock-free speed hand-off: setSpeed (control thread) publishes the + // requested rate here and raises speedChangePending; the audio thread + // adopts it on the next block. Avoids the control thread blocking on the + // lock and starving the RT tryLock (which would drop a backing block + // mid-slider-drag). + std::atomic pendingSpeed{1.0}; + std::atomic speedChangePending{false}; + // Per-song loudness normalizer (applied in renderBlockLocked, pre-fader). + // Owned + driven by the audio thread. + BackingLeveler leveler; + double levelerSr = 0.0; + juce::CriticalSection lock; +}; + +} // namespace slopsmith