fix: use Hermite interpolation at loop boundaries to eliminate clicks#1055
Open
mvanhoolwerff wants to merge 1 commit into
Open
fix: use Hermite interpolation at loop boundaries to eliminate clicks#1055mvanhoolwerff wants to merge 1 commit into
mvanhoolwerff wants to merge 1 commit into
Conversation
When playbackRate != 1.0, AudioBufferSourceNode uses the interpolation path for sample playback. At the loop boundary, linear interpolation between the last and first frame creates an audible click because it only ensures value (C0) continuity — the slope can change abruptly. This replaces linear interpolation with 4-point Hermite spline interpolation for looping sources. Hermite ensures both value AND slope (C1) continuity at the loop point, eliminating the click artifact. The fix wraps sample indices correctly through the loop boundary so all 4 points are always valid. Non-looping sources continue to use linear interpolation (no behavior change). Reproducer: play any AudioBufferSourceNode with loop=true and playbackRate=0.8 or 1.2 — audible periodic click at each loop cycle. With this fix, the loop is smooth.
closetcaiman
requested changes
May 12, 2026
Contributor
closetcaiman
left a comment
There was a problem hiding this comment.
Hi @mvanhoolwerff, thank you for your PR.
A few things:
- please open an issue via the bug report form and add more detailed reproduction steps, particularly the set parameters for the node and the audio file you've used.
AudioBufferSourceNodeandQueueBufferSourceNodehave undergone a refactor (#1047) regarding the buffer processing, please integrate the latest changes and adjust your code.- you've implemented a fix for
AudioBufferSourceNode, howeverQueueBufferSourceNodeis also affected, so please handle this case too. - I've reviewed the current changes and noticed a few places for improvement, so please address them.
| // Linear interpolation creates audible clicks at loop boundaries | ||
| // because it only ensures C0 (value) continuity. Hermite ensures | ||
| // C1 (slope) continuity, eliminating the click. | ||
| auto wrap = [&](int64_t idx) -> size_t { |
Contributor
There was a problem hiding this comment.
It is not recommended to capture everything by ref [&]. Just use [frameStart, frameEnd].
| // because it only ensures C0 (value) continuity. Hermite ensures | ||
| // C1 (slope) continuity, eliminating the click. | ||
| auto wrap = [&](int64_t idx) -> size_t { | ||
| int64_t len = static_cast<int64_t>(frameEnd - frameStart); |
Contributor
There was a problem hiding this comment.
Suggested change
| int64_t len = static_cast<int64_t>(frameEnd - frameStart); | |
| auto len = static_cast<int64_t>(frameEnd - frameStart); |
| // C1 (slope) continuity, eliminating the click. | ||
| auto wrap = [&](int64_t idx) -> size_t { | ||
| int64_t len = static_cast<int64_t>(frameEnd - frameStart); | ||
| int64_t rel = static_cast<int64_t>(idx) - static_cast<int64_t>(frameStart); |
Contributor
There was a problem hiding this comment.
This cast is not necessary
Suggested change
| int64_t rel = static_cast<int64_t>(idx) - static_cast<int64_t>(frameStart); | |
| int64_t rel = idx - static_cast<int64_t>(frameStart); |
| destination[writeIndex] = dsp::hermiteInterpolate(source, idx0, idx1, idx2, idx3, factor); | ||
| } else { | ||
| size_t nextReadIndex = readIndex + 1; | ||
| if (nextReadIndex >= frameEnd) nextReadIndex = readIndex; |
Contributor
There was a problem hiding this comment.
Use curly braces for readability.
Comment on lines
+31
to
+34
| // Hermite 4-point interpolation for smooth looping. | ||
| // Unlike linear interpolation, Hermite matches both value AND slope at | ||
| // the interpolation point, eliminating audible clicks at loop boundaries | ||
| // when playbackRate != 1.0. |
Contributor
There was a problem hiding this comment.
Use doxygen style for docstrings. We follow /// convention.
| // C1 (slope) continuity, eliminating the click. | ||
| auto wrap = [&](int64_t idx) -> size_t { | ||
| int64_t len = static_cast<int64_t>(frameEnd - frameStart); | ||
| int64_t rel = static_cast<int64_t>(idx) - static_cast<int64_t>(frameStart); |
Contributor
There was a problem hiding this comment.
This implicates forward only playback. Please handle reverse playback case too (when playbackRate < 0).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When
playbackRate != 1.0,AudioBufferSourceNodeuses the interpolation path for sample playback. At the loop boundary, linear interpolation between the last and first frame creates an audible click because it only ensures value (C0) continuity — the slope can change abruptly.This replaces linear interpolation with 4-point Hermite spline interpolation for looping sources. Hermite ensures both value AND slope (C1) continuity at the loop point, eliminating the click artifact.
Changes
AudioUtils.hpp: AddedhermiteInterpolate()function using cubic Hermite spline with 4 sample pointsAudioBufferSourceNode.cpp:processWithInterpolation()now uses Hermite interpolation with proper loop-wrapping for looping sources. Non-looping sources continue to use linear interpolation (no behavior change).Reproducer
AudioBufferSourceNodewithloop = trueplaybackRateto anything other than1.0(e.g.,0.8or1.2)With this fix, the loop is smooth at any playback rate.
Why Hermite?
At the loop boundary, the interpolation reads
source[lastFrame]andsource[firstFrame]. Even if these values are identical, the slope (derivative) at the boundary can jump — the waveform might be going down at the end and up at the start. Linear interpolation produces a sharp "kink" that's audible as a click.Hermite uses 4 surrounding samples to estimate the slope, producing a smooth curve through the boundary. The wrap function ensures all 4 indices are valid across the loop point.
Test plan
loop=trueandplaybackRate=0.8loop=trueandplaybackRate=1.2playbackRate=1.0still uses the cleanprocessWithoutInterpolationpath🤖 Generated with Claude Code