From b5dce9af4ddbbffb4a517bcf445a9ddf240466a4 Mon Sep 17 00:00:00 2001 From: Jorge Fritis <120731233+Jafz2001@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:16:27 -0400 Subject: [PATCH] fix(audio): read-ahead the backing track off the RT audio thread (#60) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(audio): read-ahead the backing track off the RT audio thread The backing AudioTransportSource was set up with no read-ahead buffer and no reader thread — setSource(src, 0, nullptr, rate) — so the realtime audio callback decoded the backing file synchronously inside getNextAudioBlock on every block while a song plays. Any disk seek or codec spike (worst on compressed formats) then blew the block's realtime budget, producing underruns heard as glitches / brief mutes. Interpose a juce::TimeSliceThread with 32768 source frames (~0.68 s @ 48 kHz) of look-ahead so decode happens off the audio thread. The thread is declared before backingTransport (so it is destroyed after it — the transport's BufferingAudioSource holds a pointer to it) and started once in the ctor. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(audio): document the bounded BufferingAudioSource lock residual Codex review: readBufferSection() holds callbackLock across a refill chunk decode and the RT callback takes the same lock. Accepted — the window is bounded (2048-frame chunks) and only hit mid-refill, vs. the old guaranteed synchronous decode every block; note it in the comment so nobody mistakes the transport stack for fully RT-safe. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jafz2001 Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: byrongamatos --- src/audio/AudioEngine.cpp | 25 ++++++++++++++++++++++++- src/audio/AudioEngine.h | 5 +++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/audio/AudioEngine.cpp b/src/audio/AudioEngine.cpp index 3a89530..3ce15e3 100644 --- a/src/audio/AudioEngine.cpp +++ b/src/audio/AudioEngine.cpp @@ -20,6 +20,11 @@ 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 // chain reads the engine's audioRunning / currentSampleRate atomics by @@ -1278,9 +1283,27 @@ bool AudioEngine::loadBackingTrack(const juce::File& file) backingSource = std::make_unique(reader, true); backingTransport = std::make_unique(); + // Read-ahead on backingReadThread so the RT audio thread normally never + // touches the disk or the format codec. Previously this passed + // (…, 0, nullptr, …): with no read-ahead buffer the transport decoded the + // file synchronously inside getNextAudioBlock ON the audio callback, so any + // disk seek / decode spike (worst for compressed formats) blew the block + // budget → underruns heard as glitches or brief mutes while a song plays. + // 32768 source frames ≈ 0.68 s @ 48k of look-ahead absorbs those spikes. + // + // Known residual (accepted): juce::BufferingAudioSource is not fully + // RT-safe — readBufferSection() holds callbackLock across the decode of one + // refill chunk, and the callback's getNextAudioBlock() takes the same lock, + // so the RT thread can still block behind an in-flight chunk decode. The + // window is bounded (JUCE caps chunks at 2048 source frames) and only hit + // when a refill is mid-decode, vs. the old guaranteed full decode on every + // block; a truly lock-free ring would mean replacing the JUCE transport + // stack and isn't worth it here. // The 4th arg makes AudioTransportSource SRC the file to device rate. // Stretch always sees device-rate audio so that its presetDefault parameters match. - backingTransport->setSource(backingSource.get(), 0, nullptr, readerSampleRate); + 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 diff --git a/src/audio/AudioEngine.h b/src/audio/AudioEngine.h index f440dd3..ea0c904 100644 --- a/src/audio/AudioEngine.h +++ b/src/audio/AudioEngine.h @@ -467,6 +467,11 @@ private: std::atomic outputPeak{0.0f}; // Backing track + // Read-ahead worker that fills the transport's buffer off the audio thread + // (see loadBackingTrack). Declared BEFORE backingTransport so it is destroyed + // AFTER it — the transport's BufferingAudioSource holds a pointer to this + // thread and must be torn down before the thread goes away. + juce::TimeSliceThread backingReadThread { "BackingReadAhead" }; std::unique_ptr backingSource; std::unique_ptr backingTransport; signalsmith::stretch::SignalsmithStretch backingStretch;