refactor(audio): extract BackingPlayer (phase 3)

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 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-14 00:19:53 +02:00
co-authored by Claude Fable 5
parent 797501e5ff
commit d8f5784c63
5 changed files with 486 additions and 406 deletions
+16 -348
View File
@@ -20,9 +20,7 @@ inline bool firstN(std::atomic<uint32_t>& 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<juce::AudioFormatReaderSource>(reader, true);
backingTransport = std::make_unique<juce::AudioTransportSource>();
// 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::SourceInfo> 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<double>(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);
+23 -58
View File
@@ -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 <juce_audio_devices/juce_audio_devices.h>
@@ -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<float> outputGain{1.0f};
std::atomic<float> 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<float> 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<float> currentBackingLevel{0.0f};
std::atomic<float> 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<juce::AudioFormatReaderSource> backingSource;
std::unique_ptr<juce::AudioTransportSource> backingTransport;
signalsmith::stretch::SignalsmithStretch<float> backingStretch;
juce::AudioBuffer<float> backingInputBuffer; // pulled from transport at device rate
juce::AudioBuffer<float> backingBuffer; // stretch output, mixed into device buffer
std::atomic<int> backingStretchLatencySamples{0};
std::atomic<bool> backingPlaying{false};
std::atomic<double> cachedBackingPosition{0.0};
std::atomic<double> 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<double> 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<double> 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<double> backingPendingSpeed{1.0};
std::atomic<bool> 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.
+1
View File
@@ -7,6 +7,7 @@ set(AUDIO_SOURCES
TonePolish.cpp
AudioEngine.cpp
engine/StreamSink.cpp
engine/BackingPlayer.cpp
SourceChain.cpp
SignalChain.cpp
VSTHost.cpp
+325
View File
@@ -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 <cmath>
#include <iostream>
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<juce::AudioFormatReaderSource>(reader, true);
transport = std::make_unique<juce::AudioTransportSource>();
// 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<double>(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
+121
View File
@@ -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 <juce_audio_devices/juce_audio_devices.h>
#include <juce_audio_formats/juce_audio_formats.h>
#include <atomic>
#include <memory>
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<float>& 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<juce::AudioFormatReaderSource> readerSource;
std::unique_ptr<juce::AudioTransportSource> transport;
signalsmith::stretch::SignalsmithStretch<float> stretch;
juce::AudioBuffer<float> inputBuffer; // pulled from transport at device rate
juce::AudioBuffer<float> outputBuffer; // stretch output, mixed by the callbacks
std::atomic<int> stretchLatencySamples{0};
std::atomic<bool> playing{false};
std::atomic<double> cachedPosition{0.0};
std::atomic<double> 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<double> 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<double> 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<double> pendingSpeed{1.0};
std::atomic<bool> 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