Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/skills/host-objects/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions packages/audiodocs/docs/fundamentals/best-practices.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -87,4 +89,5 @@ Prefer logging plain values instead:
```tsx
console.log({ channelCount: node.channelCount, numberOfInputs: node.numberOfInputs });
```

:::
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include <cstddef>
#include <memory>
#include <utility>
#include <vector>

namespace audioapi {

Expand All @@ -24,7 +25,61 @@ AudioBufferHostObject::AudioBufferHostObject(const std::shared_ptr<AudioBuffer>
}

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<AudioBuffer> 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<bool> 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()};
Expand All @@ -43,14 +98,20 @@ JSI_PROPERTY_GETTER_IMPL(AudioBufferHostObject, numberOfChannels) {
}

JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, getChannelData) {
auto channel = static_cast<int>(args[0].getNumber());
auto channel = static_cast<size_t>(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);

auto float32ArrayCtor = runtime.global().getPropertyAsFunction(runtime, "Float32Array");
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;
}
Expand Down Expand Up @@ -81,6 +142,8 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, copyToChannel) {
auto *source = reinterpret_cast<float *>(arrayBuffer.data(runtime));
auto sourceLength = arrayBuffer.size(runtime) / sizeof(float);
auto channelNumber = static_cast<int>(args[1].getNumber());
// Mutates audioBuffer_ in place, so that channel must not be one a node is playing.
makeChannelWritable(static_cast<size_t>(channelNumber));
auto rawStart = args[2].getNumber();
auto channelSize = audioBuffer_->getSize();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@

#include <jsi/jsi.h>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <utility>
#include <vector>

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> audioBuffer_;
Expand All @@ -23,15 +29,40 @@ 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;
}

~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<AudioBuffer> 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);
Expand All @@ -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<bool> channelSharedWithNode_;
uint64_t contentVersion_ = 0;
/// Float32Array views handed out by `getChannelData` since the last
/// `detachReturnedChannelData`, kept so they can be neutralised then.
std::vector<ReturnedChannelDataArray> returnedChannelDataArrays_;
};
} // namespace audioapi
Original file line number Diff line number Diff line change
Expand Up @@ -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_,
Expand All @@ -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);
Expand All @@ -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<AudioBuffer> &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<AudioBuffer> copiedBuffer;
std::shared_ptr<DSPAudioBuffer> audioBuffer;
const size_t newChannelCount = buffer == nullptr ? AudioBufferSourceOptions::kDefaultChannelCount
: buffer->getNumberOfChannels();
AudioBufferSourceNodeHostObject::NodeBuffers AudioBufferSourceNodeHostObject::prepareNodeBuffers(
const std::shared_ptr<AudioBuffer> &buffer,
const std::shared_ptr<AudioBufferHostObject> &bufferHostObject) {
NodeBuffers buffers;

if (buffer == nullptr) {
copiedBuffer = nullptr;
audioBuffer = std::make_shared<DSPAudioBuffer>(
buffers.nodeBuffer = nullptr;
buffers.audioBuffer = std::make_shared<DSPAudioBuffer>(
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<int>(buffer->getNumberOfChannels()), buffer->getSampleRate());
auto extraTailFrames =
static_cast<size_t>((inputLatency_ + outputLatency_) * buffer->getSampleRate());
size_t totalSize = buffer->getSize() + extraTailFrames;
buffers.nodeBuffer = std::make_shared<AudioBuffer>(
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<int>(buffer->getNumberOfChannels()), buffer->getSampleRate());
auto extraTailFrames =
static_cast<size_t>((inputLatency_ + outputLatency_) * buffer->getSampleRate());
size_t totalSize = buffer->getSize() + extraTailFrames;
copiedBuffer = std::make_shared<AudioBuffer>(
totalSize, buffer->getNumberOfChannels(), buffer->getSampleRate());
copiedBuffer->copy(*buffer, 0, 0, buffer->getSize());
copiedBuffer->zero(buffer->getSize(), extraTailFrames);
} else {
copiedBuffer = std::make_shared<AudioBuffer>(*buffer);
}

audioBuffer = std::make_shared<DSPAudioBuffer>(
RENDER_QUANTUM_SIZE,
copiedBuffer->getNumberOfChannels(),
audioBufferSourceNode_->getContextSampleRate());
buffers.nodeBuffer = std::make_shared<AudioBuffer>(*buffer);
}

if (bufferHostObject != nullptr) {
sharedContentVersion_ = bufferHostObject->getContentVersion();
}

buffers.audioBuffer = std::make_shared<DSPAudioBuffer>(
RENDER_QUANTUM_SIZE,
buffers.nodeBuffer->getNumberOfChannels(),
audioBufferSourceNode_->getContextSampleRate());
return buffers;
}

void AudioBufferSourceNodeHostObject::setBuffer(
const std::shared_ptr<AudioBuffer> &buffer,
const std::shared_ptr<AudioBufferHostObject> &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));
}
Expand Down
Loading
Loading