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
1 change: 1 addition & 0 deletions .claude/skills/audio-nodes/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ Tail-bearing nodes (Delay/Convolver/Biquad) need no special handling: while conn
- `AudioNode` — standard effect or analysis node
- `AudioScheduledSourceNode` — source with start/stop scheduling
- `AudioBufferBaseSourceNode` — source that plays back an AudioBuffer with pitch control
With `pitchCorrection` the WSOLA stretcher holds ~30 ms of audio, so the node must outlive its last sample. It does that itself (`stretchTailFramesLeft_`): once the processor reports the source ran dry it keeps the node playing and feeds the stretcher the zero-filled input until `WsolaTimeStretcher::latencyTailFrames` are consumed. Never pad the buffer with silence to get the same effect — that forces a private copy per node and breaks buffer sharing with the JS-facing `AudioBuffer`.

2. **Header file** (`core/<category>/MyNode.h`)
- Annotate every method with `/// @note JS Thread only` or `/// @note Audio Thread only`
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/build-compilation-dependencies/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ CI runs a parallel `cpp-coverage` job via `.github/workflows/cpp-coverage-job.ym
- Completely standalone — no Gradle, no Xcode, no prebuilt Android libraries needed
- Sources resolved from `node_modules` (symlinked to `packages/` in yarn workspaces)
- HostObjects, worklets nodes, AudioContext, and FfmpegDecoder are excluded from the test build
- HostObject **headers** still compile under `RN_AUDIO_API_TEST`; only their `.cpp` bodies are missing. When a test first links core code that constructs one (e.g. `AudioRecorderCallback` → `AudioBufferHostObject`), the linker fails on the constructor. Fix it with a stub translation unit under `test/src/` that defines just the constructors (see `test/src/AudioBufferHostObjectStub.cpp`), never with an `#if RN_AUDIO_API_TEST` test double inside the production header. The static lib also compiles `jsi/jsi.cpp` so `jsi::HostObject`'s virtuals resolve
- The static lib compiles `jsi/jsi.cpp`, so real JSI types (`jsi::HostObject`, `jsi::MutableBuffer`) work in tests. When core code a test links constructs a HostObject (e.g. `AudioRecorderCallback` → `AudioBufferHostObject`), add that HostObject's `.cpp` to the `list(APPEND RNAUDIOAPI_SRC …)` exceptions in `test/CMakeLists.txt` (as for `JsEnumParser.cpp` and `AudioBufferHostObject.cpp`) instead of writing a stub translation unit or an `#if RN_AUDIO_API_TEST` test double
- Compile definitions: `RN_AUDIO_API_ENABLE_WORKLETS=0`, `RN_AUDIO_API_TEST=1` (FFmpeg is off because the define is simply absent)
- Google Test auto-fetched via `FetchContent` if not installed locally
- New test files in `test/src/**/*.cpp` are picked up automatically by glob — no CMakeLists edit needed
Expand Down
11 changes: 9 additions & 2 deletions .claude/skills/host-objects/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,7 @@ return result;

