From c7078cf734fafe2ab5f06c4958b947e15ab3fc8c Mon Sep 17 00:00:00 2001 From: David Rowland Date: Thu, 3 Sep 2026 15:35:22 +0100 Subject: [PATCH] TimeStretch: Fixed SoundTouch producing silence through the read-ahead reader SoundTouchStretcher::getFramesNeeded() did not honour its contract of returning enough frames to produce at least one block. SoundTouch buffers its initial latency worth of input (~4k frames at 44.1kHz, up to ~13k at 96kHz with a 0.5 speed ratio) before it emits anything, but the stretcher only ever reported a single block's worth, so the first processData call after a reset returned nothing. The non-read-ahead TimeStretchReader loops until output appears and hid this; ReadAheadTimeStretchReader pushes getFramesNeeded() frames, pops one block and treats an empty pop as end-of-data, so playback stayed silent whenever read-ahead was enabled with a SoundTouch mode. The hard-coded getMaxFramesNeeded() of 8192 was also smaller than the priming requirement at 96kHz. - SoundTouchStretcher::getFramesNeeded() now includes SoundTouch's own SETTING_INITIAL_LATENCY until the first batch has been produced since the last reset, clamped to getMaxFramesNeeded(), mirroring how RubberBandStretcher handles priming. - getMaxFramesNeeded() is computed in the constructor from the initial latency at the slowest supported speed ratio (0.25) plus a block's input at the fastest (4), so it is sample-rate aware. - Removed the TimeStretchReader assertion comparing output FIFO space against an input frame count; processData only writes up to chunkSize frames, which the following assertion already covers, and RubberBand's priming request trips it as soon as any output is queued. - Documented the getFramesNeeded() contract on TimeStretcher. - Added runFramesNeededContractTest, checking for every enabled stretcher that pushing exactly getFramesNeeded() frames yields output immediately after initialisation and after a reset, and that it never exceeds getMaxFramesNeeded(). The syncTestModes latency-compensation test now runs against both ReadAhead values, and the read-ahead playback test's guard covers any enabled algorithm rather than RubberBand only. --- .../playback/graph/tracktion_WaveNode.cpp | 3 +- .../graph/tracktion_WaveNode.test.cpp | 13 ++++-- .../timestretch/tracktion_TimeStretch.cpp | 42 +++++++++++++++--- .../timestretch/tracktion_TimeStretch.h | 3 ++ .../tracktion_TimeStretch.test.cpp | 43 +++++++++++++++++++ 5 files changed, 94 insertions(+), 10 deletions(-) diff --git a/modules/tracktion_engine/playback/graph/tracktion_WaveNode.cpp b/modules/tracktion_engine/playback/graph/tracktion_WaveNode.cpp index a9aad20208c..413a1645058 100644 --- a/modules/tracktion_engine/playback/graph/tracktion_WaveNode.cpp +++ b/modules/tracktion_engine/playback/graph/tracktion_WaveNode.cpp @@ -717,8 +717,9 @@ class TimeStretchReader final : public TimeStretchReaderBase inputFifo.write (scratchBuffer.buffer); } + // N.B. processData only ever writes up to chunkSize frames to the output FIFO + // so that's all the space that needs to be free, not numThisTime assert (inputFifo.getNumReady() >= numThisTime); - assert (outputFifo.getFreeSpace() >= numThisTime); assert (outputFifo.getFreeSpace() >= chunkSize); timeStretcher.processData (inputFifo, numThisTime, outputFifo); diff --git a/modules/tracktion_engine/playback/graph/tracktion_WaveNode.test.cpp b/modules/tracktion_engine/playback/graph/tracktion_WaveNode.test.cpp index 57afb8961d2..ba7feac28fe 100644 --- a/modules/tracktion_engine/playback/graph/tracktion_WaveNode.test.cpp +++ b/modules/tracktion_engine/playback/graph/tracktion_WaveNode.test.cpp @@ -378,8 +378,9 @@ namespace wavenode_test_helpers }; for (auto mode : syncTestModes) + for (auto readAhead : { WaveNodeRealTime::ReadAhead::no, WaveNodeRealTime::ReadAhead::yes }) { - MESSAGE (magic_enum::enum_name (mode)); + MESSAGE (magic_enum::enum_name (mode) << (readAhead == WaveNodeRealTime::ReadAhead::yes ? std::string_view (", read-ahead") : std::string_view())); auto node = std::make_unique (squareAudioFile, TimeRange (1_tp, fileLength), 0_td, @@ -394,7 +395,10 @@ namespace wavenode_test_helpers ResamplingQuality::lagrange, SpeedFadeDescription(), std::nullopt, - mode); + mode, + TimeStretcher::ElastiqueProOptions(), + 0.0f, + readAhead); auto testContext = createTracktionTestContext (processState, std::move (node), ts, 1, (fileLength * 3.0).inSeconds()); @@ -445,8 +449,9 @@ TEST_CASE ("WaveNode") #endif -// Currently only works with RubberBand -#if ENGINE_UNIT_TESTS_WAVENODE_READAHEAD && TRACKTION_ENABLE_TIMESTRETCH_RUBBERBAND +#if ENGINE_UNIT_TESTS_WAVENODE_READAHEAD \ + && (TRACKTION_ENABLE_TIMESTRETCH_RUBBERBAND || TRACKTION_ENABLE_TIMESTRETCH_SOUNDTOUCH \ + || TRACKTION_ENABLE_TIMESTRETCH_SIGNALSMITH || TRACKTION_ENABLE_TIMESTRETCH_ELASTIQUE) TEST_SUITE("tracktion_engine") { TEST_CASE ("Playback single audio clip using read-ahead") diff --git a/modules/tracktion_engine/timestretch/tracktion_TimeStretch.cpp b/modules/tracktion_engine/timestretch/tracktion_TimeStretch.cpp index abdf5e975a5..26fd3d15d2e 100644 --- a/modules/tracktion_engine/timestretch/tracktion_TimeStretch.cpp +++ b/modules/tracktion_engine/timestretch/tracktion_TimeStretch.cpp @@ -499,16 +499,31 @@ struct SoundTouchStretcher : public TimeStretcher::Stretcher, setSetting (SETTING_SEQUENCE_MS, 60); setSetting (SETTING_SEEKWINDOW_MS, 25); } + + // SoundTouch buffers its initial latency worth of input before it emits anything and + // that latency grows with the tempo, so size the maximum for the slowest supported + // speed (0.25, i.e. tempo 4) plus one block's worth of input at the fastest (4) + setTempo (1.0f / minSupportedSpeedRatio); + maxFramesNeeded = getSetting (SETTING_INITIAL_LATENCY) + + juce::roundToInt (samplesPerOutputBuffer * maxSupportedSpeedRatio); + setTempo (1.0f); + initialLatency = getSetting (SETTING_INITIAL_LATENCY); } bool isOk() const override { return true; } - void reset() override { clear(); } + + void reset() override + { + clear(); + hasProducedOutput = false; + } bool setSpeedAndPitch (float speedRatio, float semitonesUp) override { setTempo (1.0f / speedRatio); setPitchSemiTones (semitonesUp); inputOutputSampleRatio = getInputOutputSampleRatio(); + initialLatency = getSetting (SETTING_INITIAL_LATENCY); return true; } @@ -517,14 +532,23 @@ struct SoundTouchStretcher : public TimeStretcher::Stretcher, { const int numAvailable = (int) numSamples(); const int numRequiredForOneBlock = juce::roundToInt (samplesPerOutputBuffer * inputOutputSampleRatio); + const int numRequiredForOutput = std::max (0, numRequiredForOneBlock - numAvailable); + + if (hasProducedOutput || numAvailable > 0) + return numRequiredForOutput; - return std::max (0, numRequiredForOneBlock - numAvailable); + // Until the first batch has been produced, SoundTouch needs its initial latency + // worth of input buffered before it will emit anything, so ask for enough to get + // the first block out in one go rather than reporting a single block's worth and + // returning nothing from processData for several calls + const int numToPrime = initialLatency + numRequiredForOneBlock - (int) numUnprocessedSamples(); + + return juce::jlimit (0, maxFramesNeeded, std::max (numRequiredForOutput, numToPrime)); } int getMaxFramesNeeded() const override { - // This was derived by experimentation - return 8192; + return maxFramesNeeded; } int processData (const float* const* inChannels, int numSamples, float* const* outChannels) override @@ -539,7 +563,10 @@ struct SoundTouchStretcher : public TimeStretcher::Stretcher, const int numToRead = std::min (numAvailable, samplesPerOutputBuffer); if (numToRead > 0) + { + hasProducedOutput = true; return readOutput (outChannels, 0, numToRead); + } return 0; } @@ -560,8 +587,13 @@ struct SoundTouchStretcher : public TimeStretcher::Stretcher, } private: + // Speed ratios outside this range still work but getFramesNeeded is clamped to + // getMaxFramesNeeded so the first block may take more than one process call + static constexpr float minSupportedSpeedRatio = 0.25f, maxSupportedSpeedRatio = 4.0f; + int numChannels = 0, samplesPerOutputBuffer = 0; - bool hasDoneFinalBlock = false; + int maxFramesNeeded = 0, initialLatency = 0; + bool hasDoneFinalBlock = false, hasProducedOutput = false; double inputOutputSampleRatio = 1.0; int readOutput (float* const* outChannels, int offset, int numNeeded) diff --git a/modules/tracktion_engine/timestretch/tracktion_TimeStretch.h b/modules/tracktion_engine/timestretch/tracktion_TimeStretch.h index 787c62378b3..ae18046910c 100644 --- a/modules/tracktion_engine/timestretch/tracktion_TimeStretch.h +++ b/modules/tracktion_engine/timestretch/tracktion_TimeStretch.h @@ -144,6 +144,9 @@ class TimeStretcher /** Returns the expected number of frames required to generate some output. This should be queried each block and the returned number of frames be passes to processData. + Passing this many frames to processData must always produce at least one block of output, + including the first call after a reset, so implementations account for any internal + buffering their algorithm needs before it emits. This never exceeds getMaxFramesNeeded. */ int getFramesNeeded() const; diff --git a/modules/tracktion_engine/timestretch/tracktion_TimeStretch.test.cpp b/modules/tracktion_engine/timestretch/tracktion_TimeStretch.test.cpp index e2705252582..de249d8f142 100644 --- a/modules/tracktion_engine/timestretch/tracktion_TimeStretch.test.cpp +++ b/modules/tracktion_engine/timestretch/tracktion_TimeStretch.test.cpp @@ -324,6 +324,46 @@ void runSmallBlockLatencyTest (tracktion::engine::TimeStretcher::Mode mode) CHECK (maxFrames >= stretcher.getLatencySamples()); } +// getFramesNeeded() must report enough frames to get at least one block out of processData, +// including the very first call after initialisation or a reset, and must never exceed +// getMaxFramesNeeded() so callers can size their FIFOs from it +void runFramesNeededContractTest (tracktion::engine::TimeStretcher::Mode mode) +{ + for (double sampleRate : { 44100.0, 96000.0 }) + for (int blockSize : { 64, 512 }) + for (auto [speedRatio, semitones] : { std::pair { 1.0f, 0.0f }, std::pair { 0.5f, 0.0f }, std::pair { 2.0f, 0.0f }, + std::pair { 1.0f, 12.0f }, std::pair { 1.0f, -12.0f } }) + { + CAPTURE (sampleRate); CAPTURE (blockSize); CAPTURE (speedRatio); CAPTURE (semitones); + + const int numChannels = 2; + tracktion::engine::TimeStretcher stretcher; + stretcher.initialise (sampleRate, blockSize, numChannels, mode, {}, true); + stretcher.setSpeedAndPitch (speedRatio, semitones); + + const int maxFramesNeeded = stretcher.getMaxFramesNeeded(); + const auto source = createSinBuffer (sampleRate, numChannels, 440.0f); + juce::AudioBuffer output (numChannels, blockSize); + + auto pushFramesNeededOnce = [&] + { + const int framesNeeded = stretcher.getFramesNeeded(); + CHECK (framesNeeded > 0); + CHECK (framesNeeded <= maxFramesNeeded); + REQUIRE (framesNeeded <= source.getNumSamples()); + + return stretcher.processData (source.getArrayOfReadPointers(), framesNeeded, + output.getArrayOfWritePointers()); + }; + + CHECK (pushFramesNeededOnce() > 0); + + stretcher.reset(); + stretcher.setSpeedAndPitch (speedRatio, semitones); + CHECK (pushFramesNeededOnce() > 0); + } +} + } // anonymous namespace TEST_SUITE ("tracktion_engine") @@ -337,6 +377,7 @@ TEST_SUITE ("tracktion_engine") runPitchShiftTest (mode); runTimestretchTest (mode); runLatencyTest (mode); + runFramesNeededContractTest (mode); } #endif @@ -347,6 +388,7 @@ TEST_SUITE ("tracktion_engine") runPitchShiftTest (mode); runTimestretchTest (mode); runLatencyTest (mode); + runFramesNeededContractTest (mode); } #endif @@ -358,6 +400,7 @@ TEST_SUITE ("tracktion_engine") runTimestretchTest (mode); runLatencyTest (mode); runSmallBlockLatencyTest (mode); + runFramesNeededContractTest (mode); } #endif