From 7607cf36e6be1849900e61ce50ea324a4b757252 Mon Sep 17 00:00:00 2001 From: WentTheFox Date: Thu, 10 Sep 2026 12:36:38 +0200 Subject: [PATCH 1/2] fix(android/ios): cache AudioBuffer copy instead of recopying on every setBuffer AudioBufferSourceNodeHostObject::setBuffer() deep-copied the entire AudioBuffer on every `.buffer = x` reassignment, even when reassigning the exact same underlying buffer. That's exactly what seeking is. A live AudioBufferSourceNode can't be repositioned, so the standard pattern is to stop/disconnect it and create a fresh source node wrapping the already-decoded buffer at a new offset. Every seek therefore triggered a full, unnecessary copy of the whole track's PCM data. On a 4-minute stereo buffer this is a real full-size copy (~85MB) per seek. The old copy isn't freed until its replacement's scheduled audio event actually runs, so repeated rapid seeking accumulates full-size buffers faster than the audio thread can free the previous ones. The growing native heap eventually crashes the app via a Hermes GC OOM once it can no longer find room to grow the JS heap. This caches the defensive copy on the JS-visible AudioBufferHostObject and reuses it across repeated reassignments of the same buffer. The cache is invalidated only when the buffer's data could actually have been mutated: copyToChannel, or a live getChannelData() view having escaped to JS, since JS could write through that view at any later time. This keeps the exact copy-on-first-touch semantics the original code needed for pitch-correction and mutation safety, while making the "same buffer, many source nodes" pattern free after the first copy instead of paying for a fresh copy on every single reassignment. The caching logic lives in a new, standalone ImmutableBufferCache utility rather than directly in AudioBufferHostObject, because HostObjects/*.cpp is excluded from this project's C++ test suite (see common/cpp/test/CMakeLists.txt) and a plain utility class can be unit tested without a jsi::Runtime. Also documents this in the best-practices guide. The existing "reuse the same AudioBuffer across nodes" guidance was already correct, but silently expensive before this fix; it is now actually cheap as advertised. Verified: - The library's own C++ test suite still passes in full, plus 4 new unit tests for ImmutableBufferCache covering reuse, distinct-copy identity, and both invalidation paths (420/420 total). - A minimal repro app (creating a fresh AudioBufferSourceNode/GainNode pair wrapping the same AudioBuffer every ~150ms, simulating rapid seeking) went from ~71MB leaked per seek (visibly crashing within ~60 iterations) to no measurable per-seek growth across three consecutive 60-iteration runs, measured via `adb shell dumpsys meminfo` before/after/+30s-settled. - A real app using this pattern for playback seeking held native heap flat (Android, Samsung Galaxy S24+) across ~200 rapid seeks that previously crashed within a similar span. Fixes #1263 --- .../docs/fundamentals/best-practices.mdx | 3 + .../sources/AudioBufferHostObject.cpp | 10 +++ .../sources/AudioBufferHostObject.h | 17 ++++ .../AudioBufferSourceNodeHostObject.cpp | 12 ++- .../sources/AudioBufferSourceNodeHostObject.h | 4 +- .../audioapi/utils/ImmutableBufferCache.hpp | 59 ++++++++++++++ .../src/utils/ImmutableBufferCacheTest.cpp | 81 +++++++++++++++++++ 7 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/utils/ImmutableBufferCache.hpp create mode 100644 packages/react-native-audio-api/common/cpp/test/src/utils/ImmutableBufferCacheTest.cpp diff --git a/packages/audiodocs/docs/fundamentals/best-practices.mdx b/packages/audiodocs/docs/fundamentals/best-practices.mdx index 273c64896..fb9eda409 100644 --- a/packages/audiodocs/docs/fundamentals/best-practices.mdx +++ b/packages/audiodocs/docs/fundamentals/best-practices.mdx @@ -53,6 +53,8 @@ user experience, and maintainability. Here are some key best practices to consid - **Scheduled source nodes are single-use**: [`AudioBufferSourceNode`](../sources/audio-buffer-source-node.mdx), [`OscillatorNode`](../sources/oscillator-node.mdx), and other [`AudioScheduledSourceNode`](../sources/audio-scheduled-source-node.mdx) subclasses can be [`start()`](../sources/audio-scheduled-source-node.mdx#start)ed only once. Create a new node to replay a sound, but reuse the same [`AudioBuffer`](../sources/audio-buffer.mdx) — nodes are inexpensive to create. +- **Seeking**: since a started `AudioBufferSourceNode` can't be repositioned, seeking means stopping/disconnecting it and creating a fresh node with the same `AudioBuffer` at a new `start()` offset. Reassigning the same buffer this way is cheap. The underlying PCM data is copied once per `AudioBuffer`, not once per node, so recreating the source node on every seek doesn't re-copy it. + - **Use [`AudioBufferQueueSourceNode`](../sources/audio-buffer-queue-source-node.mdx) for chunked playback**: When audio arrives in segments (streaming TTS, progressive download), enqueue buffers into a queue source node rather than recreating the entire graph per chunk. ## [**AudioParam**](../core/audio-param.mdx) changes @@ -87,4 +89,5 @@ Prefer logging plain values instead: ```tsx console.log({ channelCount: node.channelCount, numberOfInputs: node.numberOfInputs }); ``` + ::: diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.cpp index a2276c050..544af91c7 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.cpp @@ -43,6 +43,13 @@ JSI_PROPERTY_GETTER_IMPL(AudioBufferHostObject, numberOfChannels) { } JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, getChannelData) { + // The returned Float32Array is a live, JS-writable view straight into + // audioBuffer_'s storage, and the caller can hold onto it indefinitely. + // Any cached copy handed to a source node from here on can no longer be + // trusted to stay in sync, so stop caching for the rest of this buffer's + // lifetime. + immutableCopyCache_.markLiveViewEscaped(); + auto channel = static_cast(args[0].getNumber()); auto audioArrayBuffer = audioBuffer_->getSharedChannel(channel); auto arrayBuffer = jsi::ArrayBuffer(runtime, audioArrayBuffer); @@ -76,6 +83,9 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, copyFromChannel) { } JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, copyToChannel) { + // Mutates audioBuffer_ in place, so any previously cached copy is now stale. + immutableCopyCache_.invalidate(); + auto arrayBuffer = args[0].getObject(runtime).getPropertyAsObject(runtime, "buffer").getArrayBuffer(runtime); auto *source = reinterpret_cast(arrayBuffer.data(runtime)); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.h index 04a463fb0..b7b552b86 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -34,6 +35,19 @@ class AudioBufferHostObject : public HostObject { return audioBuffer_->getSize() * audioBuffer_->getNumberOfChannels() * sizeof(float) * 2; } + /// @brief Returns a defensive copy of `audioBuffer_` suitable for handing to an + /// `AudioBufferSourceNode`, reusing a cached copy across repeated `.buffer = x` + /// reassignments of this same JS-visible buffer (e.g. seeking, which recreates the + /// source node but keeps reusing the already-decoded buffer). Without this, every + /// reassignment allocated a brand-new full-size copy, which is where + /// https://github.com/software-mansion/react-native-audio-api/issues/1263 came from. + /// @note The cache is invalidated whenever the buffer's data could have been mutated + /// from JS (`copyToChannel`, or ever having handed out a live `getChannelData` view), + /// since a cached copy must never be shared while its source can still be written to. + [[nodiscard]] std::shared_ptr getOrCreateImmutableCopy() { + return immutableCopyCache_.getOrCreate(audioBuffer_); + } + JSI_PROPERTY_GETTER_DECL(sampleRate); JSI_PROPERTY_GETTER_DECL(length); JSI_PROPERTY_GETTER_DECL(duration); @@ -42,5 +56,8 @@ class AudioBufferHostObject : public HostObject { JSI_HOST_FUNCTION_DECL(getChannelData); JSI_HOST_FUNCTION_DECL(copyFromChannel); JSI_HOST_FUNCTION_DECL(copyToChannel); + + private: + utils::ImmutableBufferCache immutableCopyCache_; }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp index 4bd5826c6..26d7980a5 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp @@ -147,13 +147,15 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferSourceNodeHostObject, setBuffer) { thisValue.asObject(runtime).setExternalMemoryPressure( runtime, getMemoryPressure() + bufferHostObject->getSizeInBytes()); - setBuffer(bufferHostObject->audioBuffer_); + setBuffer(bufferHostObject->audioBuffer_, bufferHostObject); } return jsi::Value::undefined(); } -void AudioBufferSourceNodeHostObject::setBuffer(const std::shared_ptr &buffer) { +void AudioBufferSourceNodeHostObject::setBuffer( + const std::shared_ptr &buffer, + const std::shared_ptr &bufferHostObject) { // TODO: add optimized memory management for buffer changes, e.g. // when the same buffer is reused across threads and // buffer modification is not allowed on JS thread @@ -180,6 +182,12 @@ void AudioBufferSourceNodeHostObject::setBuffer(const std::shared_ptrgetNumberOfChannels(), buffer->getSampleRate()); copiedBuffer->copy(*buffer, 0, 0, buffer->getSize()); copiedBuffer->zero(buffer->getSize(), extraTailFrames); + } else if (bufferHostObject != nullptr) { + // Reuse a cached copy across repeated `.buffer = x` reassignments of the same + // JS-visible buffer (e.g. seeking, which recreates the source node but keeps + // reusing the already-decoded buffer) instead of deep-copying every time. + // See https://github.com/software-mansion/react-native-audio-api/issues/1263. + copiedBuffer = bufferHostObject->getOrCreateImmutableCopy(); } else { copiedBuffer = std::make_shared(*buffer); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.h index b3b480576..caacab3b1 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.h @@ -51,7 +51,9 @@ class AudioBufferSourceNodeHostObject : public AudioBufferBaseSourceNodeHostObje double loopStart_; double loopEnd_; - void setBuffer(const std::shared_ptr &buffer); + void setBuffer( + const std::shared_ptr &buffer, + const std::shared_ptr &bufferHostObject = nullptr); }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/ImmutableBufferCache.hpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/ImmutableBufferCache.hpp new file mode 100644 index 000000000..d9a8139ae --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/ImmutableBufferCache.hpp @@ -0,0 +1,59 @@ +#pragma once + +#include + +#include + +namespace audioapi::utils { + +/// @brief Caches a defensive copy of an `AudioBuffer` so repeated requests for +/// "an immutable copy suitable for handing to a source node" reuse the same +/// copy instead of allocating a fresh one every time. Used by +/// `AudioBufferHostObject` to avoid deep-copying the entire buffer on every +/// `.buffer = x` reassignment of the same underlying buffer. That reassignment +/// is what seeking requires, since a live `AudioBufferSourceNode` can't be +/// repositioned. See https://github.com/software-mansion/react-native-audio-api/issues/1263. +/// @note The two invalidation methods must be called whenever the source +/// buffer's data could have been mutated from JS. This class has no way to +/// observe that on its own. +class ImmutableBufferCache { + public: + /// @brief Returns a defensive copy of `source`, reusing the last copy + /// produced as long as neither `invalidate()` nor `markLiveViewEscaped()` + /// has been called since. + [[nodiscard]] std::shared_ptr getOrCreate( + const std::shared_ptr &source) { + if (liveViewEscaped_) { + // A live, JS-writable view into source's data has escaped. A write + // through it could happen at any later time, so we can no longer prove + // a cached copy won't go stale. Fall back to copying every time. + return std::make_shared(*source); + } + + if (cached_ == nullptr) { + cached_ = std::make_shared(*source); + } + return cached_; + } + + /// @brief Call when the source buffer's data has just been mutated in place + /// (e.g. `copyToChannel`). The current cached copy is now stale. + void invalidate() { + cached_ = nullptr; + } + + /// @brief Call when a live, JS-writable view into the source buffer's data + /// has been handed out (e.g. `getChannelData`). This permanently stops + /// caching, since the view can be written through at any later time, not + /// just at the moment it was retrieved. + void markLiveViewEscaped() { + liveViewEscaped_ = true; + cached_ = nullptr; + } + + private: + std::shared_ptr cached_; + bool liveViewEscaped_ = false; +}; + +} // namespace audioapi::utils diff --git a/packages/react-native-audio-api/common/cpp/test/src/utils/ImmutableBufferCacheTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/utils/ImmutableBufferCacheTest.cpp new file mode 100644 index 000000000..e9c8429cc --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/utils/ImmutableBufferCacheTest.cpp @@ -0,0 +1,81 @@ +#include +#include +#include +#include + +using namespace audioapi; +using namespace audioapi::utils; + +// NOLINTBEGIN + +namespace { + +constexpr size_t FRAME_COUNT = 1024; +constexpr int CHANNELS = 2; +constexpr float SAMPLE_RATE = 44100.0f; + +std::shared_ptr makeBuffer() { + return std::make_shared(FRAME_COUNT, CHANNELS, SAMPLE_RATE); +} + +} // namespace + +// The core of https://github.com/software-mansion/react-native-audio-api/issues/1263: +// repeated `.buffer = x` reassignment of the same underlying buffer (the seek pattern) +// must not allocate a fresh full-size copy every time. +TEST(ImmutableBufferCacheTest, ReusesCachedCopyAcrossRepeatedCalls) { + ImmutableBufferCache cache; + auto source = makeBuffer(); + + auto first = cache.getOrCreate(source); + auto second = cache.getOrCreate(source); + auto third = cache.getOrCreate(source); + + EXPECT_EQ(first, second) << "Second call should reuse the cached copy, not allocate a new one."; + EXPECT_EQ(second, third) << "Third call should reuse the same cached copy as well."; +} + +TEST(ImmutableBufferCacheTest, CachedCopyIsAnActualCopyNotTheOriginal) { + ImmutableBufferCache cache; + auto source = makeBuffer(); + + auto copy = cache.getOrCreate(source); + + EXPECT_NE(copy, source) << "The cached copy must be a distinct AudioBuffer instance. Sharing " + "the original directly would let a future mutation of it race with " + "a node concurrently reading the \"copy\"."; +} + +TEST(ImmutableBufferCacheTest, InvalidateForcesAFreshCopyOnce) { + ImmutableBufferCache cache; + auto source = makeBuffer(); + + auto first = cache.getOrCreate(source); + cache.invalidate(); + auto second = cache.getOrCreate(source); + auto third = cache.getOrCreate(source); + + EXPECT_NE(first, second) << "invalidate() means the source was just mutated in place, so the " + "previously cached copy is stale and must not be reused."; + EXPECT_EQ(second, third) << "After producing one fresh copy, subsequent calls should resume " + "caching normally rather than copying every time."; +} + +TEST(ImmutableBufferCacheTest, MarkLiveViewEscapedDisablesCachingPermanently) { + ImmutableBufferCache cache; + auto source = makeBuffer(); + + auto first = cache.getOrCreate(source); + cache.markLiveViewEscaped(); + auto second = cache.getOrCreate(source); + auto third = cache.getOrCreate(source); + + EXPECT_NE(first, second) << "A live, JS-writable view escaped, so the pre-existing cache entry " + "must be dropped since we can no longer prove it stayed in sync."; + EXPECT_NE(second, third) + << "Once a live view has ever escaped, every future call must fall back to a fresh, " + "uncached copy indefinitely. A write through that view could happen at any later " + "time, not just at the moment it was retrieved."; +} + +// NOLINTEND From 00f51c5f4fe65144ee23f78c5f456b71b692f288 Mon Sep 17 00:00:00 2001 From: michal Date: Fri, 11 Sep 2026 11:57:39 +0200 Subject: [PATCH 2/2] feat: acquire the content in absn --- .claude/skills/host-objects/SKILL.md | 2 + .../sources/AudioBufferHostObject.cpp | 51 ++++++++-- .../sources/AudioBufferHostObject.h | 33 ++++++- .../AudioBufferSourceNodeHostObject.cpp | 99 ++++++++++++------- .../sources/AudioBufferSourceNodeHostObject.h | 20 ++++ .../core/sources/AudioBufferSourceNode.cpp | 38 ++++--- .../core/sources/AudioBufferSourceNode.h | 16 +++ .../common/cpp/audioapi/utils/AudioBuffer.hpp | 8 ++ .../cpp/audioapi/utils/ImmutableBufferCache.h | 44 +++++++++ .../audioapi/utils/ImmutableBufferCache.hpp | 59 ----------- .../cpp/test/src/utils/AudioBufferTest.cpp | 21 ++++ .../src/utils/ImmutableBufferCacheTest.cpp | 19 +--- .../src/core/AudioBuffer.ts | 12 +++ .../src/core/AudioBufferSourceNode.ts | 9 ++ 14 files changed, 296 insertions(+), 135 deletions(-) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/utils/ImmutableBufferCache.h delete mode 100644 packages/react-native-audio-api/common/cpp/audioapi/utils/ImmutableBufferCache.hpp diff --git a/.claude/skills/host-objects/SKILL.md b/.claude/skills/host-objects/SKILL.md index 27d27bd66..7b56606f4 100644 --- a/.claude/skills/host-objects/SKILL.md +++ b/.claude/skills/host-objects/SKILL.md @@ -353,6 +353,8 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, getChannelData) { } ``` +The view aliases native memory for as long as JS keeps it, and the `shared_ptr` inside the `jsi::ArrayBuffer` keeps that memory alive on its own. When the native side later needs the view to stop aliasing (Web Audio's "acquire the content" on `AudioBufferSourceNode.start()`), it must retain the returned object and neutralise it afterwards — see `detachReturnedChannelData` in the real `AudioBufferHostObject`. + ### External memory pressure Call `setExternalMemoryPressure` whenever returning a HostObject or typed array that wraps a large native buffer. This lets the JS GC schedule collection correctly: diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.cpp index 544af91c7..787838d37 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.cpp @@ -6,6 +6,7 @@ #include #include #include +#include namespace audioapi { @@ -24,7 +25,43 @@ AudioBufferHostObject::AudioBufferHostObject(const std::shared_ptr } AudioBufferHostObject::AudioBufferHostObject(AudioBufferHostObject &&other) noexcept - : HostObject(std::move(other)), audioBuffer_(std::move(other.audioBuffer_)) {} + : HostObject(std::move(other)), + audioBuffer_(std::move(other.audioBuffer_)), + immutableCopyCache_(std::move(other.immutableCopyCache_)), + returnedChannelDataArrays_(std::move(other.returnedChannelDataArrays_)) {} + +void AudioBufferHostObject::detachReturnedChannelData(jsi::Runtime &runtime) { + if (returnedChannelDataArrays_.empty()) { + return; + } + + auto defineProperty = runtime.global() + .getPropertyAsObject(runtime, "Object") + .getPropertyAsFunction(runtime, "defineProperty"); + auto zeroDescriptor = jsi::Object(runtime); + zeroDescriptor.setProperty(runtime, "value", 0); + + std::vector channelDetached(audioBuffer_->getNumberOfChannels(), false); + + for (const auto &returned : returnedChannelDataArrays_) { + auto array = returned.array.lock(runtime); + if (array.isObject()) { + for (const auto *sizeProperty : {"length", "byteLength", "byteOffset"}) { + defineProperty.call(runtime, array, sizeProperty, zeroDescriptor); + } + } + + if (!channelDetached[returned.channel]) { + audioBuffer_->detachSharedChannel(returned.channel); + channelDetached[returned.channel] = true; + } + } + + returnedChannelDataArrays_.clear(); + // A view could have been written through right up until now, i.e. after the cached + // copy was taken. + immutableCopyCache_.invalidate(); +} JSI_PROPERTY_GETTER_IMPL(AudioBufferHostObject, sampleRate) { return {audioBuffer_->getSampleRate()}; @@ -44,13 +81,11 @@ JSI_PROPERTY_GETTER_IMPL(AudioBufferHostObject, numberOfChannels) { JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, getChannelData) { // The returned Float32Array is a live, JS-writable view straight into - // audioBuffer_'s storage, and the caller can hold onto it indefinitely. - // Any cached copy handed to a source node from here on can no longer be - // trusted to stay in sync, so stop caching for the rest of this buffer's - // lifetime. - immutableCopyCache_.markLiveViewEscaped(); + // audioBuffer_'s storage, so a copy cached before now can no longer be trusted. + // Caching resumes once the view is neutralised by detachReturnedChannelData(). + immutableCopyCache_.invalidate(); - auto channel = static_cast(args[0].getNumber()); + auto channel = static_cast(args[0].getNumber()); auto audioArrayBuffer = audioBuffer_->getSharedChannel(channel); auto arrayBuffer = jsi::ArrayBuffer(runtime, audioArrayBuffer); @@ -58,6 +93,8 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, getChannelData) { auto float32Array = float32ArrayCtor.callAsConstructor(runtime, arrayBuffer).getObject(runtime); float32Array.setExternalMemoryPressure(runtime, audioArrayBuffer->size()); + returnedChannelDataArrays_.push_back( + {.channel = channel, .array = jsi::WeakObject(runtime, float32Array)}); return float32Array; } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.h index b7b552b86..bcaaa36b2 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferHostObject.h @@ -2,12 +2,13 @@ #include #include -#include +#include #include #include #include #include +#include namespace audioapi { using namespace facebook; @@ -24,6 +25,8 @@ class AudioBufferHostObject : public HostObject { if (this != &other) { HostObject::operator=(std::move(other)); audioBuffer_ = std::move(other.audioBuffer_); + immutableCopyCache_ = std::move(other.immutableCopyCache_); + returnedChannelDataArrays_ = std::move(other.returnedChannelDataArrays_); } return *this; } @@ -41,13 +44,27 @@ class AudioBufferHostObject : public HostObject { /// source node but keeps reusing the already-decoded buffer). Without this, every /// reassignment allocated a brand-new full-size copy, which is where /// https://github.com/software-mansion/react-native-audio-api/issues/1263 came from. - /// @note The cache is invalidated whenever the buffer's data could have been mutated - /// from JS (`copyToChannel`, or ever having handed out a live `getChannelData` view), - /// since a cached copy must never be shared while its source can still be written to. + /// @note The cache is dropped whenever `audioBuffer_` may have diverged from it: + /// `copyToChannel` mutates in place, `getChannelData` hands out a live JS-writable + /// view, and `detachReturnedChannelData` is the last moment such a view could have + /// been written through. [[nodiscard]] std::shared_ptr getOrCreateImmutableCopy() { return immutableCopyCache_.getOrCreate(audioBuffer_); } + /// @brief Web Audio's "acquire the content" step for the views handed out by + /// `getChannelData`. Call once playback of this buffer has been scheduled. Every + /// previously returned Float32Array stops aliasing `audioBuffer_` and, if JS still + /// holds it, reads as zero-length; the next `getChannelData` call hands out a fresh + /// view, mirroring what a browser does when it detaches those ArrayBuffers. + void detachReturnedChannelData(jsi::Runtime &runtime); + + /// @brief Whether any `getChannelData` view is live, i.e. handed out since the last + /// `detachReturnedChannelData`. + [[nodiscard]] bool hasReturnedChannelData() const { + return !returnedChannelDataArrays_.empty(); + } + JSI_PROPERTY_GETTER_DECL(sampleRate); JSI_PROPERTY_GETTER_DECL(length); JSI_PROPERTY_GETTER_DECL(duration); @@ -58,6 +75,14 @@ class AudioBufferHostObject : public HostObject { JSI_HOST_FUNCTION_DECL(copyToChannel); private: + struct ReturnedChannelDataArray { + size_t channel; + jsi::WeakObject array; + }; + utils::ImmutableBufferCache immutableCopyCache_; + /// Float32Array views handed out by `getChannelData` since the last + /// `detachReturnedChannelData`, kept so they can be neutralised then. + std::vector returnedChannelDataArrays_; }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp index 26d7980a5..eb2de3581 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.cpp @@ -126,6 +126,9 @@ JSI_PROPERTY_SETTER_IMPL(AudioBufferSourceNodeHostObject, onLoopEnded) { } JSI_HOST_FUNCTION_IMPL(AudioBufferSourceNodeHostObject, start) { + hasBeenStarted_ = true; + acquireBufferContent(runtime); + auto handle = node_->handle; auto event = [handle, node = audioBufferSourceNode_, @@ -139,6 +142,22 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferSourceNodeHostObject, start) { return jsi::Value::undefined(); } +void AudioBufferSourceNodeHostObject::acquireBufferContent(jsi::Runtime &runtime) { + if (bufferHostObject_ == nullptr || !bufferHostObject_->hasReturnedChannelData()) { + return; + } + + bufferHostObject_->detachReturnedChannelData(runtime); + // The copy handed to the node in setBuffer() predates any writes made through those + // views since, so hand it the post-write content that has now been fenced off. + auto buffers = prepareNodeBuffers(bufferHostObject_->audioBuffer_, bufferHostObject_); + auto event = + [handle = node_->handle, node = audioBufferSourceNode_, buffers](BaseAudioContext &) { + node->replaceBufferContent(buffers.copiedBuffer, buffers.audioBuffer); + }; + audioBufferSourceNode_->scheduleAudioEvent(std::move(event)); +} + JSI_HOST_FUNCTION_IMPL(AudioBufferSourceNodeHostObject, setBuffer) { if (args[0].isNull()) { setBuffer(nullptr); @@ -150,61 +169,73 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferSourceNodeHostObject, setBuffer) { setBuffer(bufferHostObject->audioBuffer_, bufferHostObject); } + // Per Web Audio, assigning a buffer to an already-started source acquires its + // content right away, because start() had nothing to acquire back then. + if (hasBeenStarted_) { + acquireBufferContent(runtime); + } + return jsi::Value::undefined(); } -void AudioBufferSourceNodeHostObject::setBuffer( +AudioBufferSourceNodeHostObject::NodeBuffers AudioBufferSourceNodeHostObject::prepareNodeBuffers( const std::shared_ptr &buffer, const std::shared_ptr &bufferHostObject) { // TODO: add optimized memory management for buffer changes, e.g. // when the same buffer is reused across threads and // buffer modification is not allowed on JS thread - auto handle = node_->handle; - - std::shared_ptr copiedBuffer; - std::shared_ptr audioBuffer; - const size_t newChannelCount = buffer == nullptr ? AudioBufferSourceOptions::kDefaultChannelCount - : buffer->getNumberOfChannels(); + NodeBuffers buffers; if (buffer == nullptr) { - copiedBuffer = nullptr; - audioBuffer = std::make_shared( + buffers.copiedBuffer = nullptr; + buffers.audioBuffer = std::make_shared( RENDER_QUANTUM_SIZE, AudioBufferSourceOptions::kDefaultChannelCount, audioBufferSourceNode_->getContextSampleRate()); + return buffers; + } + + if (pitchCorrection_) { + initStretch(static_cast(buffer->getNumberOfChannels()), buffer->getSampleRate()); + auto extraTailFrames = + static_cast((inputLatency_ + outputLatency_) * buffer->getSampleRate()); + size_t totalSize = buffer->getSize() + extraTailFrames; + buffers.copiedBuffer = std::make_shared( + totalSize, buffer->getNumberOfChannels(), buffer->getSampleRate()); + buffers.copiedBuffer->copy(*buffer, 0, 0, buffer->getSize()); + buffers.copiedBuffer->zero(buffer->getSize(), extraTailFrames); + } else if (bufferHostObject != nullptr) { + // Reuse a cached copy across repeated `.buffer = x` reassignments of the same + // JS-visible buffer (e.g. seeking, which recreates the source node but keeps + // reusing the already-decoded buffer) instead of deep-copying every time. + // See https://github.com/software-mansion/react-native-audio-api/issues/1263. + buffers.copiedBuffer = bufferHostObject->getOrCreateImmutableCopy(); } else { - if (pitchCorrection_) { - initStretch(static_cast(buffer->getNumberOfChannels()), buffer->getSampleRate()); - auto extraTailFrames = - static_cast((inputLatency_ + outputLatency_) * buffer->getSampleRate()); - size_t totalSize = buffer->getSize() + extraTailFrames; - copiedBuffer = std::make_shared( - totalSize, buffer->getNumberOfChannels(), buffer->getSampleRate()); - copiedBuffer->copy(*buffer, 0, 0, buffer->getSize()); - copiedBuffer->zero(buffer->getSize(), extraTailFrames); - } else if (bufferHostObject != nullptr) { - // Reuse a cached copy across repeated `.buffer = x` reassignments of the same - // JS-visible buffer (e.g. seeking, which recreates the source node but keeps - // reusing the already-decoded buffer) instead of deep-copying every time. - // See https://github.com/software-mansion/react-native-audio-api/issues/1263. - copiedBuffer = bufferHostObject->getOrCreateImmutableCopy(); - } else { - copiedBuffer = std::make_shared(*buffer); - } - - audioBuffer = std::make_shared( - RENDER_QUANTUM_SIZE, - copiedBuffer->getNumberOfChannels(), - audioBufferSourceNode_->getContextSampleRate()); + buffers.copiedBuffer = std::make_shared(*buffer); } + buffers.audioBuffer = std::make_shared( + RENDER_QUANTUM_SIZE, + buffers.copiedBuffer->getNumberOfChannels(), + audioBufferSourceNode_->getContextSampleRate()); + return buffers; +} + +void AudioBufferSourceNodeHostObject::setBuffer( + const std::shared_ptr &buffer, + const std::shared_ptr &bufferHostObject) { + bufferHostObject_ = bufferHostObject; + auto buffers = prepareNodeBuffers(buffer, bufferHostObject); + // Update channelCount on the host thread before renegotiation so MAX / // CLAMPED_MAX downstream nodes see the new width immediately. + const size_t newChannelCount = buffer == nullptr ? AudioBufferSourceOptions::kDefaultChannelCount + : buffer->getNumberOfChannels(); updateChannelCount(newChannelCount); auto event = - [handle, node = audioBufferSourceNode_, copiedBuffer, audioBuffer](BaseAudioContext &) { - node->setBuffer(copiedBuffer, audioBuffer); + [handle = node_->handle, node = audioBufferSourceNode_, buffers](BaseAudioContext &) { + node->setBuffer(buffers.copiedBuffer, buffers.audioBuffer); }; audioBufferSourceNode_->scheduleAudioEvent(std::move(event)); } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.h index caacab3b1..a4d2fec5f 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/sources/AudioBufferSourceNodeHostObject.h @@ -51,9 +51,29 @@ class AudioBufferSourceNodeHostObject : public AudioBufferBaseSourceNodeHostObje double loopStart_; double loopEnd_; + /// The JS-visible buffer behind the last `setBuffer`, kept so the "acquire the + /// content" step can run on it. Null when the buffer came from options or was cleared. + std::shared_ptr bufferHostObject_; + bool hasBeenStarted_ = false; + + struct NodeBuffers { + std::shared_ptr copiedBuffer; + std::shared_ptr audioBuffer; + }; + + NodeBuffers prepareNodeBuffers( + const std::shared_ptr &buffer, + const std::shared_ptr &bufferHostObject); + void setBuffer( const std::shared_ptr &buffer, const std::shared_ptr &bufferHostObject = nullptr); + + /// Web Audio's "acquire the content" step: runs on start() when a buffer is set, and on + /// setBuffer() once already started. Cuts off every live getChannelData() view and + /// re-hands the node the fenced-off content. + /// https://webaudio.github.io/web-audio-api/#acquire-the-content + void acquireBufferContent(jsi::Runtime &runtime); }; } // namespace audioapi 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 24dbf20f6..978eeeb21 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 @@ -55,10 +55,33 @@ void AudioBufferSourceNode::setLoopEnd(double loopEnd) { void AudioBufferSourceNode::setBuffer( const std::shared_ptr &buffer, const std::shared_ptr &audioBuffer) { + if (!swapBuffers(buffer, audioBuffer)) { + return; + } + + if (buffer_ == nullptr) { + loopEnd_ = 0; + channelCount_ = AudioBufferSourceOptions::kDefaultChannelCount; + return; + } + + channelCount_ = static_cast(buffer_->getNumberOfChannels()); + loopEnd_ = buffer_->getDuration(); +} + +void AudioBufferSourceNode::replaceBufferContent( + const std::shared_ptr &buffer, + const std::shared_ptr &audioBuffer) { + swapBuffers(buffer, audioBuffer); +} + +bool AudioBufferSourceNode::swapBuffers( + const std::shared_ptr &buffer, + const std::shared_ptr &audioBuffer) { std::shared_ptr context = context_.lock(); if (context == nullptr) { - return; + return false; } if (buffer_ != nullptr) { @@ -69,21 +92,10 @@ void AudioBufferSourceNode::setBuffer( context->getDisposer()->dispose(std::move(audioBuffer_)); } - if (buffer == nullptr) { - loopEnd_ = 0; - channelCount_ = AudioBufferSourceOptions::kDefaultChannelCount; - - buffer_ = nullptr; - processor_->setBuffer(nullptr); - audioBuffer_ = audioBuffer; - return; - } - buffer_ = buffer; audioBuffer_ = audioBuffer; - channelCount_ = static_cast(buffer_->getNumberOfChannels()); - loopEnd_ = buffer_->getDuration(); processor_->setBuffer(buffer_); + return true; } void AudioBufferSourceNode::start(double when, double offset, double duration) { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.h index 37ae6c803..380c853cd 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/sources/AudioBufferSourceNode.h @@ -29,12 +29,22 @@ class AudioBufferSourceNode : public AudioBufferBaseSourceNode { /// @note Audio Thread only void setLoopEnd(double loopEnd); + [[nodiscard]] double getLoopEnd() const { + return loopEnd_; + } /// @note Audio Thread only void setBuffer( const std::shared_ptr &buffer, const std::shared_ptr &audioBuffer); + /// @brief Swaps in a buffer holding the same frames, channels and sample rate as the + /// current one, keeping loop bounds and channel count untouched. This is the "acquire + /// the content" refresh: the samples may have changed since setBuffer(), the shape has not. + void replaceBufferContent( + const std::shared_ptr &buffer, + const std::shared_ptr &audioBuffer); + using AudioScheduledSourceNode::start; /// @note Audio Thread only void start(double when, double offset, double duration = -1); @@ -73,6 +83,12 @@ class AudioBufferSourceNode : public AudioBufferBaseSourceNode { double getVirtualEndFrame(float sampleRate); std::unique_ptr processor_; + + /// Hands the old buffers to the disposer and installs the new ones. Returns false when + /// the context is already gone. + bool swapBuffers( + const std::shared_ptr &buffer, + const std::shared_ptr &audioBuffer); }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioBuffer.hpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioBuffer.hpp index 8f32141fa..95279272b 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioBuffer.hpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/AudioBuffer.hpp @@ -141,6 +141,14 @@ class AlignedAudioBuffer { return channels_[index]; } + /// @brief Gives channel @p index fresh storage holding a copy of its current samples. + /// Every handle previously obtained through getSharedChannel() keeps the old storage + /// alive but no longer aliases this buffer, so writes through it can't reach us anymore. + /// This is how a JS `getChannelData` view gets cut off once playback acquires the buffer. + void detachSharedChannel(size_t index) { + channels_[index] = std::make_shared>(*channels_[index]); + } + AlignedAudioArray &operator[](size_t index) { return *channels_[index]; } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/ImmutableBufferCache.h b/packages/react-native-audio-api/common/cpp/audioapi/utils/ImmutableBufferCache.h new file mode 100644 index 000000000..b6207fcf5 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/utils/ImmutableBufferCache.h @@ -0,0 +1,44 @@ +#pragma once + +#include + +#include + +namespace audioapi::utils { + +/// @brief Caches a defensive copy of an `AudioBuffer` so repeated requests for +/// "an immutable copy suitable for handing to a source node" reuse the same +/// copy instead of allocating a fresh one every time. Used by +/// `AudioBufferHostObject` to avoid deep-copying the entire buffer on every +/// `.buffer = x` reassignment of the same underlying buffer. That reassignment +/// is what seeking requires, since a live `AudioBufferSourceNode` can't be +/// repositioned. See https://github.com/software-mansion/react-native-audio-api/issues/1263. +/// @note `invalidate()` must be called whenever the source buffer's data could +/// have diverged from the cached copy. This class has no way to observe that on +/// its own. +class ImmutableBufferCache { + public: + /// @brief Returns a defensive copy of `source`, reusing the last copy + /// produced as long as `invalidate()` has not been called since. + [[nodiscard]] std::shared_ptr getOrCreate( + const std::shared_ptr &source) { + if (cached_ == nullptr) { + cached_ = std::make_shared(*source); + } + return cached_; + } + + /// @brief Call when the source buffer's data may no longer match the cached + /// copy: it was mutated in place (`copyToChannel`), a live JS-writable view + /// into it was handed out (`getChannelData`), or such a view has just been + /// cut off after possibly being written through. The next `getOrCreate` + /// produces a fresh copy and caching resumes from there. + void invalidate() { + cached_ = nullptr; + } + + private: + std::shared_ptr cached_; +}; + +} // namespace audioapi::utils diff --git a/packages/react-native-audio-api/common/cpp/audioapi/utils/ImmutableBufferCache.hpp b/packages/react-native-audio-api/common/cpp/audioapi/utils/ImmutableBufferCache.hpp deleted file mode 100644 index d9a8139ae..000000000 --- a/packages/react-native-audio-api/common/cpp/audioapi/utils/ImmutableBufferCache.hpp +++ /dev/null @@ -1,59 +0,0 @@ -#pragma once - -#include - -#include - -namespace audioapi::utils { - -/// @brief Caches a defensive copy of an `AudioBuffer` so repeated requests for -/// "an immutable copy suitable for handing to a source node" reuse the same -/// copy instead of allocating a fresh one every time. Used by -/// `AudioBufferHostObject` to avoid deep-copying the entire buffer on every -/// `.buffer = x` reassignment of the same underlying buffer. That reassignment -/// is what seeking requires, since a live `AudioBufferSourceNode` can't be -/// repositioned. See https://github.com/software-mansion/react-native-audio-api/issues/1263. -/// @note The two invalidation methods must be called whenever the source -/// buffer's data could have been mutated from JS. This class has no way to -/// observe that on its own. -class ImmutableBufferCache { - public: - /// @brief Returns a defensive copy of `source`, reusing the last copy - /// produced as long as neither `invalidate()` nor `markLiveViewEscaped()` - /// has been called since. - [[nodiscard]] std::shared_ptr getOrCreate( - const std::shared_ptr &source) { - if (liveViewEscaped_) { - // A live, JS-writable view into source's data has escaped. A write - // through it could happen at any later time, so we can no longer prove - // a cached copy won't go stale. Fall back to copying every time. - return std::make_shared(*source); - } - - if (cached_ == nullptr) { - cached_ = std::make_shared(*source); - } - return cached_; - } - - /// @brief Call when the source buffer's data has just been mutated in place - /// (e.g. `copyToChannel`). The current cached copy is now stale. - void invalidate() { - cached_ = nullptr; - } - - /// @brief Call when a live, JS-writable view into the source buffer's data - /// has been handed out (e.g. `getChannelData`). This permanently stops - /// caching, since the view can be written through at any later time, not - /// just at the moment it was retrieved. - void markLiveViewEscaped() { - liveViewEscaped_ = true; - cached_ = nullptr; - } - - private: - std::shared_ptr cached_; - bool liveViewEscaped_ = false; -}; - -} // namespace audioapi::utils diff --git a/packages/react-native-audio-api/common/cpp/test/src/utils/AudioBufferTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/utils/AudioBufferTest.cpp index 6f458af68..300ff9ca7 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/utils/AudioBufferTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/utils/AudioBufferTest.cpp @@ -634,4 +634,25 @@ TEST_F(AudioBufferTest, DeinterleaveZeroFramesIsNoop) { expectChannel(buf, 0, 42.0f); } +TEST_F(AudioBufferTest, DetachSharedChannelCutsOffEscapedHandleAndKeepsSamples) { + AudioBuffer buf(BUF_SIZE, 2, SR); + fillChannel(buf, 0, 0.5f); + fillChannel(buf, 1, 0.25f); + auto escapedHandle = buf.getSharedChannel(0); + auto untouchedHandle = buf.getSharedChannel(1); + + buf.detachSharedChannel(0); + + EXPECT_NE(buf.getSharedChannel(0), escapedHandle) << "Detached channel must get fresh storage."; + EXPECT_EQ(buf.getSharedChannel(1), untouchedHandle) + << "Other channels keep their storage; only the escaped one is replaced."; + expectChannel(buf, 0, 0.5f); + + (*escapedHandle)[3] = 1.0f; + + expectChannel(buf, 0, 0.5f); + EXPECT_FLOAT_EQ((*escapedHandle)[3], 1.0f) + << "The old handle stays alive and writable, it just no longer reaches the buffer."; +} + // NOLINTEND diff --git a/packages/react-native-audio-api/common/cpp/test/src/utils/ImmutableBufferCacheTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/utils/ImmutableBufferCacheTest.cpp index e9c8429cc..070f89763 100644 --- a/packages/react-native-audio-api/common/cpp/test/src/utils/ImmutableBufferCacheTest.cpp +++ b/packages/react-native-audio-api/common/cpp/test/src/utils/ImmutableBufferCacheTest.cpp @@ -1,5 +1,5 @@ #include -#include +#include #include #include @@ -61,21 +61,4 @@ TEST(ImmutableBufferCacheTest, InvalidateForcesAFreshCopyOnce) { "caching normally rather than copying every time."; } -TEST(ImmutableBufferCacheTest, MarkLiveViewEscapedDisablesCachingPermanently) { - ImmutableBufferCache cache; - auto source = makeBuffer(); - - auto first = cache.getOrCreate(source); - cache.markLiveViewEscaped(); - auto second = cache.getOrCreate(source); - auto third = cache.getOrCreate(source); - - EXPECT_NE(first, second) << "A live, JS-writable view escaped, so the pre-existing cache entry " - "must be dropped since we can no longer prove it stayed in sync."; - EXPECT_NE(second, third) - << "Once a live view has ever escaped, every future call must fall back to a fresh, " - "uncached copy indefinitely. A write through that view could happen at any later " - "time, not just at the moment it was retrieved."; -} - // NOLINTEND diff --git a/packages/react-native-audio-api/src/core/AudioBuffer.ts b/packages/react-native-audio-api/src/core/AudioBuffer.ts index b73fcf441..385d61ee2 100644 --- a/packages/react-native-audio-api/src/core/AudioBuffer.ts +++ b/packages/react-native-audio-api/src/core/AudioBuffer.ts @@ -56,6 +56,18 @@ export default class AudioBuffer implements AudioBufferLike { return data; } + /** + * Forgets the cached channel views once the native side has cut them off from + * the buffer (a source node acquired this buffer's content), so the next + * `getChannelData` hands out a fresh view the way a browser does after it + * detaches the old ones. + * + * @internal + */ + public invalidateChannelDataCache(): void { + this.channelDataCache.length = 0; + } + public copyFromChannel( destination: Float32Array, channelNumber: number, diff --git a/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts b/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts index e855e7a5b..b70699b7a 100644 --- a/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts +++ b/packages/react-native-audio-api/src/core/AudioBufferSourceNode.ts @@ -53,6 +53,12 @@ export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { (this.node as IAudioBufferSourceNode).setBuffer(buffer.buffer); this._buffer = buffer; this.bufferHasBeenSet = true; + + if (this.hasBeenStarted) { + // Assigning a buffer to an already-started source acquires its content right + // away, so native has just cut off the views this buffer handed out. + buffer.invalidateChannelDataCache(); + } } public get loopSkip(): boolean { @@ -112,6 +118,9 @@ export default class AudioBufferSourceNode extends AudioBufferBaseSourceNode { this.hasBeenStarted = true; (this.node as IAudioBufferSourceNode).start(when, offset, duration); + // Native cut off every view handed out by getChannelData() while acquiring the + // buffer's content, so the wrapper must stop returning those dead views. + this._buffer?.invalidateChannelDataCache(); this.context.markRunningOnSourceStart(); }