Skip to content

Fix SoundTouch producing silence through the read-ahead time-stretch path - #405

Merged
drowaudio merged 1 commit into
developfrom
bugfix/soundtouch-read-ahead-silence
Sep 7, 2026
Merged

Fix SoundTouch producing silence through the read-ahead time-stretch path#405
drowaudio merged 1 commit into
developfrom
bugfix/soundtouch-read-ahead-silence

Conversation

@drowaudio

@drowaudio drowaudio commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Problem

TimeStretcher::Mode::soundtouchBetter produces completely silent output when used via WaveNodeRealTime::ReadAhead::yes, i.e. whenever a host returns true from EngineBehaviour::enableReadAheadForTimeStretchNodes() (as the TestRunner does). RMS is exactly 0 across the whole buffer, at every sample rate. Without read-ahead SoundTouch works and is well aligned.

Cause

SoundTouchStretcher::getFramesNeeded() did not honour its contract. SoundTouch buffers its initial latency worth of input before it emits anything, but getFramesNeeded() only ever reported one block's worth (samplesPerBlock * ioRatio, 256-2048 frames). Measured input needed before the first output for the "better" settings:

Sample rate Speed ratio Frames before first output SETTING_INITIAL_LATENCY
44100 1.0 4096 3812
44100 0.5 6144 6106
96000 1.0 8704 8224
96000 0.5 13312 13216

So the first processData call after a reset returned 0 frames. The non-read-ahead TimeStretchReader loops until output appears and hides this; ReadAheadTimeStretchReader pushes getFramesNeeded() frames, pops one block, and treats an empty pop as end-of-data, so playback stayed silent. The hard-coded getMaxFramesNeeded() == 8192 was also wrong: at 96 kHz the priming requirement exceeds it.

Fix

Make SoundTouchStretcher report what SoundTouch actually needs, following the pattern RubberBandStretcher already uses while priming:

  • getFramesNeeded(): until the first batch has been produced since the last reset, return SETTING_INITIAL_LATENCY + one block's input - numUnprocessedSamples(), clamped to getMaxFramesNeeded(). Afterwards the existing per-block formula applies. A hasProducedOutput flag (cleared in reset()) tracks this.
  • getMaxFramesNeeded(): computed once in the constructor from SETTING_INITIAL_LATENCY at the slowest supported speed (0.25, tempo 4) plus a block's input at the fastest (4), so it is sample-rate aware rather than a magic number. Speed ratios outside 0.25-4 still work but may need more than one process call for the first block.
  • SETTING_INITIAL_LATENCY is re-read in setSpeedAndPitch since it depends on tempo/rate.

No reader changes are needed. TimeStretchReader loses its assert (outputFifo.getFreeSpace() >= numThisTime), which compared output space against an input count - processData only writes up to chunkSize frames, which the next assertion already covers, and it fires as soon as RubberBand's 8192-frame priming request coincides with queued output.

TimeStretcher::getFramesNeeded() docs now state the contract: this many frames must yield at least one block, including the first call after a reset, and never exceed getMaxFramesNeeded().

Tests

  • New runFramesNeededContractTest in tracktion_TimeStretch.test.cpp: for 44.1/96 kHz x 64/512 block x five speed/pitch combinations, pushing exactly getFramesNeeded() frames once must produce output, both straight after initialisation and again after reset(), and getFramesNeeded() <= getMaxFramesNeeded() throughout. Run for SoundTouch, RubberBand and Signalsmith.
  • The syncTestModes latency-compensation block in runTimestretchedTests() now runs every enabled algorithm against both ReadAhead::no and ReadAhead::yes.
  • Playback single audio clip using read-ahead guard relaxed from RubberBand-only to any enabled algorithm.

Verified locally on macOS (Debug, RubberBand + Signalsmith + SoundTouch):

  • --source-file="*TimeStretch.test.cpp": 542/542 assertions (was 62 before the contract test).
  • --source-file="*WaveNode.test.cpp": 3/3 cases, 1118/1118 assertions; all three algorithms run 8 setups x both read-ahead modes.
  • With only the SoundTouchStretcher change reverted: 40 contract assertions fail (every SoundTouch combination, first push and post-reset push) and the 8 soundtouchBetter, read-ahead WaveNode cases fail with rms == 0 - the reported symptom. RubberBand and Signalsmith pass the contract unchanged.

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 58.96%. Comparing base (b88a6ee) to head (c7078cf).

Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #405      +/-   ##
===========================================
+ Coverage    58.74%   58.96%   +0.22%     
===========================================
  Files          564      564              
  Lines        78879    78920      +41     
  Branches     12330    12331       +1     
===========================================
+ Hits         46338    46539     +201     
+ Misses       32541    32381     -160     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@drowaudio

Copy link
Copy Markdown
Contributor Author

Pushed 62bd6b5 after the first CI run went red.

The new alignment test also covers RubberBand, which CI enables by adding the rubberband submodule at build time. That exposed a second, unrelated latent bug in the non-read-ahead TimeStretchReader:

Assertion `inputFifo.getNumReady() >= numThisTime` failed, tracktion_WaveNode.cpp:696

juce::AbstractFifo::getFreeSpace() is bufferSize - numReady - 1, so a FIFO can only hold one less than its total size. TimeStretchReader sized its FIFOs to exactly getMaxFramesNeeded() (8192), but RubberBandStretcher::getFramesNeeded() returns min (getSamplesRequired() * 6, getMaxFramesNeeded()) whilst priming and so can return exactly 8192. The write silently failed and the assertion fired. ReadAheadTimeStretcher::initialise already allows for this with getMaxFramesNeeded() + 1, so TimeStretchReader now does the same. The assert (outputFifo.getFreeSpace() >= numThisTime) on the next line is also gone - it asserts output space against an input frame count, and processData only ever writes up to chunkSize, which the following assertion covers.

