mirror of
https://github.com/got-feedBack/feedBack-desktop.git
synced 2026-09-10 23:04:09 +00:00
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:
co-authored by
Claude Fable 5
parent
797501e5ff
commit
d8f5784c63
+16
-348
@@ -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
@@ -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.
|
||||
|
||||
@@ -7,6 +7,7 @@ set(AUDIO_SOURCES
|
||||
TonePolish.cpp
|
||||
AudioEngine.cpp
|
||||
engine/StreamSink.cpp
|
||||
engine/BackingPlayer.cpp
|
||||
SourceChain.cpp
|
||||
SignalChain.cpp
|
||||
VSTHost.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 <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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user