Skip to main content

audio-nodes

C++ audio node engine for react-native-audio-api. Covers the AudioNode class hierarchy, the processNode() audio-thread contract (no allocs, no locks, no blocking I/O), AudioParam a-rate/k-rate processing, cross-thread communication patterns (CrossThreadEventScheduler, IAudioEventHandlerRegistry), and a step-by-step checklist for implementing a new node end-to-end. Use this skill when implementing a new Web Audio API node, modifying audio graph traversal or processing logic, or debugging audio rendering artifacts. Trigger phrases: "add a new node", "implement AudioNode", "processNode", "audio thread", "AudioParam automation".

소스 정보

저장소
software-mansion/react-native-audio-api
최근 소스 활동
2026년 10월 6일 14:47
감지된 SKILL.md 언어
영어
스타
848
포크
94

설치 방법

기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.

소스 파일 검토

설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.

파일 탐색기
3 개 파일

SKILL.md 표시 중

SKILL.md
소스 지침 · 읽기 전용 미리보기
name
audio-nodes
description
C++ audio node engine for react-native-audio-api. Covers the AudioNode class hierarchy, the processNode() audio-thread contract (no allocs, no locks, no blocking I/O), AudioParam a-rate/k-rate processing, cross-thread communication patterns (CrossThreadEventScheduler, IAudioEventHandlerRegistry), and a step-by-step checklist for implementing a new node end-to-end. Use this skill when implementing a new Web Audio API node, modifying audio graph traversal or processing logic, or debugging audio rendering artifacts. Trigger phrases: "add a new node", "implement AudioNode", "processNode", "audio thread", "AudioParam automation".
# Skill: AudioNodes Golden references: `GainNode.h/.cpp` (effect node), `OscillatorNode.h/.cpp` (scheduled source). Mirror their structure for any new node. See [gainnode-example.md](gainnode-example.md) for an annotated header + .cpp. **If spec defaults or parameter ranges are unclear → fetch https://webaudio.github.io/web-audio-api/ before writing any constructor code.** --- ## Directory Structure ``` common/cpp/audioapi/core/ ├── AudioNode.h / .cpp # Base class for all nodes ├── AudioParam.h / .cpp # Automatable parameter ├── BaseAudioContext.h / .cpp # Engine + node factory ├── AudioContext.h / .cpp # Real-time context ├── OfflineAudioContext.h / .cpp # Offline rendering context ├── sources/ │ ├── AudioScheduledSourceNode.h # Base for start/stop sources (INTERNAL) │ ├── AudioBufferBaseSourceNode.h # Base for buffer playback (INTERNAL) │ ├── OscillatorNode.h / .cpp │ ├── AudioBufferSourceNode.h / .cpp │ ├── AudioBufferQueueSourceNode.h / .cpp │ ├── ConstantSourceNode.h / .cpp │ ├── WorkletSourceNode.h / .cpp │ └── RecorderAdapterNode.h / .cpp ├── effects/ │ ├── GainNode.h / .cpp │ ├── BiquadFilterNode.h / .cpp │ ├── DelayNode.h / .cpp │ ├── IIRFilterNode.h / .cpp │ ├── StereoPannerNode.h / .cpp │ ├── WaveShaperNode.h / .cpp │ ├── ConvolverNode.h / .cpp │ ├── WorkletNode.h / .cpp │ ├── channel_merger/ # ChannelMerger internal input/output nodes (composite) │ ├── channel_splitter/ # ChannelSplitter internal input/output nodes (composite) │ └── PeriodicWave.h / .cpp # Wave table (not a node) ├── analysis/ │ └── AnalyserNode.h / .cpp ├── destinations/ │ └── AudioDestinationNode.h / .cpp ├── inputs/ │ └── AudioRecorder.h / .cpp └── utils/ └── AudioGraphManager.h / .cpp ``` --- ## The Audio Thread Contract `processNode()` runs on the **audio thread** — the real-time rendering thread driven by the native audio driver (Oboe on Android, CoreAudio on iOS). This thread has strict requirements: **MUST NOT in `processNode()`:** - Allocate or free memory (`new`, `delete`, `malloc`, `free`, `std::vector::push_back` that grows, etc.) - Acquire any mutex or lock (`std::mutex`, `std::lock_guard`, etc.) - Make any blocking syscall (file I/O, socket, `sleep`, `wait`) - Call into JavaScript — no JSI calls, no `callInvoker_->invokeSync()` - Throw exceptions (or rely on exception unwinding paths that allocate) **Preallocate everything in the constructor:** ```cpp // Constructor — JS thread, allocations OK GainNode::GainNode(const std::shared_ptr<BaseAudioContext> &context, const GainOptions &options) : AudioNode(context, options) { // Preallocate the AudioBuffer used during processing audioBuffer_ = std::make_shared<AudioBuffer>(channelCount_, context->getBufferSize()); // Preallocate params — they own their internal AudioBuffer too gainParam_ = std::make_shared<AudioParam>( options.gain, -3.4028234663852886e+38f, 3.4028234663852886e+38f, context); } // processNode — audio thread, NO allocations std::shared_ptr<AudioBuffer> GainNode::processNode( const std::shared_ptr<AudioBuffer> &processingBuffer, int framesToProcess) { // Already-allocated buffer reused each render quantum auto gainValues = gainParam_->processARateParam(framesToProcess, time); for (size_t i = 0; i < processingBuffer->getNumberOfChannels(); i++) { processingBuffer->getChannel(i)->multiply(*gainValues->getChannel(0), framesToProcess); } return processingBuffer; } ``` --- ## Processable State (reverse-topo pull) Which nodes run each render quantum is decided by `AudioGraph::settleProcessableState()`, run inside `Graph::process()` after toposort/compaction and before the forward `iter()` pass. It is an **audio-thread-only** concern — never derived from HostGraph adjacency (that mutates on the JS thread under `nodesMutex_`). `GraphObject::PROCESSABLE_STATE`: - `ALWAYS_PROCESSABLE` — seed / pull root. Set in ctors of `AudioDestinationNode` and `AnalyserNode`. Never reset by settle. - `CONDITIONAL_PROCESSABLE` — on this quantum because something processable downstream pulls it. Recomputed from scratch every quantum. - `NOT_PROCESSABLE` — idle / disconnected / default. Settle algorithm (allocation-free): 1. **Reverse pull**: walk the topo-sorted node array sinks → sources; for every `ALWAYS_`/`CONDITIONAL_PROCESSABLE` node, mark its inputs (and processable-links) `CONDITIONAL_PROCESSABLE`. Iterates to a fixpoint for processable-links. 2. **End-of-quantum demotion**: after `processInputs()`, each node that was `CONDITIONAL_PROCESSABLE` flips back to `NOT_PROCESSABLE` in `GraphObject::process()`. That replaces a global reset at the start of settle — nodes that ran last quantum are already idle when the next pull begins. Key invariants: - **Pull from `processableState_`, never `AudioNode::isProcessable()`.** A tail-bearing node (Delay/Convolver/Biquad) overrides `isProcessable()` to stay `true` while its tail drains after a disconnect; using that for the pull would wrongly re-activate its whole upstream cone. The tail node stays scheduled via that override; its `processableState_` is `NOT_PROCESSABLE`, so it correctly does not pull upstream. - **`disable()` is sticky.** `AudioNode::disable()` sets `NOT_PROCESSABLE` **and** `alwaysNotProcessable_ = true`, so a finished source still wired to a live consumer is not re-activated by the every-quantum pull. Sources call `disable()` from the audio thread when playback finishes. - **DelayReader → DelayWriter** have no audio edge (they share a ring buffer). `Graph::linkNodes(reader, writer)` records a processable-link, mirrored onto `AudioGraph::Node::link_head`. Settle follows links so pulling the reader also pulls the writer and the writer's inputs. Links are NOT part of the topological sort (that would create a cycle for feedback delays). --- ## Class Hierarchy ```mermaid classDiagram direction TD class AudioScheduledSourceNode { <<internal base>> start(when) stop(when) } class AudioBufferBaseSourceNode { <<internal base>> playbackRate AudioParam detune AudioParam } AudioNode <|-- AudioScheduledSourceNode AudioNode <|-- GainNode AudioNode <|-- BiquadFilterNode AudioNode <|-- DelayNode AudioNode <|-- IIRFilterNode AudioNode <|-- StereoPannerNode AudioNode <|-- WaveShaperNode AudioNode <|-- ConvolverNode AudioNode <|-- WorkletNode AudioNode <|-- AnalyserNode AudioNode <|-- AudioDestinationNode AudioScheduledSourceNode <|-- AudioBufferBaseSourceNode AudioScheduledSourceNode <|-- OscillatorNode AudioScheduledSourceNode <|-- ConstantSourceNode AudioScheduledSourceNode <|-- WorkletSourceNode AudioBufferBaseSourceNode <|-- AudioBufferSourceNode AudioBufferBaseSourceNode <|-- AudioBufferQueueSourceNode ``` ### AudioRecorder (not an AudioNode) `core/inputs/AudioRecorder` is a standalone base class, not part of the `AudioNode` hierarchy — it feeds the graph through a `RecorderAdapterNode` instead of being processed by it. The split between it and `IOSAudioRecorder` / `AndroidAudioRecorder` is: the base owns everything that happens to recorded frames (file writer, JS callback, adapter node — `enableFileOutput`, `setupFileWriter`, `setOnAudioReadyCallback`, `connect`, `detachSideEffects`/`finalizeSideEffects`), the subclasses own only the platform input stream. The one thing the base needs from the platform is `resolveStreamFormat()`, returning sample rate, channel count and max frames per buffer; iOS reads it from `NativeAudioRecorder` on every call (a route change invalidates it), Android returns values cached when the Oboe stream opened. Add shared recorder behavior to the base, not to one platform; state only one platform touches (Android's `streamMutex_`, iOS's `inputChannelCount_`) lives in that subclass. State a consumer already owns stays with the consumer: the adapter node's ring layout (`RecorderAdapterNode::writeFrames`) and the session's file paths (returned by `AudioFileWriter::closeFile()`) are not mirrored in the recorder. Pitfall: never redeclare a base member (`streamSampleRate_`, `fileWriter_`, `lastCallbackFrameCount_`) in a platform recorder. The shadowing copy compiles fine, but the base's audio-thread fan-out reads its own member and silently drops that output. --- ### AudioScheduledSourceNode (internal only — not exposed to JS directly) Base class for source nodes that have a scheduled start and stop time. **Not instantiated directly.** ```cpp // Playback state machine enum class PlaybackState { UNSCHEDULED, // before start() called SCHEDULED, // start() called, waiting for startTime_ PLAYING, // actively producing audio STOP_SCHEDULED, // stop() called, waiting for stopTime_ FINISHED // done, node will be disabled }; ``` Subclasses call `updatePlaybackInfo(currentTime, framesToProcess)` at the top of `processNode()` to transition the state machine and handle sample-accurate start/stop. When the node finishes, `AudioScheduledSourceNode::disable()` fires `ENDED` through its `EventCaller` (`dispatchEmptyFromAudioThread`); a node that will never render (stop-before-start) defers it with `EventCaller::deferEmpty` instead — see `thread-safety-itc`. Pitfalls: - `start(double)` in this base class is a separate overload from `AudioBufferSourceNode::start(when, offset, duration)`. Calling `node->start(when)` on an ABSN skips the derived logic; the HostObject always passes three arguments, and so must tests. - `AudioBufferBaseSourceNode::processNode` is `final` and returns early on `isEmpty()`. Whether a started-but-empty source ends there (`ended` fires) is `endsWhenStartedEmpty()`: the base default is true (spec: a null buffer forces `stop = currentTime`); `AudioBufferQueueSourceNode` overrides it to false because an empty queue is waiting for the next enqueue. The check runs per quantum on the audio thread on purpose, because the buffer setter legitimately acquires content assigned after `start()` but before the first render. --- ## processNode() Signature ```cpp protected: /// @note Audio Thread only virtual std::shared_ptr<AudioBuffer> processNode( const std::shared_ptr<AudioBuffer> &processingBuffer, int framesToProcess) = 0; ``` - `processingBuffer` — already contains the mixed input from all connected input nodes. Modify in-place and return it. - `framesToProcess` — number of samples per channel to process, typically 128 (RENDER_QUANTUM_SIZE). - Called by `AudioNode::processAudio()` which handles input mixing, channel count modes, and deduplication (via `lastRenderedFrame_`). --- ## Thread Annotations in Header Files **Annotate every method with the thread it is safe to call from.** Use comments in the header: ```cpp class MyNode : public AudioNode { public: /// @note JS Thread only void setSomething(float value); float getSomething() const; protected: /// @note Audio Thread only std::shared_ptr<AudioBuffer> processNode( const std::shared_ptr<AudioBuffer> &processingBuffer, int framesToProcess) override; }; ``` In `AudioParam.h` the pattern is: ```cpp /// @note JS Thread only [[nodiscard]] inline float getValue() const noexcept { ... } void setValue(float value); void setValueAtTime(float value, double startTime); /// Audio-Thread only methods (idempotent per quantum — see below) std::shared_ptr<DSPAudioBuffer> processARateParam(int framesToProcess, double time); float processKRateParam(double time); // k-rate is quantum-wide ``` --- ## AudioParam — Automatable Parameters Every automatable property (frequency, gain, detune, Q, etc.) is an `AudioParam`. ```cpp gainParam_ = std::make_shared<AudioParam>( defaultValue, minValue, maxValue, context ); ``` ### A-rate vs K-rate - **A-rate (audio-rate)**: one value per sample — use when the parameter can change significantly within a render quantum (e.g. frequency modulation) ```cpp // Call processARateParam() for per-sample values — returns AudioBuffer, no allocation auto gainValues = gainParam_->processARateParam(framesToProcess, time); float *values = gainValues->getChannel(0)->getData(); // values[i] is the gain for frame i ``` - **K-rate (control-rate)**: one value per render quantum — use when the parameter changes slowly ```cpp // Call processKRateParam() for a single quantum-wide value float gain = gainParam_->processKRateParam(time); // Single value for the whole block ``` ### Param class hierarchy & idempotency `AudioParam` and `CompositeAudioParam<CombineFunction>` both derive from the abstract `GeneralizedAudioParam` base (`core/GeneralizedAudioParam.h`), which owns the nominal range, the a-rate `outputBuffer_`, and the per-quantum memoization state, and centralizes clamping via `finalizeKRate` / `finalizeARate`. - **`AudioParam`** — the only JS-connectable param; owns `inputBuffer_` (BridgeNode modulation). - **`CompositeAudioParam<Fn>`** (`core/CompositeAudioParam.hpp`) — represents a spec `computedValue` (e.g. `computedOscFrequency`). `Fn` is a pure, captureless free function (defined in the owning node's header, next to the composite member) taking float children and returning float; its arity is deduced. It processes each child, folds `Fn` over them, and clamps to its own nominal range. No `inputBuffer_`. `processKRateParam(time)` / `processARateParam(frames, time)` are **idempotent**: a repeat call with the same arguments returns the cached result and does **not** re-consume modulation. This is why a composite and a node can both read the same child param in one quantum. It also means `processNode()` should read `context->getCurrentTime()` **once** and thread that same `double` into every param call so the cache keys match (the context clock is constant within a quantum). Consequently, unit tests that re-process the same node must advance the clock (e.g. `context->processGraph(buffer.get(), frames)`) between renders. **Clamping (§ 1.6.3):** automation intrinsic values are computed **without** clamping (see `getValueAtTimeUnmodulated` / `ParamRenderQueue`). Clip only in `finalizeKRate` / `finalizeARate` after adding modulation — never on the intrinsic alone before modulation. **Automation timing model (scheduled times, snapped on demand):** a `RenderParamEvent` stores only its scheduled `startTime`/`endTime`, and `calculateValueAtTime` interpolates on them unmodified (the spec defines the formulas on real times; a ramp between two times inside one frame must still interpolate on those times — WPT `audioparam-close.html`). Effect boundaries live on the frame grid instead: `ParamRenderQueue::computeValueAtTime` calls `snapToSampleFrameTime` (`std::round(t * sampleRate) / sampleRate`) on the scheduled times whenever it decides which event is in effect, matching Blink and the WPT reference's `timeToSampleFrame`. Nothing stores the snapped values, so resolving neighbours (`resolveEventValues`, `cancelAndHoldAtTime`) only ever touches the scheduled times. A queued event supersedes the current one as soon as its snapped start is due, even mid-ramp; ramps stay active until their scheduled end, other finite events end on their snapped end. On the evaluation side, `processARateParam` derives each sample's time as `(quantumStartFrame + i) / sampleRate` — never accumulate `time += 1/sampleRate`, the ULP drift lands boundaries one frame late. **Never snap an automation time through an integer type.** `snapToSampleFrameTime` rounds in `double` instead of reusing `dsp::timeToSampleFrame`, which returns a frame index as `size_t`. Automation times are unvalidated user input: nothing in `AudioParam` or its host object rejects negative or absurd values, and WPT schedules ramps at `1e300` (`audioparam-large-endtime.html`). A `double → size_t → int` round trip on that is undefined behaviour, and in practice produced a small *negative* time that sorted the event ahead of everything legitimately scheduled. The same unguarded cast is still reachable from `AudioScheduledSourceNode`'s `startTime_`/`stopTime_` conversions. **Param queue capacity:** both param event queues (`ParamRenderQueue` on `AudioParam`, `ParamControlQueue` on the host object) are bounded by `AUDIO_PARAM_MAX_QUEUED_EVENTS` and **silently drop** events past capacity (`BoundedPriorityQueue::push` returns false, nobody checks). Automation scheduled far ahead must fit entirely; the WPT audioparam suites queue 100 events per file. Symptom of overflow: automation freezes at the last accepted event. ### JS → Audio Thread parameter updates `CrossThreadEventScheduler<T>` is a lock-free SPSC channel. When JS calls `param.setValueAtTime(...)`, it enqueues a lambda on the scheduler. The audio thread drains the queue at the start of each `processARateParam` / `processKRateParam` call. ```cpp // JS-thread (in AudioParam): void AudioParam::setValueAtTime(float value, double startTime) { eventScheduler_.scheduleEvent([value, startTime](AudioParam &param) {
GitHub에서 보기
이 SKILL.md는 매우 커서 SkillsMP가 여기에는 첫 섹션만 미리 보여줍니다. GitHub에서 보기