This is why test (Debug, windows, TestRunner) sat at 6h - a Debug CRT assertion dialog blocking until the job timeout.

Verified locally with the rubberband submodule added: all 128 alignment cases (8 test setups x soundtouchNormal/Better + rubberbandMelodic/Percussive x read-ahead on/off) now run clean. Before the fix it aborted on the first RubberBand (Melodic) case.

Separately, the build (macOS) and build (windows) jobs fail for reasons that predate this branch and will stay red: macOS pins Xcode_15.3.app, which no longer exists on the current macos-26-arm64 runner image, and Windows hits JUCE API drift (FloatAudioFileFormat::createWriterFor no longer overrides anything, juce::DrawableRectangle::setVisible is gone). Master last went green in August 2025 on older images. Happy to open a separate issue for the CI image refresh.

@drowaudio

Copy link
Copy Markdown
Contributor Author

CI after 62bd6b5: 19 pass / 17 fail, and every remaining failure is a build failure that never reaches the test stage.

Fixed by this push (all were red before):

Job Before After
test (Debug, linux, TestRunner) fail (the RubberBand assert) pass
test (Debug, windows, TestRunner) fail (6h - assert dialog) pass
test (Release, linux, TestRunner) fail pass
test (Debug/Release, linux, Benchmarks) fail pass
test (macOS_tsan, TestRunner/Benchmarks) fail pass

The 17 that stay red are all pre-existing toolchain/API rot on the current runner images, none of it in code this PR touches:

  • xcode-select: error: invalid developer directory /Applications/Xcode_15.3.app - the pinned Xcode is gone from macos-26-arm64.
  • _LIBCPP_ENABLE_ASSERTIONS has been removed, please use _LIBCPP_HARDENING_MODE=<mode> instead - JUCE juce_recommended_config_flags versus the macOS 26.5 SDK. This is what kills test (Debug, macOS, TestRunner) and test (Debug, macOS, Benchmarks).
  • -Werror on existing implicit int-to-float conversions in tracktion_PerformanceMeasurement.h, tracktion_Time.h and tracktion_NodePlayer.h - all Release macOS jobs.
  • JUCE API drift: FloatAudioFileFormat::createWriterFor no longer overrides anything, juce::DrawableRectangle lost setVisible, Component::addAndMakeVisible overloads changed - build (linux), build (windows), all Release Windows jobs.
  • JUCE_USE_CURL / JUCE_WEB_BROWSER redefined [-Werror] on linux.

Master last went green in August 2025 on older runner images. Happy to open a separate issue for the CI refresh if useful.

Also verified locally with the rubberband submodule added, Debug: 128/128 alignment cases clean and doctest 22/22 pass - including tracktion_EditClip.test.cpp:103, the null test I previously reported as failing. It passes here because with RubberBand available TimeStretcher::defaultMode is rubberbandMelodic rather than soundtouchBetter, so that was a SoundTouch fidelity artefact rather than a bug. The only local failures were RecordingSyncTests / Test injected impulses align, which needs a real audio input device and passes on the CI linux/windows runners.

@drowaudio

Copy link
Copy Markdown
Contributor Author

I don't think this is the correct approach as it breaks the contract of getFramesNeeded(). That should always return the number of frames required to generate at least one block. Perhaps the SoundTouchStretcher needs to be fixed so getMaxFramesNeeded() and getFramesNeeded() work as advertised?

@drowaudio

Copy link
Copy Markdown
Contributor Author

Opened #407 for the CI breakage so it does not get conflated with this change.

@drowaudio
drowaudio force-pushed the bugfix/soundtouch-read-ahead-silence branch from 62bd6b5 to 91197c6 Compare September 3, 2026 14:35
@drowaudio
drowaudio changed the base branch from master to develop September 3, 2026 14:35
@drowaudio

Copy link
Copy Markdown
Contributor Author

Rebased onto develop (the original branch was cut from master, which is why CI was red and why the description did not match the tree). The branch is now a single commit on top of b88a6ee; the earlier master-based commits are gone. develop already had the popData progress loop and the TimeStretchReader FIFO +1, so this is now just the reader-side priming retry, the bogus output-FIFO assertion, the docs, and the test running syncTestModes against both ReadAhead values.

…d 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.
@drowaudio
drowaudio force-pushed the bugfix/soundtouch-read-ahead-silence branch from 91197c6 to c7078cf Compare September 7, 2026 11:52
@drowaudio

Copy link
Copy Markdown
Contributor Author

Agreed, that was the wrong layer. Reworked: the reader-side retry is gone and SoundTouchStretcher now honours the contract itself. Until the first batch has been produced since a reset, getFramesNeeded() returns SoundTouch's own SETTING_INITIAL_LATENCY plus one block's input minus what it already has buffered, clamped to getMaxFramesNeeded() - the same shape as RubberBand's priming path. getMaxFramesNeeded() is derived from that latency at the slowest supported speed (0.25) instead of the hard-coded 8192, which was too small at 96 kHz. Added runFramesNeededContractTest so all three stretchers are held to "one push of getFramesNeeded() yields output, straight after init and after reset, and never exceeds getMaxFramesNeeded()" - reverting the SoundTouch change fails 40 of those plus the 8 read-ahead WaveNode cases with rms 0. Description updated.

@drowaudio
drowaudio merged commit 5cfdac4 into develop Sep 7, 2026
39 checks passed
@drowaudio
drowaudio deleted the bugfix/soundtouch-read-ahead-silence branch September 7, 2026 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant