Skip to content

fix: use Hermite interpolation at loop boundaries to eliminate clicks#1055

Open
mvanhoolwerff wants to merge 1 commit into
software-mansion:mainfrom
mvanhoolwerff:fix/hermite-loop-interpolation
Open

fix: use Hermite interpolation at loop boundaries to eliminate clicks#1055
mvanhoolwerff wants to merge 1 commit into
software-mansion:mainfrom
mvanhoolwerff:fix/hermite-loop-interpolation

Conversation

@mvanhoolwerff
Copy link
Copy Markdown

Summary

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.

Changes

  • AudioUtils.hpp: Added hermiteInterpolate() function using cubic Hermite spline with 4 sample points
  • AudioBufferSourceNode.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

  1. Create an AudioBufferSourceNode with loop = true
  2. Set playbackRate to anything other than 1.0 (e.g., 0.8 or 1.2)
  3. Listen for a periodic click at each loop cycle

With this fix, the loop is smooth at any playback rate.

Why Hermite?

At the loop boundary, the interpolation reads source[lastFrame] and source[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

  • Verify no audible clicks with loop=true and playbackRate=0.8
  • Verify no audible clicks with loop=true and playbackRate=1.2
  • Verify playbackRate=1.0 still uses the clean processWithoutInterpolation path
  • Verify non-looping playback is unchanged

🤖 Generated with Claude Code

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.
@mdydek mdydek added the fix label May 7, 2026
Copy link
Copy Markdown
Contributor

@closetcaiman closetcaiman left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
  • AudioBufferSourceNode and QueueBufferSourceNode have undergone a refactor (#1047) regarding the buffer processing, please integrate the latest changes and adjust your code.
  • you've implemented a fix for AudioBufferSourceNode, however QueueBufferSourceNode is 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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This implicates forward only playback. Please handle reverse playback case too (when playbackRate < 0).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants