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);