```cpp
JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, getChannelData) {
auto channel = static_cast<int>(args[0].getNumber());
auto audioArrayBuffer = audioBuffer_->getSharedChannel(channel);
auto audioArrayBuffer = sharedBuffer_->exposeChannel(static_cast<size_t>(args[0].getNumber()));
auto arrayBuffer = jsi::ArrayBuffer(runtime, audioArrayBuffer);

auto float32ArrayCtor = runtime.global()
Expand All @@ -355,6 +354,14 @@ 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 storage and replace it afterwards, so the old view keeps the old storage and stops reaching the buffer.

That bookkeeping — copy-on-write against playing nodes, tracking exposed views, the spec's `copyFromChannel` / `copyToChannel` range rules — lives in the engine class `core/sources/SharedAudioBuffer`, not in `AudioBufferHostObject`. An AudioBuffer involves no inter-thread events (every member runs on the JS thread), so nothing forces its logic into the host layer; the host object only unpacks JSI arguments and wraps the returned storage. `SharedAudioBuffer` is unit-tested in `test/src/core/sources/SharedAudioBufferTest.cpp`, which the HostObject layer cannot be (see `build-compilation-dependencies`). Its per-channel flag vectors are sized in the constructor: sizing them lazily on first share crashed the view cut-off the moment the order of the two steps flipped.

`AudioBufferSourceNodeHostObject` holds the assigned `SharedAudioBuffer` and hands samples to the audio thread in exactly one place, `acquireBufferContent`, which runs on `start()` and on a `setBuffer()` made after start. A `setBuffer()` before start only records the buffer and publishes its channel count; the node reads nothing until acquire, so every JS write made in between is played and nothing has to track content versions. `SharedAudioBuffer::acquireContent` both cuts off the live views and returns the storage-sharing buffer the node plays.

`AudioBufferQueueSourceNodeHostObject::enqueueBuffer` reuses the same mechanism: a buffer of the queue's width is shared through `SharedAudioBuffer::acquireContent` instead of copied, only a buffer of another width is mixed into a private copy, and the TS wrapper calls `invalidateChannelDataCache` after every enqueue. The stretcher is configured inline on the JS thread by the host base `initStretch`, once per node and with no audio event: the audio thread touches it only once the node has content, and the first content is always scheduled behind that call. Audio events are `FatFunction<72, …>`; a handle, a node pointer, two `shared_ptr`s and one scalar already fill the slot, so derive what you can on the audio thread (the queue event reads the width off its content) instead of capturing it.

### 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 @@ -111,6 +111,10 @@ Adds another buffer to queue. Returns `bufferId` that can be used to identify th

#### Returns `string`.

:::tip
The first enqueued buffer sets the number of channels the queue plays at. A buffer with that same number of channels is played directly from its own memory, so enqueuing it costs no copy. A buffer with a different number of channels is first mixed into a copy of the queue's width, which is slower and allocates memory for every such buffer. For best performance, enqueue buffers that all have the same number of channels.
:::

### `dequeueBuffer`

Removes a buffer from the queue. Note that [`onbufferended`](audio-buffer-queue-source-node#onbufferended) event will not be fired for the removed buffers.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,12 @@ JSI_HOST_FUNCTION_IMPL(ConvolverNodeHostObject, setBuffer) {
irBytes_ = bufferHostObject->getSizeInBytes();
thisValue.asObject(runtime).setExternalMemoryPressure(runtime, getMemoryPressure());

setBuffer(bufferHostObject->audioBuffer_);
setBuffer(bufferHostObject->sharedBuffer_->getSamples());

return jsi::Value::undefined();
}

void ConvolverNodeHostObject::setBuffer(const std::shared_ptr<AudioBuffer> &buffer) {
void ConvolverNodeHostObject::setBuffer(const std::shared_ptr<const AudioBuffer> &buffer) {
if (buffer == nullptr) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,6 @@ class ConvolverNodeHostObject : public AudioNodeHostObject {

bool normalize_;
size_t irBytes_ = 0;
void setBuffer(const std::shared_ptr<AudioBuffer> &buffer);
void setBuffer(const std::shared_ptr<const AudioBuffer> &buffer);
};
} // namespace audioapi
Original file line number Diff line number Diff line change
Expand Up @@ -81,18 +81,19 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferBaseSourceNodeHostObject, getOutputLatency) {
}

void AudioBufferBaseSourceNodeHostObject::initStretch(int channelCount, float sampleRate) {
auto handle = node_->handle;
if (stretchHasBeenInit_) {
return;
}
stretchHasBeenInit_ = true;

inputLatency_ = WsolaTimeStretcher::INPUT_LATENCY_MS / 1000.0;
outputLatency_ = WsolaTimeStretcher::OUTPUT_LATENCY_MS / 1000.0;

auto playbackRateBuffer = std::make_shared<DSPAudioBuffer>(
WsolaTimeStretcher::MAX_PLAYBACK_RATE * RENDER_QUANTUM_SIZE, channelCount, sampleRate);

auto event = [handle, node = bufferBaseSourceNode_, playbackRateBuffer, channelCount, sampleRate](
BaseAudioContext &) {
node->initStretch(static_cast<size_t>(channelCount), sampleRate, playbackRateBuffer);
};
bufferBaseSourceNode_->scheduleAudioEvent(std::move(event));
bufferBaseSourceNode_->initStretch(
static_cast<size_t>(channelCount), sampleRate, playbackRateBuffer);
}

} // namespace audioapi
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@ class AudioBufferBaseSourceNodeHostObject : public AudioScheduledSourceNodeHostO
double inputLatency_ = 0;
double outputLatency_ = 0;
bool pitchCorrection_;
bool stretchHasBeenInit_ = false;

/// Configures the node's stretcher for content of this shape, inline and only once
void initStretch(int channelCount, float sampleRate);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@

#include <audioapi/utils/AudioArrayBuffer.hpp>

#include <algorithm>
#include <cstddef>
#include <memory>
#include <utility>

namespace audioapi {

AudioBufferHostObject::AudioBufferHostObject(const std::shared_ptr<AudioBuffer> &audioBuffer)
: audioBuffer_(audioBuffer) {
: sharedBuffer_(std::make_shared<SharedAudioBuffer>(audioBuffer)) {
addGetters(
JSI_EXPORT_PROPERTY_GETTER(AudioBufferHostObject, sampleRate),
JSI_EXPORT_PROPERTY_GETTER(AudioBufferHostObject, length),
Expand All @@ -23,28 +21,24 @@ AudioBufferHostObject::AudioBufferHostObject(const std::shared_ptr<AudioBuffer>
JSI_EXPORT_FUNCTION(AudioBufferHostObject, copyToChannel));
}

AudioBufferHostObject::AudioBufferHostObject(AudioBufferHostObject &&other) noexcept
: HostObject(std::move(other)), audioBuffer_(std::move(other.audioBuffer_)) {}

JSI_PROPERTY_GETTER_IMPL(AudioBufferHostObject, sampleRate) {
return {audioBuffer_->getSampleRate()};
return {sharedBuffer_->getSamples()->getSampleRate()};
}

JSI_PROPERTY_GETTER_IMPL(AudioBufferHostObject, length) {
return {static_cast<double>(audioBuffer_->getSize())};
return {static_cast<double>(sharedBuffer_->getSamples()->getSize())};
}

JSI_PROPERTY_GETTER_IMPL(AudioBufferHostObject, duration) {
return {audioBuffer_->getDuration()};
return {sharedBuffer_->getSamples()->getDuration()};
}

JSI_PROPERTY_GETTER_IMPL(AudioBufferHostObject, numberOfChannels) {
return {static_cast<int>(audioBuffer_->getNumberOfChannels())};
return {static_cast<int>(sharedBuffer_->getSamples()->getNumberOfChannels())};
}

JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, getChannelData) {
auto channel = static_cast<int>(args[0].getNumber());
auto audioArrayBuffer = audioBuffer_->getSharedChannel(channel);
auto audioArrayBuffer = sharedBuffer_->exposeChannel(static_cast<size_t>(args[0].getNumber()));
auto arrayBuffer = jsi::ArrayBuffer(runtime, audioArrayBuffer);

auto float32ArrayCtor = runtime.global().getPropertyAsFunction(runtime, "Float32Array");
Expand All @@ -58,39 +52,23 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, getChannelData) {
JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, copyFromChannel) {
auto arrayBuffer =
args[0].getObject(runtime).getPropertyAsObject(runtime, "buffer").getArrayBuffer(runtime);
auto *destination = reinterpret_cast<float *>(arrayBuffer.data(runtime));
auto destinationLength = arrayBuffer.size(runtime) / sizeof(float);
auto channelNumber = static_cast<int>(args[1].getNumber());
auto rawStart = args[2].getNumber();
auto channelSize = audioBuffer_->getSize();

// Per spec, an out-of-range (or negative) startInChannel copies nothing, and a
// copy that would run past the end of the channel is truncated rather than throwing.
if (rawStart >= 0 && static_cast<size_t>(rawStart) < channelSize) {
auto startInChannel = static_cast<size_t>(rawStart);
auto framesToCopy = std::min(destinationLength, channelSize - startInChannel);
audioBuffer_->getChannel(channelNumber)->copyTo(destination, startInChannel, 0, framesToCopy);
}
sharedBuffer_->copyFromChannel(
reinterpret_cast<float *>(arrayBuffer.data(runtime)),
arrayBuffer.size(runtime) / sizeof(float),
static_cast<size_t>(args[1].getNumber()),
args[2].getNumber());

return jsi::Value::undefined();
}

JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, copyToChannel) {
auto arrayBuffer =
args[0].getObject(runtime).getPropertyAsObject(runtime, "buffer").getArrayBuffer(runtime);
auto *source = reinterpret_cast<float *>(arrayBuffer.data(runtime));
auto sourceLength = arrayBuffer.size(runtime) / sizeof(float);
auto channelNumber = static_cast<int>(args[1].getNumber());
auto rawStart = args[2].getNumber();
auto channelSize = audioBuffer_->getSize();

// Per spec, an out-of-range (or negative) startInChannel copies nothing, and a
// copy that would run past the end of the channel is truncated rather than throwing.
if (rawStart >= 0 && static_cast<size_t>(rawStart) < channelSize) {
auto startInChannel = static_cast<size_t>(rawStart);
auto framesToCopy = std::min(sourceLength, channelSize - startInChannel);
audioBuffer_->getChannel(channelNumber)->copy(source, 0, startInChannel, framesToCopy);
}
sharedBuffer_->copyToChannel(
reinterpret_cast<const float *>(arrayBuffer.data(runtime)),
arrayBuffer.size(runtime) / sizeof(float),
static_cast<size_t>(args[1].getNumber()),
args[2].getNumber());

return jsi::Value::undefined();
}
Expand Down
Comment thread
SomePersonFromMars marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,37 +1,30 @@
#pragma once

#include <audioapi/core/sources/SharedAudioBuffer.h>
#include <audioapi/jsi/HostObject.h>
#include <audioapi/utils/AudioBuffer.hpp>

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

namespace audioapi {
using namespace facebook;

class AudioBufferHostObject : public HostObject {
public:
std::shared_ptr<AudioBuffer> audioBuffer_;
std::shared_ptr<SharedAudioBuffer> sharedBuffer_;

explicit AudioBufferHostObject(const std::shared_ptr<AudioBuffer> &audioBuffer);
AudioBufferHostObject(const AudioBufferHostObject &) = delete;
AudioBufferHostObject &operator=(const AudioBufferHostObject &) = delete;
AudioBufferHostObject(AudioBufferHostObject &&other) noexcept;
AudioBufferHostObject &operator=(AudioBufferHostObject &&other) noexcept {
if (this != &other) {
HostObject::operator=(std::move(other));
audioBuffer_ = std::move(other.audioBuffer_);
}
return *this;
}
AudioBufferHostObject(AudioBufferHostObject &&other) noexcept = default;
AudioBufferHostObject &operator=(AudioBufferHostObject &&other) noexcept = default;

~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 sharedBuffer_->getSizeInBytes();
}

JSI_PROPERTY_GETTER_DECL(sampleRate);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,47 +77,44 @@ JSI_HOST_FUNCTION_IMPL(AudioBufferQueueSourceNodeHostObject, resume) {
JSI_HOST_FUNCTION_IMPL(AudioBufferQueueSourceNodeHostObject, enqueueBuffer) {
auto audioBufferHostObject =
args[0].getObject(runtime).asHostObject<AudioBufferHostObject>(runtime);
// 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
const auto &sharedBuffer = audioBufferHostObject->sharedBuffer_;
const auto &samples = sharedBuffer->getSamples();
const auto bufferChannelNumber = samples->getNumberOfChannels();

auto swapBuffer = false; // whether to swap internal node buffer with the new buffer
// The first buffer fixes the width the queue plays at.
if (outputChannelNumber_ == 0) {
outputChannelNumber_ = audioBufferHostObject->audioBuffer_->getNumberOfChannels();
swapBuffer = true;
outputChannelNumber_ = bufferChannelNumber;
}

// first buffer defines the output channel number, the rest are mixed to it
auto copiedBuffer = std::make_shared<AudioBuffer>(
audioBufferHostObject->audioBuffer_->getSize(),
outputChannelNumber_,
audioBufferHostObject->audioBuffer_->getSampleRate());
std::shared_ptr<AudioBuffer> content;

copiedBuffer->sum(*audioBufferHostObject->audioBuffer_);
if (bufferChannelNumber == outputChannelNumber_) {
content = sharedBuffer->acquireContent();
} else {
// A buffer of another width is mixed into a private copy of the queue's width.
content = std::make_shared<AudioBuffer>(
samples->getSize(), static_cast<int>(outputChannelNumber_), samples->getSampleRate());
content->sum(*samples);
}

std::shared_ptr<AudioBuffer> tailBuffer = nullptr;

if (pitchCorrection_ && !stretchHasBeenInit_) {
initStretch(
static_cast<int>(copiedBuffer->getNumberOfChannels()), copiedBuffer->getSampleRate());
initStretch(static_cast<int>(outputChannelNumber_), samples->getSampleRate());
auto extraTailFrames =
static_cast<size_t>((inputLatency_ + outputLatency_) * copiedBuffer->getSampleRate());
static_cast<size_t>((inputLatency_ + outputLatency_) * samples->getSampleRate());
tailBuffer = std::make_shared<AudioBuffer>(
extraTailFrames, copiedBuffer->getNumberOfChannels(), copiedBuffer->getSampleRate());
extraTailFrames, static_cast<int>(outputChannelNumber_), samples->getSampleRate());
tailBuffer->zero();
stretchHasBeenInit_ = true;
}

auto event = [node = bufferQueueSourceNode_,
copiedBuffer,
auto event = [handle = node_->handle,
node = bufferQueueSourceNode_,
content,
bufferId = bufferId_,
tailBuffer,
swapBuffer,
outputChannelNumber = outputChannelNumber_](BaseAudioContext &) {
if (swapBuffer) {
node->resizeOutputBuffer(static_cast<int>(outputChannelNumber));
}
node->enqueueBuffer(copiedBuffer, bufferId, tailBuffer);
tailBuffer](BaseAudioContext &) {
node->resizeOutputBuffer(content->getNumberOfChannels());
node->enqueueBuffer(content, bufferId, tailBuffer);
};
bufferQueueSourceNode_->scheduleAudioEvent(std::move(event));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ class AudioBufferQueueSourceNodeHostObject : public AudioBufferBaseSourceNodeHos
AudioBufferQueueSourceNode *const bufferQueueSourceNode_;

size_t bufferId_ = 0;
bool stretchHasBeenInit_ = false;
/// Width of the first enqueued buffer; later buffers are mixed to it. 0 until then.
size_t outputChannelNumber_ = 0;
};
Expand Down
Loading
Loading