Skip to content

ClipEffects: Build render Nodes at render time so plugins get the real sample rate - #432

Merged
drowaudio merged 2 commits into
developfrom
bugfix/issue_417_clip_effect_plugin_sample_rate
Sep 21, 2026
Merged

drowaudio merged 2 commits into
developfrom
bugfix/issue_417_clip_effect_plugin_sample_rate

Conversation

@drowaudio

Copy link
Copy Markdown
Contributor

Summary

A PluginEffect clip effect prepared its hosted plugin at the wrong sample rate, so anything rate-dependent inside the plugin (delay lines, filters, oversamplers) was silently wrong in the rendered file.

AudioNodeRenderJob::initialise took an already-built tracktion::graph::Node, so every effect built its graph inside createRenderJob - before the render sample rate was known. Node building is now deferred to createAndPrepareRenderContext, which knows the rate.

Root cause

PluginNode's constructor calls initialisePlugin (sampleRateToUse, ...) → plugin->baseClassInitialise (...), so the plugin is initialised at construction time. The rates the clip effects were passing in were all wrong:

  • PluginEffect and PitchShiftEffect passed job->processState.sampleRate, which is ProcessState's placeholder default of 44100.0 - the render's real rate is only set later, from the writer.
  • VolumeEffect passed sourceFile.getInfo().sampleRate, which is 0 when the source is an earlier effect stage's output file that hasn't been rendered yet.

Plugin::baseClassInitialise only re-runs initialise() when the rate or block size changed, so the constructor's wrong rate could not be corrected by preparing the plugin again later. PluginNode::prepareToPlay then hits jassert (sampleRate == info.sampleRate) and carries on regardless.

The same "read the source file too early" problem affected the channel count: createWaveNodeForFile reads file.getInfo().numChannels, which is 0 (clamped to mono) for a not-yet-rendered chained source.

The change

  • AudioNodeRenderJob::initialise now takes a NodeBuilder - std::function<std::unique_ptr<Node> (AudioNodeRenderJob&, double sampleRate)> - instead of a Node. The old Node-taking overload is gone, so the mistake can't recur.
  • createAndPrepareRenderContext reads source.getInfo().sampleRate (valid by then - preceding jobs in the chain have run) and passes it to the builder.
  • VolumeEffect, FadeInOutEffect, StepVolumeEffect, PitchShiftEffect and PluginEffect all build their Nodes from the builder. FadeInOutEffect and StepVolumeEffect capture their CachedValue properties by value up-front rather than capturing this.
  • PluginNode::initialisePlugin gained jassert (sampleRateToUse > 0.0) / jassert (blockSizeToUse > 0) so a bad rate is caught where the contract is broken, not three layers down.

Regression test

ClipEffects: PluginEffect is prepared at the render sample rate in tracktion_ClipEffects.test.cpp. It registers a test-only SampleRateProbePlugin that records the PluginInitialisationInfo it was initialised with, renders a clip effect chain over a 96 kHz source (deliberately not 44100, so the ProcessState default can't accidentally pass), and checks:

  • the plugin was initialised at 96000 Hz with a 512 block size
  • the rendered proxy keeps the source's rate and channel count

Two subcases: the plugin effect on its own, and chained after a VolumeEffect (the case where the source file doesn't exist yet when the jobs are created).

Before the fix both subcases fail with 44100 == Approx( 96000 ); after, they pass.

Testing

TestRunner --no-juce-tests: 370/370 test cases, 21280 assertions, all passing in Release. ClipEffects, Plugins, ExternalPlugin, RackNode, RackInstance and EditNodeBuilder suites also run clean in Debug, so the new assertions don't fire anywhere else.

Fixes #417

🤖 Generated with Claude Code

…l sample rate (fixes #417)

AudioNodeRenderJob took a fully built Node at createRenderJob time, but a
PluginNode initialises its plugin in its constructor, so the plugin was
prepared before the render sample rate was known. PluginEffect and
PitchShiftEffect passed ProcessState's placeholder 44100.0, and VolumeEffect
passed the source file's rate - which is 0 when the source is an earlier
effect stage that hasn't rendered yet. Plugin::baseClassInitialise only
re-runs initialise() when the rate or block size changes, so the wrong rate
could not be corrected later; PluginNode::prepareToPlay just asserted the
mismatch and carried on, leaving anything rate-dependent in the plugin
(delay lines, filters, oversampling) silently wrong in the render.

AudioNodeRenderJob::initialise now takes a builder function which is invoked
from createAndPrepareRenderContext, where the source has been rendered by any
preceding jobs, and is passed the sample rate the render will actually use.
All five effects that used the job build their Nodes from there, which also
fixes the WaveNode channel count being read from a not-yet-rendered source.

Added a jassert in PluginNode::initialisePlugin so an invalid rate or block
size is caught at the point the contract is broken.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.75758% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.42%. Comparing base (aa6301a) to head (d3d89f0).

Files with missing lines Patch % Lines
..._engine/model/clips/tracktion_ClipEffects.test.cpp 94.89% 5 Missing ⚠️
...ktion_engine/model/clips/tracktion_ClipEffects.cpp 96.92% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop     #432      +/-   ##
===========================================
+ Coverage    59.81%   60.42%   +0.61%     
===========================================
  Files          568      568              
  Lines        79964    80082     +118     
  Branches     12381    12381              
===========================================
+ Hits         47828    48390     +562     
+ Misses       32136    31692     -444     

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

The previous commit moved Node building for VolumeEffect, FadeInOutEffect,
StepVolumeEffect, PitchShiftEffect and PluginEffect into a builder that only
runs when the render runs, and only the PluginEffect path had a test. Adds a
test case that renders each of the other types through ClipEffects and checks
the proxy keeps the source's sample rate and channel count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@drowaudio
drowaudio merged commit ee5cf8b into develop Sep 21, 2026
39 checks passed
@drowaudio
drowaudio deleted the bugfix/issue_417_clip_effect_plugin_sample_rate branch September 21, 2026 16:42
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.

A PluginEffect clip effect prepares its plugin at a sample rate of ZERO

1 participant