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/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..2eef2ae6b 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,61 @@ 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_)), + channelSharedWithNode_(std::move(other.channelSharedWithNode_)), + contentVersion_(other.contentVersion_), + returnedChannelDataArrays_(std::move(other.returnedChannelDataArrays_)) {} + +std::shared_ptr AudioBufferHostObject::shareForPlayback() { + channelSharedWithNode_.assign(audioBuffer_->getNumberOfChannels(), true); + return audioBuffer_->shareChannels(); +} + +void AudioBufferHostObject::makeChannelWritable(size_t channel) { + if (channel < channelSharedWithNode_.size() && channelSharedWithNode_[channel]) { + replaceChannelStorage(channel); + } +} + +void AudioBufferHostObject::replaceChannelStorage(size_t channel) { + // mark the channel as no longer shared with a node, so that future writes to it don't trigger another copy-on-write + if (channel < channelSharedWithNode_.size()) { + audioBuffer_->detachSharedChannel(channel); + channelSharedWithNode_[channel] = false; + ++contentVersion_; + } +} + +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]) { + replaceChannelStorage(returned.channel); + channelDetached[returned.channel] = true; + } + } + + returnedChannelDataArrays_.clear(); +} JSI_PROPERTY_GETTER_IMPL(AudioBufferHostObject, sampleRate) { return {audioBuffer_->getSampleRate()}; @@ -43,7 +98,11 @@ JSI_PROPERTY_GETTER_IMPL(AudioBufferHostObject, numberOfChannels) { } JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, getChannelData) { - auto channel = static_cast(args[0].getNumber()); + auto channel = static_cast(args[0].getNumber()); + // The returned Float32Array is a live, JS-writable view straight into audioBuffer_'s + // storage, so that storage must not be one a node is playing. + makeChannelWritable(channel); + auto audioArrayBuffer = audioBuffer_->getSharedChannel(channel); auto arrayBuffer = jsi::ArrayBuffer(runtime, audioArrayBuffer); @@ -51,6 +110,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; } @@ -81,6 +142,8 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, copyToChannel) { auto *source = reinterpret_cast(arrayBuffer.data(runtime)); auto sourceLength = arrayBuffer.size(runtime) / sizeof(float); auto channelNumber = static_cast(args[1].getNumber()); + // Mutates audioBuffer_ in place, so that channel must not be one a node is playing. + makeChannelWritable(static_cast(channelNumber)); auto rawStart = args[2].getNumber(); auto channelSize = audioBuffer_->getSize(); 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..6c4a787f2 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 @@ -5,12 +5,18 @@ #include #include +#include #include #include +#include namespace audioapi { using namespace facebook; +/// @brief JS-facing AudioBuffer. Source nodes play its channel storage directly instead of +/// deep-copying it (see `shareForPlayback`), so the one rule this class enforces is that the +/// JS thread never writes into storage a node may be reading: every write path first gives +/// the touched channel fresh storage (copy-on-write) and leaves the old one to the nodes. class AudioBufferHostObject : public HostObject { public: std::shared_ptr audioBuffer_; @@ -23,6 +29,9 @@ class AudioBufferHostObject : public HostObject { if (this != &other) { HostObject::operator=(std::move(other)); audioBuffer_ = std::move(other.audioBuffer_); + channelSharedWithNode_ = std::move(other.channelSharedWithNode_); + contentVersion_ = other.contentVersion_; + returnedChannelDataArrays_ = std::move(other.returnedChannelDataArrays_); } return *this; } @@ -30,8 +39,30 @@ class AudioBufferHostObject : public HostObject { ~AudioBufferHostObject() override = default; [[nodiscard]] size_t getSizeInBytes() const { - // *2 because every time buffer is passed we create a copy of it. - return audioBuffer_->getSize() * audioBuffer_->getNumberOfChannels() * sizeof(float) * 2; + return audioBuffer_->getSize() * audioBuffer_->getNumberOfChannels() * sizeof(float); + } + + /// @brief from this call both the js and native side can read the same channel storage. + /// The JS side is copy-on-write, so it will get a fresh copy if it writes into it while a node is reading it. + [[nodiscard]] std::shared_ptr shareForPlayback(); + + /// @brief Bumped every time a channel's storage is replaced. A source node compares it + /// with the version it shared at to decide whether "acquire the content" must re-share. + [[nodiscard]] uint64_t getContentVersion() const { + return contentVersion_; + } + + /// @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); @@ -42,5 +73,30 @@ class AudioBufferHostObject : public HostObject { JSI_HOST_FUNCTION_DECL(getChannelData); JSI_HOST_FUNCTION_DECL(copyFromChannel); JSI_HOST_FUNCTION_DECL(copyToChannel); + + private: + struct ReturnedChannelDataArray { + size_t channel; + /// Weak on purpose: a view JS already dropped must not be kept alive (nor keep its + /// external-memory-pressure hint alive) until the next start(). The channel is still + /// recorded so its storage gets swapped, since wrappers over the same ArrayBuffer may + /// outlive this particular Float32Array object. + jsi::WeakObject array; + }; + + /// Copy-on-write: call before exposing or mutating a channel from JS. If a node may be + /// reading that channel's storage, the buffer gets a private copy of it first. + void makeChannelWritable(size_t channel); + + /// Replaces the channel's storage with a copy and records that nothing shares it yet. + void replaceChannelStorage(size_t channel); + + /// One flag per channel: true while a node handed out by `shareForPlayback` may still + /// be reading that channel's current storage. + std::vector channelSharedWithNode_; + uint64_t contentVersion_ = 0; + /// 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 4bd5826c6..f49decd3d 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,30 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferSourceNodeHostObject, start) { return jsi::Value::undefined(); } +void AudioBufferSourceNodeHostObject::acquireBufferContent(jsi::Runtime &runtime) { + if (bufferHostObject_ == nullptr) { + return; + } + + bufferHostObject_->detachReturnedChannelData(runtime); + + if (bufferHostObject_->getContentVersion() == sharedContentVersion_) { + // Nothing replaced the storage the node already reads, so it holds the content + // exactly as acquired. + return; + } + + // Some channel storage could be swapped since the node received its samples, either by + // the detach above or by an earlier copy-on-write. Re-hand it the current content, + // touching only the samples so loopEnd and friends survive. + auto buffers = prepareNodeBuffers(bufferHostObject_->audioBuffer_, bufferHostObject_); + auto event = + [handle = node_->handle, node = audioBufferSourceNode_, buffers](BaseAudioContext &) { + node->replaceBufferContent(buffers.nodeBuffer, buffers.audioBuffer); + }; + audioBufferSourceNode_->scheduleAudioEvent(std::move(event)); +} + JSI_HOST_FUNCTION_IMPL(AudioBufferSourceNodeHostObject, setBuffer) { if (args[0].isNull()) { setBuffer(nullptr); @@ -147,56 +174,77 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferSourceNodeHostObject, setBuffer) { thisValue.asObject(runtime).setExternalMemoryPressure( runtime, getMemoryPressure() + bufferHostObject->getSizeInBytes()); - setBuffer(bufferHostObject->audioBuffer_); + 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(const std::shared_ptr &buffer) { - // 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(); +AudioBufferSourceNodeHostObject::NodeBuffers AudioBufferSourceNodeHostObject::prepareNodeBuffers( + const std::shared_ptr &buffer, + const std::shared_ptr &bufferHostObject) { + NodeBuffers buffers; if (buffer == nullptr) { - copiedBuffer = nullptr; - audioBuffer = std::make_shared( + buffers.nodeBuffer = nullptr; + buffers.audioBuffer = std::make_shared( RENDER_QUANTUM_SIZE, AudioBufferSourceOptions::kDefaultChannelCount, audioBufferSourceNode_->getContextSampleRate()); + return buffers; + } + + if (pitchCorrection_) { + // The stretcher needs tail padding past the end of the samples, so this path keeps + // its own padded copy. + initStretch(static_cast(buffer->getNumberOfChannels()), buffer->getSampleRate()); + auto extraTailFrames = + static_cast((inputLatency_ + outputLatency_) * buffer->getSampleRate()); + size_t totalSize = buffer->getSize() + extraTailFrames; + buffers.nodeBuffer = std::make_shared( + totalSize, buffer->getNumberOfChannels(), buffer->getSampleRate()); + buffers.nodeBuffer->copy(*buffer, 0, 0, buffer->getSize()); + buffers.nodeBuffer->zero(buffer->getSize(), extraTailFrames); + } else if (bufferHostObject != nullptr) { + // stamp the buffer so from now on each call to getChannelData() returns a fresh view + // and the node can share the storage without risk of JS writing into it. + buffers.nodeBuffer = bufferHostObject->shareForPlayback(); } 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 { - copiedBuffer = std::make_shared(*buffer); - } - - audioBuffer = std::make_shared( - RENDER_QUANTUM_SIZE, - copiedBuffer->getNumberOfChannels(), - audioBufferSourceNode_->getContextSampleRate()); + buffers.nodeBuffer = std::make_shared(*buffer); } + if (bufferHostObject != nullptr) { + sharedContentVersion_ = bufferHostObject->getContentVersion(); + } + + buffers.audioBuffer = std::make_shared( + RENDER_QUANTUM_SIZE, + buffers.nodeBuffer->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.nodeBuffer, 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 b3b480576..3ff446c90 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 @@ -4,6 +4,7 @@ #include #include +#include #include namespace audioapi { @@ -51,7 +52,36 @@ class AudioBufferSourceNodeHostObject : public AudioBufferBaseSourceNodeHostObje double loopStart_; double loopEnd_; - void setBuffer(const std::shared_ptr &buffer); + /// 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_; + /// `bufferHostObject_->getContentVersion()` at the moment the node last received its + /// samples. A newer version means JS replaced some channel storage since, so the node + /// is reading stale content and must be re-handed the buffer when it acquires it. + uint64_t sharedContentVersion_ = 0; + bool hasBeenStarted_ = false; + + /// The samples the node will read (shared with the JS-facing buffer when possible, a + /// padded private copy for pitch correction) plus its render-quantum scratch buffer. + struct NodeBuffers { + std::shared_ptr nodeBuffer; + 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 + /// if the JS-facing buffer's storage moved on since the node last received it, + /// re-hands the node the current 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..52b6753c7 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,26 @@ class AlignedAudioBuffer { return channels_[index]; } + /// @brief Creates a buffer that shares this buffer's channel storage instead of copying + /// it. Both buffers read the same samples until one of them calls detachSharedChannel() + /// on a channel, which is how a source node can play a JS-visible buffer without a deep + /// copy while the JS side keeps copy-on-write semantics. + [[nodiscard]] std::shared_ptr shareChannels() const { + auto shared = std::make_shared(); + shared->numberOfChannels_ = numberOfChannels_; + shared->sampleRate_ = sampleRate_; + shared->size_ = size_; + shared->channels_ = channels_; + return shared; + } + + /// @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. + void detachSharedChannel(size_t index) { + channels_[index] = std::make_shared>(*channels_[index]); + } + AlignedAudioArray &operator[](size_t index) { return *channels_[index]; } @@ -345,13 +365,18 @@ class AlignedAudioBuffer { {2, {ChannelLeft, ChannelRight}}, {4, {ChannelLeft, ChannelRight, ChannelSurroundLeft, ChannelSurroundRight}}, {5, {ChannelLeft, ChannelRight, ChannelCenter, ChannelSurroundLeft, ChannelSurroundRight}}, - {6, - {ChannelLeft, - ChannelRight, - ChannelCenter, - ChannelLFE, - ChannelSurroundLeft, - ChannelSurroundRight}}}; + { + 6, + { + ChannelLeft, + ChannelRight, + ChannelCenter, + ChannelLFE, + ChannelSurroundLeft, + ChannelSurroundRight, + }, + }, + }; template void discreteSum( 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..bba6bd061 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,49 @@ 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."; +} + +// A source node plays a JS-visible buffer through shareChannels(). The JS side then relies +// on detachSharedChannel() as copy-on-write: its writes must never reach the node's view. +TEST_F(AudioBufferTest, ShareChannelsAliasesStorageUntilOneSideDetaches) { + AudioBuffer owner(BUF_SIZE, 2, SR); + fillChannel(owner, 0, 0.5f); + fillChannel(owner, 1, 0.25f); + + auto shared = owner.shareChannels(); + + ASSERT_EQ(shared->getNumberOfChannels(), 2u); + ASSERT_EQ(shared->getSize(), BUF_SIZE); + ASSERT_FLOAT_EQ(shared->getSampleRate(), SR); + EXPECT_EQ(shared->getSharedChannel(0), owner.getSharedChannel(0)) + << "shareChannels() must alias, not copy."; + + owner.detachSharedChannel(0); + fillChannel(owner, 0, 1.0f); + + expectChannel(*shared, 0, 0.5f); + expectChannel(owner, 0, 1.0f); + EXPECT_EQ(shared->getSharedChannel(1), owner.getSharedChannel(1)) + << "Detaching one channel must not touch the others."; +} + // 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(); }