| name | host-objects |
| description | Covers how to create, structure, and maintain JSI HostObjects that bridge C++ audio nodes to JavaScript in react-native-audio-api. Explains naming conventions, property/method exposure via macros, shadow state for JS↔audio-thread communication, JSI argument parsing, return value patterns, memory pressure, factory wiring in BaseAudioContextHostObject, and common pitfalls. Use this skill when creating a new audio node HostObject, modifying existing HostObject get/set/call logic, wiring a new node into the context factory, or debugging JSI-related crashes and type errors. Trigger phrases: "add HostObject", "create JSI bridge", "expose C++ node to JS", "shadow state", "scheduleAudioEvent from setter", "JSI property getter", "HostObject crashes", "new audio node".
|
Skill: HostObjects
Scope: C++ JSI HostObject layer — packages/react-native-audio-api/common/cpp/audioapi/HostObjects/
HostObjects are the middle layer between the TypeScript API and the C++ audio engine. They expose C++ audio node state and methods to JavaScript via JSI (no bridge serialization), and route state changes to the audio thread via a lock-free SPSC event queue.
Golden references: GainNodeHostObject.h/.cpp (effect node), OscillatorNodeHostObject.h/.cpp (source node). Mirror their structure for any new HostObject. See full examples for annotated implementations.
Critical Pitfalls — Read Before Writing Any Code
- NEVER read from
node_ in a getter if the property can be written by the audio thread. Use shadow state or atomics instead.
- NEVER call
node_->someMethod() directly from a setter — always schedule via scheduleAudioEvent. The audio thread may be mid-render.
- ALWAYS register getters/setters/functions in the constructor. Anything not added to
addGetters/addSetters/addFunctions is silently missing from JS.
- Match property names exactly. The string in
JSI_EXPORT_PROPERTY_GETTER becomes the JS property name. A typo means the property doesn't exist in JS.
- Clear callback IDs in the destructor for any HO that registers audio events. Otherwise the audio thread fires into a destroyed JS function.
- Call
setExternalMemoryPressure when returning HOs or typed arrays backed by large native buffers.
- Shadow state must be initialized from
options in the constructor — JS may read a property before ever setting it.
Three-Layer Architecture
Every audio node has three layers. HostObject is the middle one:
flowchart TD
TS["TypeScript class\n(src/core/)"]
HO["HostObject\n(C++ — JsiHostObject subclass)"]
AN["AudioNode\n(C++ core — audio thread)"]
TS <--> |"JSI — direct memory, no serialization"| HO
HO <--> |"shared_ptr\nscheduleAudioEvent → SPSC"| AN
There is no strong typing between the C++ HostObject and the TypeScript interface. Alignment is by convention — property names and function signatures must match manually.
Directory Structure
HostObjects/
├── AudioNodeHostObject.h/.cpp # Base for all audio node HOs
├── AudioParamHostObject.h/.cpp # AudioParam wrapper
├── BaseAudioContextHostObject.h/.cpp # Factory — all createXxx() methods live here
├── AudioContextHostObject.h/.cpp # Realtime context (adds close/resume/suspend)
├── OfflineAudioContextHostObject.h/.cpp
├── analysis/
│ └── AnalyserNodeHostObject.h/.cpp
├── destinations/
│ └── AudioDestinationNodeHostObject.h
├── effects/
│ ├── GainNodeHostObject.h/.cpp
│ ├── BiquadFilterNodeHostObject.h/.cpp
│ ├── DelayNodeHostObject.h/.cpp
│ ├── IIRFilterNodeHostObject.h/.cpp
│ ├── StereoPannerNodeHostObject.h/.cpp
│ ├── WaveShaperNodeHostObject.h/.cpp
│ ├── ConvolverNodeHostObject.h/.cpp
│ ├── WorkletNodeHostObject.h/.cpp
│ └── WorkletProcessingNodeHostObject.h/.cpp
├── sources/
│ ├── AudioScheduledSourceNodeHostObject.h/.cpp # Base for timed sources
│ ├── AudioBufferBaseSourceNodeHostObject.h/.cpp # Base for buffer sources
│ ├── OscillatorNodeHostObject.h/.cpp
│ ├── AudioBufferSourceNodeHostObject.h/.cpp
│ ├── AudioBufferQueueSourceNodeHostObject.h/.cpp
│ ├── ConstantSourceNodeHostObject.h/.cpp
│ ├── StreamerNodeHostObject.h/.cpp
│ ├── AudioBufferHostObject.h/.cpp # Data container, not a node
│ └── RecorderAdapterNodeHostObject.h/.cpp
├── inputs/
│ └── AudioRecorderHostObject.h/.cpp
├── events/
│ └── AudioEventHandlerRegistryHostObject.h/.cpp
└── utils/
├── JsEnumParser.h/.cpp # Enum ↔ string conversions
├── NodeOptionsParser.h # Parses JS option objects into C++ structs
├── AudioDecoderHostObject.h/.cpp
└── AudioStretcherHostObject.h/.cpp
Macro System
All HostObjects use macros defined in jsi/JsiHostObject.h. Always use these — never write raw JSI dispatch code.
Declaration macros (in .h)
JSI_PROPERTY_GETTER_DECL(gain)
JSI_PROPERTY_SETTER_DECL(gain)
JSI_HOST_FUNCTION_DECL(setValueAtTime)
Implementation macros (in .cpp)
JSI_PROPERTY_GETTER_IMPL(GainNodeHostObject, gain) { ... }
JSI_PROPERTY_SETTER_IMPL(GainNodeHostObject, gain) { ... }
JSI_HOST_FUNCTION_IMPL(GainNodeHostObject, setValueAtTime) { ... }
Registration macros (in constructor)
addGetters(
JSI_EXPORT_PROPERTY_GETTER(GainNodeHostObject, gain));
addSetters(
JSI_EXPORT_PROPERTY_SETTER(GainNodeHostObject, fftSize));
addFunctions(
JSI_EXPORT_FUNCTION(GainNodeHostObject, connect),
JSI_EXPORT_FUNCTION(GainNodeHostObject, disconnect));
All getters, setters, and functions must be registered in the constructor. Anything not registered is invisible to JS.
Shadow State
Shadow state is the core pattern for JS↔audio-thread communication (introduced in PR #942).
The Problem
The audio node's C++ state is read and written on the audio thread. JS runs on a different thread. Without shadow state, reading a property from JS would require either a lock (forbidden on the audio thread) or an atomic (only works for primitives).
The Solution
The HostObject maintains its own copy of the node's properties — the shadow state. This copy:
- Is read/written only by the JS thread
- Is always in sync with what JS last set
- Is the source of truth for JS reads
When JS sets a property:
- Update the shadow copy immediately (JS thread)
- Schedule an event on
CrossThreadEventScheduler that will apply the change on the audio thread
When JS reads a property:
- Return the shadow copy — do not touch the C++ node
class OscillatorNodeHostObject : public AudioScheduledSourceNodeHostObject {
public:
JSI_PROPERTY_GETTER_DECL(type);
JSI_PROPERTY_SETTER_DECL(type);
private:
OscillatorType type_;
};
JSI_PROPERTY_GETTER_IMPL(OscillatorNodeHostObject, type) {
return jsi::String::createFromUtf8(
runtime, js_enum_parser::oscillatorTypeToString(type_));
}
JSI_PROPERTY_SETTER_IMPL(OscillatorNodeHostObject, type) {
auto oscillatorNode = std::static_pointer_cast<OscillatorNode>(node_);
auto type = js_enum_parser::oscillatorTypeFromString(
value.asString(runtime).utf8(runtime));
type_ = type;
auto event = [oscillatorNode, type](BaseAudioContext &) {
oscillatorNode->setType(type);
};
oscillatorNode->scheduleAudioEvent(std::move(event));
}
When NOT to use shadow state
| Scenario | Pattern |
|---|
| Primitive, only written by JS | Shadow state (standard) |
| Non-primitive, only written by JS | Store in TS layer, pass to AudioNode when needed |
| Primitive, can be written by audio thread | std::atomic<T> on the C++ node; read directly via getter |
| Non-primitive, can be written by audio thread | Triple buffer pattern (see AnalyserNode for reference) |
AudioParam is a special case
AudioParam::value_ is std::atomic<float> because it can be updated by the audio thread during automation. The HO reads it directly:
JSI_PROPERTY_GETTER_IMPL(AudioParamHostObject, value) {
return {param_->getValue()};
}
JSI_PROPERTY_SETTER_IMPL(AudioParamHostObject, value) {
auto event = [param = param_, v = static_cast<float>(value.getNumber())](BaseAudioContext &) {
param->setValue(v);
};
param_->scheduleAudioEvent(std::move(event));
}
Shadow state must be initialized in the constructor
Initialize shadow members from options in the constructor — JS may read a property before ever setting it:
OscillatorNodeHostObject::OscillatorNodeHostObject(...) {
type_ = options.type;
}
Argument Parsing
Primitives
float v = static_cast<float>(args[0].getNumber());
double d = args[0].getNumber();
int i = static_cast<int>(args[0].getNumber());
bool b = args[0].getBool();
std::string s = args[0].getString(runtime).utf8(runtime);
Optional arguments — check count first
double duration = (count > 2 && !args[2].isUndefined()) ? args[2].getNumber() : -1.0;
Use jsiutils::argToString(runtime, args, count, index, defaultValue) for optional string args.
TypedArrays (Float32Array, Uint8Array, etc.)
JS typed arrays are passed as objects with a .buffer property:
JSI_HOST_FUNCTION_IMPL(AnalyserNodeHostObject, getByteFrequencyData) {
auto arrayBuffer = args[0]
.getObject(runtime)
.getPropertyAsObject(runtime, "buffer")
.getArrayBuffer(runtime);
auto data = arrayBuffer.data(runtime);
auto length = static_cast<int>(arrayBuffer.size(runtime));
auto analyserNode = std::static_pointer_cast<AnalyserNode>(node_);
analyserNode->getByteFrequencyData(data, length);
return jsi::Value::undefined();
}
For Float32Arrays (reinterpret the bytes):
auto rawValues = reinterpret_cast<float *>(arrayBuffer.data(runtime));
auto length = static_cast<int>(arrayBuffer.size(runtime) / sizeof(float));
HostObject arguments (node-to-node)
JSI_HOST_FUNCTION_IMPL(AudioNodeHostObject, connect) {
auto obj = args[0].getObject(runtime);
if (obj.isHostObject<AudioNodeHostObject>(runtime)) {
auto other = obj.getHostObject<AudioNodeHostObject>(runtime);
node_->connect(other->node_);
} else if (obj.isHostObject<AudioParamHostObject>(runtime)) {
auto param = obj.getHostObject<AudioParamHostObject>(runtime);
node_->connect(param->param_);
}
return jsi::Value::undefined();
}
Extracting a HostObject's inner C++ object
auto periodicWave = args[0]
.getObject(runtime)
.getHostObject<PeriodicWaveHostObject>(runtime);
oscillatorNode->setPeriodicWave(periodicWave->periodicWave_);
Return Value Patterns
Primitives
return {fftSize_};
return {true};
return jsi::String::createFromUtf8(runtime, "suspended");
return jsi::Value::undefined();
return jsi::Value::null();
A HostObject
JSI_PROPERTY_GETTER_IMPL(GainNodeHostObject, gain) {
auto gainNode = std::static_pointer_cast<GainNode>(node_);
auto gainParam = std::make_shared<AudioParamHostObject>(gainNode->getGainParam());
return jsi::Object::createFromHostObject(runtime, gainParam);
}
A plain JS object
auto result = jsi::Object(runtime);
result.setProperty(runtime, "status", jsi::String::createFromUtf8(runtime, "success"));
result.setProperty(runtime, "path", jsi::String::createFromUtf8(runtime, path));
return result;
A Float32Array wrapping native memory
JSI_HOST_FUNCTION_IMPL(AudioBufferHostObject, getChannelData) {
auto channel = static_cast<int>(args[0].getNumber());
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());
return float32Array;
}
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:
jsiObject.setExternalMemoryPressure(runtime, bufferHostObject->getSizeInBytes());
Enum Parsing
Use JsEnumParser (utils/JsEnumParser.h) for all enum ↔ string conversions. Never hardcode strings.
auto type = js_enum_parser::oscillatorTypeFromString(
value.asString(runtime).utf8(runtime));
return jsi::String::createFromUtf8(
runtime, js_enum_parser::oscillatorTypeToString(type_));
When adding a new enum, add both directions to JsEnumParser.
Destructor: Clearing Callbacks
When a HostObject is garbage collected, registered audio callbacks must be cleared. Otherwise the audio thread fires into a destroyed JS function.
AudioScheduledSourceNodeHostObject::~AudioScheduledSourceNodeHostObject() {
auto node = std::static_pointer_cast<AudioScheduledSourceNode>(node_);
node->setOnEndedCallbackId(0);
}
Apply this for every std::atomic<uint64_t> callback ID on the node.
TypeScript Counterpart
Each HO must have a matching TS interface and class in packages/react-native-audio-api/src/core/.
export interface IGainNode extends IAudioNode {
readonly gain: IAudioParam;
}
class GainNode extends AudioNode {
readonly gain: AudioParam;
constructor(context: BaseAudioContext, options?: TGainOptions) {
const gainNode: IGainNode = context.context.createGain(options ?? {});
super(context, gainNode);
this.gain = new AudioParam(gainNode.gain, context);
}
}
See the turbo-modules skill for full TS wiring details.
Adding to BaseAudioContextHostObject
Every new node needs a factory method. Three steps:
1. Declare in BaseAudioContextHostObject.h
JSI_HOST_FUNCTION_DECL(createMyNode);
2. Register in BaseAudioContextHostObject constructor
addFunctions(
JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createMyNode));
3. Implement in BaseAudioContextHostObject.cpp
JSI_HOST_FUNCTION_IMPL(BaseAudioContextHostObject, createMyNode) {
MyNodeOptions options = NodeOptionsParser::parseMyNodeOptions(runtime, args, count);
auto myNode = std::make_shared<MyNodeHostObject>(context_, options);
return jsi::Object::createFromHostObject(runtime, myNode);
}
Also add createMyNode() to the C++ BaseAudioContext factory — see the audio-nodes skill.
Maintenance: see maintenance.md.