diff --git a/modules/tracktion_engine/playback/graph/tracktion_WaveNode.cpp b/modules/tracktion_engine/playback/graph/tracktion_WaveNode.cpp index a9aad20208c..3b427641551 100644 --- a/modules/tracktion_engine/playback/graph/tracktion_WaveNode.cpp +++ b/modules/tracktion_engine/playback/graph/tracktion_WaveNode.cpp @@ -733,18 +733,23 @@ class TimeStretchReader final : public TimeStretchReaderBase return; // Back up the source to provide real audio context for the warmup - auto backupPos = std::max (SampleCount (0), static_cast (readPosition) - latencySamples); + const auto position = static_cast (readPosition); + const auto backupPos = std::max (SampleCount (0), position - latencySamples); source->setPosition (backupPos); - // Feed real audio and discard output until we've passed the latency region + // The stretcher's output lags its input by latencySamples so to line the next + // output frame up with readPosition, the frames between backupPos and + // readPosition have to be discarded as well as the latency region itself + const auto numToDiscard = static_cast (position - backupPos) + latencySamples; int outputDiscarded = 0; - while (outputDiscarded < latencySamples) + while (outputDiscarded < numToDiscard) { - feedAndProcess(); + if (! feedAndProcess()) + break; const int available = outputFifo.getNumReady(); - const int toDiscard = std::min (latencySamples - outputDiscarded, available); + const int toDiscard = std::min (numToDiscard - outputDiscarded, available); if (toDiscard > 0) { @@ -904,18 +909,25 @@ class ReadAheadTimeStretchReader final : public TimeStretchReaderBase if (latencySamples <= 0) return; - auto backupPos = std::max (SampleCount (0), static_cast (readPosition) - latencySamples); + const auto position = static_cast (readPosition); + const auto backupPos = std::max (SampleCount (0), position - latencySamples); source->setPosition (backupPos); + // The stretcher's output lags its input by latencySamples so to line the next + // output frame up with readPosition, the frames between backupPos and + // readPosition have to be discarded as well as the latency region itself + const auto numToDiscard = static_cast (position - backupPos) + latencySamples; int outputDiscarded = 0; - while (outputDiscarded < latencySamples) + while (outputDiscarded < numToDiscard) { if (const auto numToPush = timeStretcher.getFramesRecomended(); numToPush > 0) readSourceAndPushFrames (numToPush); - AudioScratchBuffer discardBuf (numChannels, chunkSize); - const int numRead = timeStretcher.popData (discardBuf.buffer.getArrayOfWritePointers(), chunkSize); + // Only ever discard up to the target so the stretcher isn't left ahead of readPosition + const int numThisTime = std::min (chunkSize, numToDiscard - outputDiscarded); + AudioScratchBuffer discardBuf (numChannels, numThisTime); + const int numRead = timeStretcher.popData (discardBuf.buffer.getArrayOfWritePointers(), numThisTime); if (numRead <= 0) break; diff --git a/modules/tracktion_engine/playback/graph/tracktion_WaveNode.test.cpp b/modules/tracktion_engine/playback/graph/tracktion_WaveNode.test.cpp index 57afb8961d2..4170ba74435 100644 --- a/modules/tracktion_engine/playback/graph/tracktion_WaveNode.test.cpp +++ b/modules/tracktion_engine/playback/graph/tracktion_WaveNode.test.cpp @@ -412,6 +412,93 @@ namespace wavenode_test_helpers // After clip: should be silent expectAudioBuffer (testContext->buffer, 0, toSamples ({ toPosition (fileLength) + fileLength, fileLength }, ts.sampleRate), 0.0f, 0.0f); } + + // Check the stretchers' latency compensation lines the source up with the timeline + // rather than just producing some audio at roughly the right place. The source has + // a silent second of its three so a mis-compensated stretcher moves the onsets. + // The clip offset matters here as the compensation has to survive the reader being + // repositioned, not just started from the beginning of the file + { + const auto onsetFileLength = 3_td; + const auto numOnsetFrames = (choc::buffer::FrameCount) toSamples (toPosition (onsetFileLength), ts.sampleRate); + const auto oneSecondOfFrames = numOnsetFrames / 3; + auto onsetBuffer = choc::buffer::createChannelArrayBuffer (1, numOnsetFrames, + [=] (auto, auto frame) -> float + { + if (frame >= oneSecondOfFrames && frame < oneSecondOfFrames * 2) + return 0.0f; + + return (float) std::sin (juce::MathConstants::twoPi * 220.0 + * (double) frame / ts.sampleRate); + }); + auto onsetFile = writeToTemporaryFile (onsetBuffer.getView(), ts.sampleRate, 0); + AudioFile onsetAudioFile (engine, onsetFile->getFile()); + + auto getRMS = [] (juce::AudioBuffer& buffer, juce::Range range) + { + return buffer.getRMSLevel (0, (int) range.getStart(), (int) range.getLength()); + }; + + for (auto mode : syncTestModes) + { + for (auto readAhead : { WaveNodeRealTime::ReadAhead::no, WaveNodeRealTime::ReadAhead::yes }) + { + // Read-ahead doesn't currently support every algorithm, SoundTouch + // produces no output at all through it + if (readAhead == WaveNodeRealTime::ReadAhead::yes && mode == TimeStretcher::Mode::soundtouchBetter) + continue; + + // Each entry is the clip's source offset and the sections of the timeline + // that must then be audible, the clip always starting at 1s + const std::pair> offsetsAndExpectedSections[] = + { + { 0_td, { true, false, true } }, + { 1_td, { false, true, false } } + }; + + for (auto [offset, expectedSections] : offsetsAndExpectedSections) + { + CAPTURE (magic_enum::enum_name (mode)); + CAPTURE (readAhead == WaveNodeRealTime::ReadAhead::yes); + CAPTURE (offset.inSeconds()); + + auto node = std::make_unique (onsetAudioFile, + TimeRange (1_tp, onsetFileLength - offset), + offset, + TimeRange(), + LiveClipLevel(), + 1.0, + ChannelConfiguration::discreteChannels (onsetAudioFile.getNumChannels()), + ChannelConfiguration::discreteChannels (1), + processState, + EditItemID(), + true, + ResamplingQuality::lagrange, + SpeedFadeDescription(), + std::nullopt, + mode, + TimeStretcher::ElastiqueProOptions(), + 0.0f, + readAhead); + + auto testContext = createTracktionTestContext (processState, std::move (node), ts, 1, 5.0); + + for (size_t section = 0; section < expectedSections.size(); ++section) + { + CAPTURE (section); + const auto sectionStart = 1.0 + (double) section; + const auto range = toSamples ({ TimePosition::fromSeconds (sectionStart + 0.05), + TimePosition::fromSeconds (sectionStart + 0.95) }, ts.sampleRate); + + if (expectedSections[section]) + CHECK_GT (getRMS (testContext->buffer, range), 0.5f); + else + CHECK_LT (getRMS (testContext->buffer, range), 0.05f); + } + } + } + } + } } } // namespace wavenode_test_helpers