From 090ef99bd6a0f6cd09705018bb26707bca5a66f2 Mon Sep 17 00:00:00 2001 From: mvanhoolwerff <253370791+mvanhoolwerff@users.noreply.github.com> Date: Thu, 7 May 2026 11:24:47 +0200 Subject: [PATCH] fix: use Hermite interpolation at loop boundaries to eliminate clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../core/sources/AudioBufferSourceNode.cpp | 26 ++++++++++++++----- .../common/cpp/audioapi/dsp/AudioUtils.hpp | 19 ++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp index 10b06e92f..ae49b8ad9 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.cpp @@ -220,18 +220,32 @@ void AudioBufferSourceNode::processWithInterpolation( while (framesLeft > 0) { auto readIndex = static_cast(vReadIndex_); - size_t nextReadIndex = readIndex + 1; auto factor = static_cast(vReadIndex_ - static_cast(readIndex)); - if (nextReadIndex >= frameEnd) { - nextReadIndex = loop_ ? frameStart : readIndex; - } - for (size_t i = 0; i < processingBuffer->getNumberOfChannels(); i++) { auto destination = processingBuffer->getChannel(i)->span(); const auto source = buffer_->getChannel(i)->span(); - destination[writeIndex] = dsp::linearInterpolate(source, readIndex, nextReadIndex, factor); + if (loop_) { + // Use Hermite 4-point interpolation with proper loop wrapping. + // 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 { + int64_t len = static_cast(frameEnd - frameStart); + int64_t rel = static_cast(idx) - static_cast(frameStart); + return static_cast(frameStart + ((rel % len) + len) % len); + }; + size_t idx0 = wrap(static_cast(readIndex) - 1); + size_t idx1 = wrap(static_cast(readIndex)); + size_t idx2 = wrap(static_cast(readIndex) + 1); + size_t idx3 = wrap(static_cast(readIndex) + 2); + destination[writeIndex] = dsp::hermiteInterpolate(source, idx0, idx1, idx2, idx3, factor); + } else { + size_t nextReadIndex = readIndex + 1; + if (nextReadIndex >= frameEnd) nextReadIndex = readIndex; + destination[writeIndex] = dsp::linearInterpolate(source, readIndex, nextReadIndex, factor); + } } writeIndex += 1; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/dsp/AudioUtils.hpp b/packages/react-native-audio-api/common/cpp/audioapi/dsp/AudioUtils.hpp index af2b27e12..49b472c8f 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/dsp/AudioUtils.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/dsp/AudioUtils.hpp @@ -28,6 +28,25 @@ namespace audioapi::dsp { return std::lerp(source[firstIndex], source[secondIndex], factor); } +// 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. +[[nodiscard]] inline float hermiteInterpolate( + std::span source, + size_t idx0, + size_t idx1, + size_t idx2, + size_t idx3, + float t) { + float y0 = source[idx0], y1 = source[idx1], y2 = source[idx2], y3 = source[idx3]; + float c0 = y1; + float c1 = 0.5f * (y2 - y0); + float c2 = y0 - 2.5f * y1 + 2.0f * y2 - 0.5f * y3; + float c3 = 0.5f * (y3 - y0) + 1.5f * (y1 - y2); + return ((c3 * t + c2) * t + c1) * t + c0; +} + [[nodiscard]] inline float linearToDecibels(float value) { constexpr float kDecibelsLinearFactor = 20.0f; return kDecibelsLinearFactor * log10f(value);