Skip to content

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

Open
drowaudio wants to merge 1 commit into
developfrom
bugfix/soundtouch-read-ahead-silence
Open

Fix SoundTouch producing silence through the read-ahead time-stretch path#405
drowaudio wants to merge 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

SoundTouch buffers input internally and emits nothing until it holds sampleReq frames. Measured priming requirement for the "better" settings:

Sample rate Speed ratio Frames of input before first output
44100 1.0 4096
44100 0.5 6144
96000 1.0 8704
96000 0.5 13312

SoundTouchStretcher::getFramesNeeded() only ever reports one block's worth (256-2048). ReadAheadTimeStretcher::popData already loops whilst input is being consumed, but on the first block after a reset only getFramesNeeded() frames have been pushed, so the loop drains them, SoundTouch emits nothing, and popData returns 0. ReadAheadTimeStretchReader::readSamples then treated that as fatal - destBuffer.clear(); return false; - and playback never recovered.

The non-read-ahead TimeStretchReader pre-feeds the stretcher and loops until output appears, which is why it was unaffected. This is a consumer-side assumption, not an algorithm limitation, so it is fixable rather than something to reject at the API level.

Changes

  • ReadAheadTimeStretchReader::readSamples now pushes more source frames and retries when popData returns 0, capped at one second of source material so an algorithm that genuinely cannot emit still fails fast instead of spinning on the audio thread.
  • TimeStretchReader: removed assert (outputFifo.getFreeSpace() >= numThisTime). It asserts output space against an input frame count; processData only ever writes up to chunkSize frames, which the following assertion already covers. With RubberBand returning getMaxFramesNeeded() (8192) whilst priming, this fires as soon as any output is queued.
  • Docs on TimeStretcher::getFramesNeeded() and ReadAheadTimeStretcher::popData() now state that some algorithms need priming and that an empty block is not a failure.

Tests

  • The syncTestModes latency-compensation block in runTimestretchedTests() now runs every enabled algorithm against both ReadAhead::no and ReadAhead::yes, so the read-ahead path is covered for SoundTouch, RubberBand and Signalsmith rather than RubberBand only.
  • The Playback single audio clip using read-ahead guard is relaxed from RubberBand-only to any enabled algorithm.

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

  • TestRunner --no-juce-tests --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 readSamples change reverted, the same run fails with 8 x CHECK( rms > 0.5f ) reporting rms == 0 (one per test setup for soundtouchBetter, read-ahead), i.e. the reported symptom.

@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.95%. Comparing base (b88a6ee) to head (91197c6).

Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #405      +/-   ##
===========================================
+ Coverage    58.74%   58.95%   +0.20%     
===========================================
  Files          564      564              
  Lines        78879    78888       +9     
  Branches     12330    12335       +5     
===========================================
+ Hits         46338    46508     +170     
+ Misses       32541    32380     -161     

☔ 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.

…path

SoundTouch buffers a large amount of input internally before it emits its
first block (~4k frames at 44.1kHz, up to ~13k at 96kHz with a 0.5 speed
ratio) but getFramesNeeded() only ever reports a single block's worth.
ReadAheadTimeStretcher::popData already loops whilst input is being
consumed, but on the first block after a reset only getFramesNeeded()
frames have been pushed, so it drains them, SoundTouch emits nothing and
popData returns 0. ReadAheadTimeStretchReader::readSamples treated that as
a failure and cleared the buffer, so playback was silent for its whole
duration whenever read-ahead was enabled with a SoundTouch mode. The
non-read-ahead reader pre-feeds and loops until output appears, which is
why it was unaffected.

- ReadAheadTimeStretchReader::readSamples now pushes more source frames and
  retries rather than bailing out, limited to a second of source material
  so an algorithm that genuinely can't produce output still fails fast.
- Remove the TimeStretchReader assertion that the output FIFO has
  getFramesNeeded() frames free. processData only ever writes up to
  chunkSize frames, which the following assertion already covers, and
  RubberBand returns getMaxFramesNeeded() whilst priming so this fired as
  soon as any output was queued.
- Document the priming behaviour on TimeStretcher::getFramesNeeded and
  ReadAheadTimeStretcher::popData.
- Run the syncTestModes latency-compensation test against both ReadAhead
  values, and relax the read-ahead playback test's guard from
  RubberBand-only to any enabled algorithm.
@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.

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