From 9c940e6b30762d1ddc10d1e79154ce50127b5088 Mon Sep 17 00:00:00 2001 From: Barbara Wojtarowicz Date: Tue, 21 Jul 2026 17:36:37 +0200 Subject: [PATCH 01/13] feat: add PannerOptions to NodeOptions.h --- .../common/cpp/audioapi/types/NodeOptions.h | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/packages/react-native-audio-api/common/cpp/audioapi/types/NodeOptions.h b/packages/react-native-audio-api/common/cpp/audioapi/types/NodeOptions.h index 53fdd113f..6d097d155 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/types/NodeOptions.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/types/NodeOptions.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -54,6 +55,55 @@ struct StereoPannerOptions : AudioNodeOptions { } }; +// enum class PanningModelType { +// EqualPower, +// HRTF +// }; + +// enum class DistanceModelType { +// Inverse, +// Linear, +// Exponential +// }; +enum class PanningModelType : std::uint8_t { EqualPower, HRTF }; + +enum class DistanceModelType : std::uint8_t { Inverse, Linear, Exponential }; + +struct PannerOptions : public AudioNodeOptions { + static constexpr double kDefaultRefDistance = 1.0; + static constexpr double kDefaultMaxDistance = 10000.0; + static constexpr double kDefaultRolloffFactor = 1.0; + static constexpr double kDefaultConeAngle = 360.0; + static constexpr double kDefaultConeOuterGain = 0.0; + + PanningModelType panningModel = PanningModelType::EqualPower; + DistanceModelType distanceModel = DistanceModelType::Inverse; + + float positionX = 0.0f; + float positionY = 0.0f; + float positionZ = 0.0f; + + float orientationX = 1.0f; + float orientationY = 0.0f; + float orientationZ = 0.0f; + + double refDistance = kDefaultRefDistance; + double maxDistance = kDefaultMaxDistance; + double rolloffFactor = kDefaultRolloffFactor; + + double coneInnerAngle = kDefaultConeAngle; + double coneOuterAngle = kDefaultConeAngle; + double coneOuterGain = kDefaultConeOuterGain; + + PannerOptions() { + channelCountMode = ChannelCountMode::CLAMPED_MAX; + } + + explicit PannerOptions(const AudioNodeOptions &options) : AudioNodeOptions(options) { + channelCountMode = ChannelCountMode::CLAMPED_MAX; + } +}; + struct ConvolverOptions : AudioNodeOptions { bool disableNormalization = false; std::shared_ptr buffer = nullptr; From 3cc3225239cd74874bfa3f8e05ca6e40779e7cd0 Mon Sep 17 00:00:00 2001 From: Barbara Wojtarowicz Date: Tue, 21 Jul 2026 18:00:19 +0200 Subject: [PATCH 02/13] feat: add PannerNode.cpp skeleton --- .../cpp/audioapi/core/effects/PannerNode.cpp | 154 ++++++++++++++++++ .../cpp/audioapi/core/effects/PannerNode.h | 82 ++++++++++ 2 files changed, 236 insertions(+) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.h diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.cpp new file mode 100644 index 000000000..4587fed1a --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.cpp @@ -0,0 +1,154 @@ +#include +#include +#include +#include +#include + +#include +#include + +namespace audioapi { + +PannerNode::PannerNode( + const std::shared_ptr &context, + const PannerOptions &options) + : AudioNode(context, options), + positionXParam_(std::make_shared(options.positionX, -FLT_MAX, FLT_MAX, context)), + positionYParam_(std::make_shared(options.positionY, -FLT_MAX, FLT_MAX, context)), + positionZParam_(std::make_shared(options.positionZ, -FLT_MAX, FLT_MAX, context)), + orientationXParam_( + std::make_shared(options.orientationX, -FLT_MAX, FLT_MAX, context)), + orientationYParam_( + std::make_shared(options.orientationY, -FLT_MAX, FLT_MAX, context)), + orientationZParam_( + std::make_shared(options.orientationZ, -FLT_MAX, FLT_MAX, context)), + panningModel_(options.panningModel), + distanceModel_(options.distanceModel), + refDistance_(options.refDistance), + maxDistance_(options.maxDistance), + rolloffFactor_(options.rolloffFactor), + coneInnerAngle_(options.coneInnerAngle), + coneOuterAngle_(options.coneOuterAngle), + coneOuterGain_(options.coneOuterGain), + outputBuffer_( + std::make_shared( + RENDER_QUANTUM_SIZE, + channelCount_, + context->getSampleRate())) {} + +std::shared_ptr PannerNode::getPositionXParam() const { + return positionXParam_; +} + +std::shared_ptr PannerNode::getPositionYParam() const { + return positionYParam_; +} + +std::shared_ptr PannerNode::getPositionZParam() const { + return positionZParam_; +} + +std::shared_ptr PannerNode::getOrientationXParam() const { + return orientationXParam_; +} + +std::shared_ptr PannerNode::getOrientationYParam() const { + return orientationYParam_; +} + +std::shared_ptr PannerNode::getOrientationZParam() const { + return orientationZParam_; +} + +void PannerNode::setPanningModel(PanningModelType model) { + panningModel_ = model; +} + +PanningModelType PannerNode::getPanningModel() const { + return panningModel_; +} + +void PannerNode::setDistanceModel(DistanceModelType model) { + distanceModel_ = model; +} + +DistanceModelType PannerNode::getDistanceModel() const { + return distanceModel_; +} + +void PannerNode::setRefDistance(double distance) { + refDistance_ = distance; +} + +double PannerNode::getRefDistance() const { + return refDistance_; +} + +void PannerNode::setMaxDistance(double distance) { + maxDistance_ = distance; +} + +double PannerNode::getMaxDistance() const { + return maxDistance_; +} + +void PannerNode::setRolloffFactor(double factor) { + rolloffFactor_ = factor; +} + +double PannerNode::getRolloffFactor() const { + return rolloffFactor_; +} + +void PannerNode::setConeInnerAngle(double angle) { + coneInnerAngle_ = angle; +} + +double PannerNode::getConeInnerAngle() const { + return coneInnerAngle_; +} + +void PannerNode::setConeOuterAngle(double angle) { + coneOuterAngle_ = angle; +} + +double PannerNode::getConeOuterAngle() const { + return coneOuterAngle_; +} + +void PannerNode::setConeOuterGain(double gain) { + coneOuterGain_ = gain; +} + +double PannerNode::getConeOuterGain() const { + return coneOuterGain_; +} + +std::shared_ptr PannerNode::getOutputBuffer() const { + return outputBuffer_; +} + +std::shared_ptr PannerNode::getNegotiatedBuffer() const { + return getInputBuffer(); +} + +void PannerNode::setNegotiatedBuffer(const std::shared_ptr &buffer) { + audioBuffer_ = buffer; +} + +size_t PannerNode::getUpstreamChannelCount(size_t /*negotiatedChannelCount*/) const { + return outputBuffer_->getNumberOfChannels(); +} + +void PannerNode::processNode(int framesToProcess) { + std::shared_ptr context = context_.lock(); + if (context == nullptr || audioBuffer_ == nullptr) { + return; + } + + // TODO (Krok 3): Tutaj zaimplementujemy matematykę 3D, tłumienie dystansu (Inverse) + // oraz podział na kanały algorytmem EqualPower. Na czas testu kompilacji + // zostawiamy pusty przebieg — węzeł wyprodukuje bezpieczną ciszę w outputBuffer_. +} + +} // namespace audioapi \ No newline at end of file diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.h new file mode 100644 index 000000000..b9740c6da --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.h @@ -0,0 +1,82 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace audioapi { + +class PannerNode : public AudioNode { + public: + PannerNode(const std::shared_ptr &context, const PannerOptions &options); + + ~PannerNode() override = default; + + [[nodiscard]] std::shared_ptr getPositionXParam() const; + [[nodiscard]] std::shared_ptr getPositionYParam() const; + [[nodiscard]] std::shared_ptr getPositionZParam() const; + + [[nodiscard]] std::shared_ptr getOrientationXParam() const; + [[nodiscard]] std::shared_ptr getOrientationYParam() const; + [[nodiscard]] std::shared_ptr getOrientationZParam() const; + + void setPanningModel(PanningModelType model); + [[nodiscard]] PanningModelType getPanningModel() const; + + void setDistanceModel(DistanceModelType model); + [[nodiscard]] DistanceModelType getDistanceModel() const; + + void setRefDistance(double distance); + [[nodiscard]] double getRefDistance() const; + + void setMaxDistance(double distance); + [[nodiscard]] double getMaxDistance() const; + + void setRolloffFactor(double factor); + [[nodiscard]] double getRolloffFactor() const; + + void setConeInnerAngle(double angle); + [[nodiscard]] double getConeInnerAngle() const; + + void setConeOuterAngle(double angle); + [[nodiscard]] double getConeOuterAngle() const; + + void setConeOuterGain(double gain); + [[nodiscard]] double getConeOuterGain() const; + + [[nodiscard]] std::shared_ptr getOutputBuffer() const override; + [[nodiscard]] std::shared_ptr getNegotiatedBuffer() const override; + void setNegotiatedBuffer(const std::shared_ptr &buffer) override; + [[nodiscard]] size_t getUpstreamChannelCount(size_t negotiatedChannelCount) const override; + + protected: + void processNode(int framesToProcess) override; + [[nodiscard]] const DSPAudioBuffer *getOutput() const override { + return outputBuffer_.get(); + } + + private: + const std::shared_ptr positionXParam_; + const std::shared_ptr positionYParam_; + const std::shared_ptr positionZParam_; + const std::shared_ptr orientationXParam_; + const std::shared_ptr orientationYParam_; + const std::shared_ptr orientationZParam_; + + PanningModelType panningModel_; + DistanceModelType distanceModel_; + double refDistance_; + double maxDistance_; + double rolloffFactor_; + double coneInnerAngle_; + double coneOuterAngle_; + double coneOuterGain_; + + const std::shared_ptr outputBuffer_; +}; + +} // namespace audioapi \ No newline at end of file From 14b4180533ccf678507f559371e53900188c53f3 Mon Sep 17 00:00:00 2001 From: Barbara Wojtarowicz Date: Wed, 22 Jul 2026 12:16:09 +0200 Subject: [PATCH 03/13] feat: rnaa-243 second --- .claude/skills/host-objects/SKILL.md | 1 + .claude/skills/web-audio-api/SKILL.md | 5 +- .../audiodocs/docs/core/audio-listener.mdx | 2 +- .../docs/core/base-audio-context.mdx | 6 + .../audiodocs/docs/effects/panner-node.mdx | 67 +++++ .../docs/other/web-audio-api-coverage.mdx | 4 +- .../HostObjects/AudioListenerHostObject.h | 4 + .../BaseAudioContextHostObject.cpp | 12 + .../HostObjects/BaseAudioContextHostObject.h | 1 + .../effects/PannerNodeHostObject.cpp | 240 ++++++++++++++++++ .../effects/PannerNodeHostObject.h | 71 ++++++ .../HostObjects/utils/JsEnumParser.cpp | 44 ++++ .../audioapi/HostObjects/utils/JsEnumParser.h | 5 + .../HostObjects/utils/NodeOptionsParser.h | 74 ++++++ .../cpp/audioapi/core/AudioListener.cpp | 21 ++ .../common/cpp/audioapi/core/AudioListener.h | 48 ++++ .../cpp/audioapi/core/effects/PannerNode.cpp | 139 +++++++++- .../cpp/audioapi/core/effects/PannerNode.h | 11 +- .../core/effects/PannerSpatialization.h | 199 +++++++++++++++ .../common/cpp/audioapi/types/NodeOptions.h | 10 - .../cpp/test/src/core/effects/PannerTest.cpp | 94 +++++++ packages/react-native-audio-api/src/api.ts | 1 + .../react-native-audio-api/src/api.web.ts | 1 + .../src/core/BaseAudioContext.ts | 6 + .../src/core/PannerNode.ts | 116 +++++++++ .../src/jsi-interfaces.ts | 21 ++ .../react-native-audio-api/src/mock/index.ts | 160 ++++++++++++ packages/react-native-audio-api/src/types.ts | 21 ++ .../src/utils/validation/index.ts | 2 + .../src/utils/validation/panner.ts | 29 +++ .../src/web-core/AudioContext.web.ts | 5 + .../src/web-core/BaseAudioContext.web.ts | 2 + .../src/web-core/OfflineAudioContext.web.ts | 5 + .../src/web-core/PannerNode.web.ts | 98 +++++++ .../wpt_tests/wpt-api.js | 1 + 35 files changed, 1497 insertions(+), 29 deletions(-) create mode 100644 packages/audiodocs/docs/effects/panner-node.mdx create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/PannerNodeHostObject.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/PannerNodeHostObject.h create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerSpatialization.h create mode 100644 packages/react-native-audio-api/common/cpp/test/src/core/effects/PannerTest.cpp create mode 100644 packages/react-native-audio-api/src/core/PannerNode.ts create mode 100644 packages/react-native-audio-api/src/utils/validation/panner.ts create mode 100644 packages/react-native-audio-api/src/web-core/PannerNode.web.ts diff --git a/.claude/skills/host-objects/SKILL.md b/.claude/skills/host-objects/SKILL.md index 08cd1ec5c..856ab2f98 100644 --- a/.claude/skills/host-objects/SKILL.md +++ b/.claude/skills/host-objects/SKILL.md @@ -70,6 +70,7 @@ HostObjects/ │ ├── DelayNodeHostObject.h/.cpp │ ├── IIRFilterNodeHostObject.h/.cpp │ ├── StereoPannerNodeHostObject.h/.cpp +│ ├── PannerNodeHostObject.h/.cpp │ ├── WaveShaperNodeHostObject.h/.cpp │ ├── ConvolverNodeHostObject.h/.cpp │ ├── WorkletNodeHostObject.h/.cpp diff --git a/.claude/skills/web-audio-api/SKILL.md b/.claude/skills/web-audio-api/SKILL.md index bc97d86c6..6ac1e13cb 100644 --- a/.claude/skills/web-audio-api/SKILL.md +++ b/.claude/skills/web-audio-api/SKILL.md @@ -115,10 +115,11 @@ Current status (from `packages/audiodocs/docs/other/web-audio-api-coverage.mdx`) |---|---| | `AudioContext` | `close`, `suspend`, `resume`, `currentTime`, `destination`, `sampleRate`, `state` | | `BaseAudioContext` | `currentTime`, `destination`, `listener`, `sampleRate`, `state`, `decodeAudioData`, all `create*` for available nodes | -| `AudioListener` | All nine AudioParams; deprecated `setPosition` / `setOrientation` omitted; no audible effect until `PannerNode` | +| `AudioListener` | All nine AudioParams; used by `PannerNode` for 3D spatialization; deprecated `setPosition` / `setOrientation` omitted | +| `PannerNode` | Equal-power spatialization, distance models, cone gain; `HRTF` accepted but falls back to equal-power | ### Not yet implemented ❌ -`AudioSinkInfo`, `AudioWorklet`, `AudioWorkletGlobalScope`, `AudioWorkletNode`, `AudioWorkletProcessor`, `ChannelMergerNode`, `ChannelSplitterNode`, `DynamicsCompressorNode`, `MediaElementAudioSourceNode`, `MediaStreamAudioDestinationNode`, `MediaStreamAudioSourceNode`, `PannerNode` +`AudioSinkInfo`, `AudioWorklet`, `AudioWorkletGlobalScope`, `AudioWorkletNode`, `AudioWorkletProcessor`, `ChannelMergerNode`, `ChannelSplitterNode`, `DynamicsCompressorNode`, `MediaElementAudioSourceNode`, `MediaStreamAudioDestinationNode`, `MediaStreamAudioSourceNode` **Goal**: everything in the Web Audio API spec should eventually be in this library. If you implement a node from the ❌ list, update the coverage table in `packages/audiodocs/docs/other/web-audio-api-coverage.mdx`. diff --git a/packages/audiodocs/docs/core/audio-listener.mdx b/packages/audiodocs/docs/core/audio-listener.mdx index b9d3ce1db..b0f0f43c7 100644 --- a/packages/audiodocs/docs/core/audio-listener.mdx +++ b/packages/audiodocs/docs/core/audio-listener.mdx @@ -53,4 +53,4 @@ All properties are [`AudioParam`](/docs/core/audio-param)s with `minValue`/`maxV ## Remarks - The deprecated Web Audio API methods `setPosition(x, y, z)` and `setOrientation(x, y, z, xUp, yUp, zUp)` are intentionally **not implemented**. Set the corresponding `AudioParam` values directly instead. -- `PannerNode` — the consumer of these parameters — is not implemented yet, so changing the listener currently has no audible effect. The interface is provided ahead of `PannerNode` support. +- Used by [`PannerNode`](/docs/effects/panner-node) for 3D spatialization relative to the listener. diff --git a/packages/audiodocs/docs/core/base-audio-context.mdx b/packages/audiodocs/docs/core/base-audio-context.mdx index 874007a77..eef136c81 100644 --- a/packages/audiodocs/docs/core/base-audio-context.mdx +++ b/packages/audiodocs/docs/core/base-audio-context.mdx @@ -174,6 +174,12 @@ Creates [`StereoPannerNode`](/docs/effects/stereo-panner-node). **Returns:** `StereoPannerNode` +### `createPanner` + +Creates [`PannerNode`](/docs/effects/panner-node). + +**Returns:** `PannerNode` + ### `createWaveShaper` Creates [`WaveShaperNode`](/docs/effects/wave-shaper-node). diff --git a/packages/audiodocs/docs/effects/panner-node.mdx b/packages/audiodocs/docs/effects/panner-node.mdx new file mode 100644 index 000000000..770f72a0e --- /dev/null +++ b/packages/audiodocs/docs/effects/panner-node.mdx @@ -0,0 +1,67 @@ +--- +sidebar_position: 7 +--- + +import AudioNodePropsTable from "@site/src/components/AudioNodePropsTable" +import AudioNodeMethodsTable from "@site/src/components/AudioNodeMethodsTable" +import { Optional, ReadOnly } from '@site/src/components/Badges'; + +# PannerNode + +The [`PannerNode`](https://developer.mozilla.org/en-US/docs/Web/API/PannerNode) interface positions an audio source in 3D space relative to the context's [`AudioListener`](/docs/core/audio-listener). + +## Constructor + +```tsx +constructor(context: BaseAudioContext, options?: PannerOptions) +``` + +### `PannerOptions` + +Inherits all properties from [`AudioNodeOptions`](/docs/core/audio-node#audionodeoptions). + +| Parameter | Type | Default | | +| :---: | :---: | :----: | :---- | +| `panningModel` | `'equalpower' \| 'HRTF'` | `'equalpower'` | Spatialization algorithm. Native uses equal-power; `HRTF` is accepted but currently rendered with equal-power. | +| `distanceModel` | `'linear' \| 'inverse' \| 'exponential'` | `'inverse'` | Distance attenuation model. | +| `positionX` … `positionZ` | `number` | `0` | Initial source position. | +| `orientationX` … `orientationZ` | `number` | `(1, 0, 0)` | Initial source orientation. | +| `refDistance` | `number` | `1` | Reference distance for attenuation. | +| `maxDistance` | `number` | `10000` | Maximum distance for the linear model. | +| `rolloffFactor` | `number` | `1` | Rolloff for distance models. | +| `coneInnerAngle` / `coneOuterAngle` | `number` | `360` | Directional cone angles (degrees). | +| `coneOuterGain` | `number` | `0` | Gain outside the outer cone. | + +You can also create a `PannerNode` via [`BaseAudioContext.createPanner()`](/docs/core/base-audio-context#createpanner). + +## Properties + +Inherits all properties from [`AudioNode`](/docs/core/audio-node#properties). + + + +| Name | Type | Description | +| :----: | :----: | :-------- | +| `positionX`, `positionY`, `positionZ` | [`AudioParam`](/docs/core/audio-param) | Source position in 3D space. | +| `orientationX`, `orientationY`, `orientationZ` | [`AudioParam`](/docs/core/audio-param) | Source orientation vector. | +| `panningModel` | `PanningModelType` | `'equalpower'` or `'HRTF'`. | +| `distanceModel` | `DistanceModelType` | `'linear'`, `'inverse'`, or `'exponential'`. | +| `refDistance`, `maxDistance`, `rolloffFactor` | `number` | Distance model parameters. | +| `coneInnerAngle`, `coneOuterAngle`, `coneOuterGain` | `number` | Directional cone. | + +## Methods + +Inherits all methods from [`AudioNode`](/docs/core/audio-node#methods). + + + +| Method | Description | +| :---- | :-------- | +| `setPosition(x, y, z)` | Sets `positionX/Y/Z.value` (deprecated in the spec; provided for compatibility). | +| `setOrientation(x, y, z)` | Sets `orientationX/Y/Z.value`. | + +## Remarks + +- Output is always stereo (2 channels), like [`StereoPannerNode`](/docs/effects/stereo-panner-node). +- Spatialization uses the context [`listener`](/docs/core/audio-listener) position and orientation. +- **`HRTF`** panning is not implemented in the native engine yet; selecting it still produces equal-power stereo output. diff --git a/packages/audiodocs/docs/other/web-audio-api-coverage.mdx b/packages/audiodocs/docs/other/web-audio-api-coverage.mdx index f40ed597a..bea849da0 100644 --- a/packages/audiodocs/docs/other/web-audio-api-coverage.mdx +++ b/packages/audiodocs/docs/other/web-audio-api-coverage.mdx @@ -43,7 +43,7 @@ We will do our best to ship it as soon as possible! | WaveShaperNode | ✅ | | AudioContext | 🚧 | Available props and methods: `close`, `suspend`, `resume` | | BaseAudioContext | 🚧 | Available props and methods: `currentTime`, `destination`, `listener`, `sampleRate`, `state`, `decodeAudioData`, all create methods for available or partially implemented nodes | -| AudioListener | 🚧 | No effect until PannerNode. | +| AudioListener | ✅ | Used by PannerNode for 3D spatialization. | | AudioSinkInfo | ❌ | | AudioWorklet | ❌ | | AudioWorkletGlobalScope | ❌ | @@ -54,7 +54,7 @@ We will do our best to ship it as soon as possible! | DynamicsCompressorNode | ❌ | | MediaStreamAudioDestinationNode | ❌ | | MediaStreamAudioSourceNode | ❌ | -| PannerNode | ❌ | +| PannerNode | 🚧 | Equal-power spatialization, distance models, and cone gain are implemented. `HRTF` is accepted but currently falls back to equal-power. | ### Description diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioListenerHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioListenerHostObject.h index ca6bdb00a..479d47056 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioListenerHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioListenerHostObject.h @@ -36,6 +36,10 @@ class AudioListenerHostObject : public HostObject, public utils::graph::HostNode JSI_PROPERTY_GETTER_DECL(upY); JSI_PROPERTY_GETTER_DECL(upZ); + [[nodiscard]] AudioListener *audioListener() const { + return listener_.get(); + } + private: std::unique_ptr listener_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp index 8f5651534..50b017ab1 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -51,6 +52,7 @@ BaseAudioContextHostObject::BaseAudioContextHostObject( JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createGain), JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createDelay), JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createStereoPanner), + JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createPanner), JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createBiquadFilter), JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createIIRFilter), JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createBufferSource), @@ -146,6 +148,16 @@ JSI_HOST_FUNCTION_IMPL(BaseAudioContextHostObject, createStereoPanner) { return object; } +JSI_HOST_FUNCTION_IMPL(BaseAudioContextHostObject, createPanner) { + const auto options = args[0].asObject(runtime); + const auto pannerOptions = audioapi::option_parser::parsePannerOptions(runtime, options); + auto pannerHostObject = + std::make_shared(context_, listener_->audioListener(), pannerOptions); + auto object = jsi::Object::createFromHostObject(runtime, pannerHostObject); + object.setExternalMemoryPressure(runtime, pannerHostObject->getMemoryPressure()); + return object; +} + JSI_HOST_FUNCTION_IMPL(BaseAudioContextHostObject, createBiquadFilter) { const auto options = args[0].asObject(runtime); const auto biquadFilterOptions = diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h index a7344e1de..1a41d50e6 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h @@ -34,6 +34,7 @@ class BaseAudioContextHostObject : public HostObject { JSI_HOST_FUNCTION_DECL(createConstantSource); JSI_HOST_FUNCTION_DECL(createGain); JSI_HOST_FUNCTION_DECL(createStereoPanner); + JSI_HOST_FUNCTION_DECL(createPanner); JSI_HOST_FUNCTION_DECL(createBiquadFilter); JSI_HOST_FUNCTION_DECL(createIIRFilter); JSI_HOST_FUNCTION_DECL(createBufferSource); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/PannerNodeHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/PannerNodeHostObject.cpp new file mode 100644 index 000000000..d47797e56 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/PannerNodeHostObject.cpp @@ -0,0 +1,240 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace audioapi { + +PannerNodeHostObject::PannerNodeHostObject( + const std::shared_ptr &context, + AudioListener *listener, + const PannerOptions &options) + : AudioNodeHostObject( + context->getGraph(), + std::make_unique(context, listener, options), + options), + pannerNode_(typedAudioNode(node_)), + panningModel_(options.panningModel), + distanceModel_(options.distanceModel), + refDistance_(options.refDistance), + maxDistance_(options.maxDistance), + rolloffFactor_(options.rolloffFactor), + coneInnerAngle_(options.coneInnerAngle), + coneOuterAngle_(options.coneOuterAngle), + coneOuterGain_(options.coneOuterGain) { + positionXParam_ = + std::make_shared(graph_, node_, pannerNode_->getPositionXParam()); + positionYParam_ = + std::make_shared(graph_, node_, pannerNode_->getPositionYParam()); + positionZParam_ = + std::make_shared(graph_, node_, pannerNode_->getPositionZParam()); + orientationXParam_ = + std::make_shared(graph_, node_, pannerNode_->getOrientationXParam()); + orientationYParam_ = + std::make_shared(graph_, node_, pannerNode_->getOrientationYParam()); + orientationZParam_ = + std::make_shared(graph_, node_, pannerNode_->getOrientationZParam()); + + addGetters( + JSI_EXPORT_PROPERTY_GETTER(PannerNodeHostObject, positionX), + JSI_EXPORT_PROPERTY_GETTER(PannerNodeHostObject, positionY), + JSI_EXPORT_PROPERTY_GETTER(PannerNodeHostObject, positionZ), + JSI_EXPORT_PROPERTY_GETTER(PannerNodeHostObject, orientationX), + JSI_EXPORT_PROPERTY_GETTER(PannerNodeHostObject, orientationY), + JSI_EXPORT_PROPERTY_GETTER(PannerNodeHostObject, orientationZ), + JSI_EXPORT_PROPERTY_GETTER(PannerNodeHostObject, panningModel), + JSI_EXPORT_PROPERTY_GETTER(PannerNodeHostObject, distanceModel), + JSI_EXPORT_PROPERTY_GETTER(PannerNodeHostObject, refDistance), + JSI_EXPORT_PROPERTY_GETTER(PannerNodeHostObject, maxDistance), + JSI_EXPORT_PROPERTY_GETTER(PannerNodeHostObject, rolloffFactor), + JSI_EXPORT_PROPERTY_GETTER(PannerNodeHostObject, coneInnerAngle), + JSI_EXPORT_PROPERTY_GETTER(PannerNodeHostObject, coneOuterAngle), + JSI_EXPORT_PROPERTY_GETTER(PannerNodeHostObject, coneOuterGain)); + + addSetters( + JSI_EXPORT_PROPERTY_SETTER(PannerNodeHostObject, panningModel), + JSI_EXPORT_PROPERTY_SETTER(PannerNodeHostObject, distanceModel), + JSI_EXPORT_PROPERTY_SETTER(PannerNodeHostObject, refDistance), + JSI_EXPORT_PROPERTY_SETTER(PannerNodeHostObject, maxDistance), + JSI_EXPORT_PROPERTY_SETTER(PannerNodeHostObject, rolloffFactor), + JSI_EXPORT_PROPERTY_SETTER(PannerNodeHostObject, coneInnerAngle), + JSI_EXPORT_PROPERTY_SETTER(PannerNodeHostObject, coneOuterAngle), + JSI_EXPORT_PROPERTY_SETTER(PannerNodeHostObject, coneOuterGain)); +} + +JSI_PROPERTY_GETTER_IMPL(PannerNodeHostObject, positionX) { + return jsi::Object::createFromHostObject(runtime, positionXParam_); +} + +JSI_PROPERTY_GETTER_IMPL(PannerNodeHostObject, positionY) { + return jsi::Object::createFromHostObject(runtime, positionYParam_); +} + +JSI_PROPERTY_GETTER_IMPL(PannerNodeHostObject, positionZ) { + return jsi::Object::createFromHostObject(runtime, positionZParam_); +} + +JSI_PROPERTY_GETTER_IMPL(PannerNodeHostObject, orientationX) { + return jsi::Object::createFromHostObject(runtime, orientationXParam_); +} + +JSI_PROPERTY_GETTER_IMPL(PannerNodeHostObject, orientationY) { + return jsi::Object::createFromHostObject(runtime, orientationYParam_); +} + +JSI_PROPERTY_GETTER_IMPL(PannerNodeHostObject, orientationZ) { + return jsi::Object::createFromHostObject(runtime, orientationZParam_); +} + +JSI_PROPERTY_GETTER_IMPL(PannerNodeHostObject, panningModel) { + return jsi::String::createFromUtf8(runtime, js_enum_parser::panningModelToString(panningModel_)); +} + +JSI_PROPERTY_SETTER_IMPL(PannerNodeHostObject, panningModel) { + PanningModelType parsedModel; + try { + parsedModel = js_enum_parser::panningModelFromString(value.asString(runtime).utf8(runtime)); + } catch (const std::invalid_argument &) { + return; + } + panningModel_ = parsedModel; + auto event = [node = pannerNode_, parsedModel](BaseAudioContext &) { + node->setPanningModel(parsedModel); + }; + pannerNode_->scheduleAudioEvent(std::move(event)); +} + +JSI_PROPERTY_GETTER_IMPL(PannerNodeHostObject, distanceModel) { + return jsi::String::createFromUtf8( + runtime, js_enum_parser::distanceModelToString(distanceModel_)); +} + +JSI_PROPERTY_SETTER_IMPL(PannerNodeHostObject, distanceModel) { + DistanceModelType parsedModel; + try { + parsedModel = js_enum_parser::distanceModelFromString(value.asString(runtime).utf8(runtime)); + } catch (const std::invalid_argument &) { + return; + } + distanceModel_ = parsedModel; + auto event = [node = pannerNode_, parsedModel](BaseAudioContext &) { + node->setDistanceModel(parsedModel); + }; + pannerNode_->scheduleAudioEvent(std::move(event)); +} + +JSI_PROPERTY_GETTER_IMPL(PannerNodeHostObject, refDistance) { + return refDistance_; +} + +JSI_PROPERTY_SETTER_IMPL(PannerNodeHostObject, refDistance) { + if (!value.isNumber()) { + return; + } + const double distance = value.getNumber(); + if (distance < 0.0) { + throw jsi::JSError(runtime, "refDistance cannot be set to a negative value"); + } + refDistance_ = distance; + auto event = [node = pannerNode_, distance](BaseAudioContext &) { + node->setRefDistance(distance); + }; + pannerNode_->scheduleAudioEvent(std::move(event)); +} + +JSI_PROPERTY_GETTER_IMPL(PannerNodeHostObject, maxDistance) { + return maxDistance_; +} + +JSI_PROPERTY_SETTER_IMPL(PannerNodeHostObject, maxDistance) { + if (!value.isNumber()) { + return; + } + const double distance = value.getNumber(); + if (distance <= 0.0) { + throw jsi::JSError(runtime, "maxDistance cannot be set to a non-positive value"); + } + maxDistance_ = distance; + auto event = [node = pannerNode_, distance](BaseAudioContext &) { + node->setMaxDistance(distance); + }; + pannerNode_->scheduleAudioEvent(std::move(event)); +} + +JSI_PROPERTY_GETTER_IMPL(PannerNodeHostObject, rolloffFactor) { + return rolloffFactor_; +} + +JSI_PROPERTY_SETTER_IMPL(PannerNodeHostObject, rolloffFactor) { + if (!value.isNumber()) { + return; + } + const double factor = value.getNumber(); + if (factor < 0.0) { + throw jsi::JSError(runtime, "rolloffFactor cannot be set to a negative value"); + } + rolloffFactor_ = factor; + auto event = [node = pannerNode_, factor](BaseAudioContext &) { + node->setRolloffFactor(factor); + }; + pannerNode_->scheduleAudioEvent(std::move(event)); +} + +JSI_PROPERTY_GETTER_IMPL(PannerNodeHostObject, coneInnerAngle) { + return coneInnerAngle_; +} + +JSI_PROPERTY_SETTER_IMPL(PannerNodeHostObject, coneInnerAngle) { + if (!value.isNumber()) { + return; + } + const double angle = value.getNumber(); + coneInnerAngle_ = angle; + auto event = [node = pannerNode_, angle](BaseAudioContext &) { + node->setConeInnerAngle(angle); + }; + pannerNode_->scheduleAudioEvent(std::move(event)); +} + +JSI_PROPERTY_GETTER_IMPL(PannerNodeHostObject, coneOuterAngle) { + return coneOuterAngle_; +} + +JSI_PROPERTY_SETTER_IMPL(PannerNodeHostObject, coneOuterAngle) { + if (!value.isNumber()) { + return; + } + const double angle = value.getNumber(); + coneOuterAngle_ = angle; + auto event = [node = pannerNode_, angle](BaseAudioContext &) { + node->setConeOuterAngle(angle); + }; + pannerNode_->scheduleAudioEvent(std::move(event)); +} + +JSI_PROPERTY_GETTER_IMPL(PannerNodeHostObject, coneOuterGain) { + return coneOuterGain_; +} + +JSI_PROPERTY_SETTER_IMPL(PannerNodeHostObject, coneOuterGain) { + if (!value.isNumber()) { + return; + } + const double gain = value.getNumber(); + if (gain < 0.0 || gain > 1.0) { + throw jsi::JSError(runtime, "coneOuterGain must be between 0 and 1"); + } + coneOuterGain_ = gain; + auto event = [node = pannerNode_, gain](BaseAudioContext &) { + node->setConeOuterGain(gain); + }; + pannerNode_->scheduleAudioEvent(std::move(event)); +} + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/PannerNodeHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/PannerNodeHostObject.h new file mode 100644 index 000000000..a8ae9113b --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/PannerNodeHostObject.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include +#include + +#include + +namespace audioapi { +using namespace facebook; + +class AudioListener; +struct PannerOptions; +class BaseAudioContext; +class PannerNode; + +class PannerNodeHostObject : public AudioNodeHostObject { + public: + explicit PannerNodeHostObject( + const std::shared_ptr &context, + AudioListener *listener, + const PannerOptions &options); + + JSI_PROPERTY_GETTER_DECL(positionX); + JSI_PROPERTY_GETTER_DECL(positionY); + JSI_PROPERTY_GETTER_DECL(positionZ); + JSI_PROPERTY_GETTER_DECL(orientationX); + JSI_PROPERTY_GETTER_DECL(orientationY); + JSI_PROPERTY_GETTER_DECL(orientationZ); + JSI_PROPERTY_GETTER_DECL(panningModel); + JSI_PROPERTY_SETTER_DECL(panningModel); + JSI_PROPERTY_GETTER_DECL(distanceModel); + JSI_PROPERTY_SETTER_DECL(distanceModel); + JSI_PROPERTY_GETTER_DECL(refDistance); + JSI_PROPERTY_SETTER_DECL(refDistance); + JSI_PROPERTY_GETTER_DECL(maxDistance); + JSI_PROPERTY_SETTER_DECL(maxDistance); + JSI_PROPERTY_GETTER_DECL(rolloffFactor); + JSI_PROPERTY_SETTER_DECL(rolloffFactor); + JSI_PROPERTY_GETTER_DECL(coneInnerAngle); + JSI_PROPERTY_SETTER_DECL(coneInnerAngle); + JSI_PROPERTY_GETTER_DECL(coneOuterAngle); + JSI_PROPERTY_SETTER_DECL(coneOuterAngle); + JSI_PROPERTY_GETTER_DECL(coneOuterGain); + JSI_PROPERTY_SETTER_DECL(coneOuterGain); + + [[nodiscard]] size_t getMemoryPressure() const override { + return AudioNodeHostObject::getMemoryPressure() + 6 * kAudioParamBytes; + } + + private: + PannerNode *pannerNode_ = nullptr; + + std::shared_ptr positionXParam_; + std::shared_ptr positionYParam_; + std::shared_ptr positionZParam_; + std::shared_ptr orientationXParam_; + std::shared_ptr orientationYParam_; + std::shared_ptr orientationZParam_; + + PanningModelType panningModel_; + DistanceModelType distanceModel_; + double refDistance_; + double maxDistance_; + double rolloffFactor_; + double coneInnerAngle_; + double coneOuterAngle_; + double coneOuterGain_; +}; + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp index 9c205d830..799225659 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.cpp @@ -188,6 +188,50 @@ std::string channelInterpretationToString(ChannelInterpretation interpretation) throw std::invalid_argument("Unknown channel interpretation"); } } + +std::string panningModelToString(PanningModelType model) { + switch (model) { + case PanningModelType::EqualPower: + return "equalpower"; + case PanningModelType::HRTF: + return "HRTF"; + default: + throw std::invalid_argument("Unknown panning model"); + } +} + +PanningModelType panningModelFromString(const std::string &model) { + if (model == "equalpower") + return PanningModelType::EqualPower; + if (model == "HRTF") + return PanningModelType::HRTF; + + throw std::invalid_argument("Invalid panning model: " + model); +} + +std::string distanceModelToString(DistanceModelType model) { + switch (model) { + case DistanceModelType::Linear: + return "linear"; + case DistanceModelType::Inverse: + return "inverse"; + case DistanceModelType::Exponential: + return "exponential"; + default: + throw std::invalid_argument("Unknown distance model"); + } +} + +DistanceModelType distanceModelFromString(const std::string &model) { + if (model == "linear") + return DistanceModelType::Linear; + if (model == "inverse") + return DistanceModelType::Inverse; + if (model == "exponential") + return DistanceModelType::Exponential; + + throw std::invalid_argument("Invalid distance model: " + model); +} } // namespace audioapi::js_enum_parser // NOLINTEND(readability-braces-around-statements) diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.h index 089befda7..b07716ccd 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/JsEnumParser.h @@ -8,6 +8,7 @@ #include #include #include +#include #include namespace audioapi::js_enum_parser { @@ -21,4 +22,8 @@ AudioEvent audioEventFromString(const std::string &event); std::string contextStateToString(ContextState state); std::string channelCountModeToString(ChannelCountMode mode); std::string channelInterpretationToString(ChannelInterpretation interpretation); +std::string panningModelToString(PanningModelType model); +PanningModelType panningModelFromString(const std::string &model); +std::string distanceModelToString(DistanceModelType model); +DistanceModelType distanceModelFromString(const std::string &model); } // namespace audioapi::js_enum_parser diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h index f1d10866d..55a31dbbb 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/utils/NodeOptionsParser.h @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -106,6 +107,79 @@ inline StereoPannerOptions parseStereoPannerOptions( return options; } +inline PannerOptions parsePannerOptions(jsi::Runtime &runtime, const jsi::Object &optionsObject) { + PannerOptions options(parseAudioNodeOptions(runtime, optionsObject)); + + auto panningModelValue = optionsObject.getProperty(runtime, "panningModel"); + if (panningModelValue.isString()) { + try { + options.panningModel = + js_enum_parser::panningModelFromString(panningModelValue.asString(runtime).utf8(runtime)); + } catch (const std::invalid_argument &) {} + } + + auto distanceModelValue = optionsObject.getProperty(runtime, "distanceModel"); + if (distanceModelValue.isString()) { + try { + options.distanceModel = js_enum_parser::distanceModelFromString( + distanceModelValue.asString(runtime).utf8(runtime)); + } catch (const std::invalid_argument &) {} + } + + auto positionXValue = optionsObject.getProperty(runtime, "positionX"); + if (positionXValue.isNumber()) { + options.positionX = static_cast(positionXValue.getNumber()); + } + auto positionYValue = optionsObject.getProperty(runtime, "positionY"); + if (positionYValue.isNumber()) { + options.positionY = static_cast(positionYValue.getNumber()); + } + auto positionZValue = optionsObject.getProperty(runtime, "positionZ"); + if (positionZValue.isNumber()) { + options.positionZ = static_cast(positionZValue.getNumber()); + } + + auto orientationXValue = optionsObject.getProperty(runtime, "orientationX"); + if (orientationXValue.isNumber()) { + options.orientationX = static_cast(orientationXValue.getNumber()); + } + auto orientationYValue = optionsObject.getProperty(runtime, "orientationY"); + if (orientationYValue.isNumber()) { + options.orientationY = static_cast(orientationYValue.getNumber()); + } + auto orientationZValue = optionsObject.getProperty(runtime, "orientationZ"); + if (orientationZValue.isNumber()) { + options.orientationZ = static_cast(orientationZValue.getNumber()); + } + + auto refDistanceValue = optionsObject.getProperty(runtime, "refDistance"); + if (refDistanceValue.isNumber()) { + options.refDistance = refDistanceValue.getNumber(); + } + auto maxDistanceValue = optionsObject.getProperty(runtime, "maxDistance"); + if (maxDistanceValue.isNumber()) { + options.maxDistance = maxDistanceValue.getNumber(); + } + auto rolloffFactorValue = optionsObject.getProperty(runtime, "rolloffFactor"); + if (rolloffFactorValue.isNumber()) { + options.rolloffFactor = rolloffFactorValue.getNumber(); + } + auto coneInnerAngleValue = optionsObject.getProperty(runtime, "coneInnerAngle"); + if (coneInnerAngleValue.isNumber()) { + options.coneInnerAngle = coneInnerAngleValue.getNumber(); + } + auto coneOuterAngleValue = optionsObject.getProperty(runtime, "coneOuterAngle"); + if (coneOuterAngleValue.isNumber()) { + options.coneOuterAngle = coneOuterAngleValue.getNumber(); + } + auto coneOuterGainValue = optionsObject.getProperty(runtime, "coneOuterGain"); + if (coneOuterGainValue.isNumber()) { + options.coneOuterGain = coneOuterGainValue.getNumber(); + } + + return options; +} + inline ConvolverOptions parseConvolverOptions( jsi::Runtime &runtime, const jsi::Object &optionsObject) { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioListener.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioListener.cpp index 07b7da0da..46ff78649 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioListener.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioListener.cpp @@ -29,4 +29,25 @@ AudioListener::AudioListener(const std::shared_ptr &context) upXParam_(makeListenerParam(0.0f, context)), upYParam_(makeListenerParam(1.0f, context)), upZParam_(makeListenerParam(0.0f, context)) {} + +void AudioListener::processForQuantum(int framesToProcess, double time, std::size_t sampleFrame) { + if (lastProcessedSampleFrame_.has_value() && *lastProcessedSampleFrame_ == sampleFrame) { + return; + } + + lastProcessedSampleFrame_ = sampleFrame; + positionXValues_ = + positionXParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); + positionYValues_ = + positionYParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); + positionZValues_ = + positionZParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); + forwardXValues_ = forwardXParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); + forwardYValues_ = forwardYParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); + forwardZValues_ = forwardZParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); + upXValues_ = upXParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); + upYValues_ = upYParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); + upZValues_ = upZParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); +} + } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioListener.h b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioListener.h index a4b0b1364..149bddc3e 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioListener.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioListener.h @@ -2,7 +2,10 @@ #include +#include #include +#include +#include namespace audioapi { @@ -43,6 +46,40 @@ class AudioListener { return upZParam_; } + /// @brief Process listener AudioParams once per render quantum. + /// Multiple PannerNodes share these params; calling processARateParam from + /// each panner would advance automation and zero modulation inputs repeatedly. + /// @note Audio-thread only. + void processForQuantum(int framesToProcess, double time, std::size_t sampleFrame); + + [[nodiscard]] std::span positionXValues() const { + return positionXValues_; + } + [[nodiscard]] std::span positionYValues() const { + return positionYValues_; + } + [[nodiscard]] std::span positionZValues() const { + return positionZValues_; + } + [[nodiscard]] std::span forwardXValues() const { + return forwardXValues_; + } + [[nodiscard]] std::span forwardYValues() const { + return forwardYValues_; + } + [[nodiscard]] std::span forwardZValues() const { + return forwardZValues_; + } + [[nodiscard]] std::span upXValues() const { + return upXValues_; + } + [[nodiscard]] std::span upYValues() const { + return upYValues_; + } + [[nodiscard]] std::span upZValues() const { + return upZValues_; + } + private: std::shared_ptr positionXParam_; std::shared_ptr positionYParam_; @@ -53,6 +90,17 @@ class AudioListener { std::shared_ptr upXParam_; std::shared_ptr upYParam_; std::shared_ptr upZParam_; + + std::optional lastProcessedSampleFrame_; + std::span positionXValues_; + std::span positionYValues_; + std::span positionZValues_; + std::span forwardXValues_; + std::span forwardYValues_; + std::span forwardZValues_; + std::span upXValues_; + std::span upYValues_; + std::span upZValues_; }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.cpp index 4587fed1a..9a6a30e56 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.cpp @@ -1,27 +1,63 @@ +#include #include #include +#include #include #include #include -#include #include namespace audioapi { +namespace { + +using panner::Vec3; + +} // namespace + PannerNode::PannerNode( const std::shared_ptr &context, + AudioListener *listener, const PannerOptions &options) : AudioNode(context, options), - positionXParam_(std::make_shared(options.positionX, -FLT_MAX, FLT_MAX, context)), - positionYParam_(std::make_shared(options.positionY, -FLT_MAX, FLT_MAX, context)), - positionZParam_(std::make_shared(options.positionZ, -FLT_MAX, FLT_MAX, context)), + listener_(listener), + positionXParam_( + std::make_shared( + options.positionX, + MOST_NEGATIVE_SINGLE_FLOAT, + MOST_POSITIVE_SINGLE_FLOAT, + context)), + positionYParam_( + std::make_shared( + options.positionY, + MOST_NEGATIVE_SINGLE_FLOAT, + MOST_POSITIVE_SINGLE_FLOAT, + context)), + positionZParam_( + std::make_shared( + options.positionZ, + MOST_NEGATIVE_SINGLE_FLOAT, + MOST_POSITIVE_SINGLE_FLOAT, + context)), orientationXParam_( - std::make_shared(options.orientationX, -FLT_MAX, FLT_MAX, context)), + std::make_shared( + options.orientationX, + MOST_NEGATIVE_SINGLE_FLOAT, + MOST_POSITIVE_SINGLE_FLOAT, + context)), orientationYParam_( - std::make_shared(options.orientationY, -FLT_MAX, FLT_MAX, context)), + std::make_shared( + options.orientationY, + MOST_NEGATIVE_SINGLE_FLOAT, + MOST_POSITIVE_SINGLE_FLOAT, + context)), orientationZParam_( - std::make_shared(options.orientationZ, -FLT_MAX, FLT_MAX, context)), + std::make_shared( + options.orientationZ, + MOST_NEGATIVE_SINGLE_FLOAT, + MOST_POSITIVE_SINGLE_FLOAT, + context)), panningModel_(options.panningModel), distanceModel_(options.distanceModel), refDistance_(options.refDistance), @@ -142,13 +178,92 @@ size_t PannerNode::getUpstreamChannelCount(size_t /*negotiatedChannelCount*/) co void PannerNode::processNode(int framesToProcess) { std::shared_ptr context = context_.lock(); - if (context == nullptr || audioBuffer_ == nullptr) { + if (context == nullptr || audioBuffer_ == nullptr || listener_ == nullptr) { + outputBuffer_->zero(); return; } - // TODO (Krok 3): Tutaj zaimplementujemy matematykę 3D, tłumienie dystansu (Inverse) - // oraz podział na kanały algorytmem EqualPower. Na czas testu kompilacji - // zostawiamy pusty przebieg — węzeł wyprodukuje bezpieczną ciszę w outputBuffer_. + const double time = context->getCurrentTime(); + const bool monoInput = audioBuffer_->getNumberOfChannels() == 1; + + listener_->processForQuantum(framesToProcess, time, context->getCurrentSampleFrame()); + + const auto posX = + positionXParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); + const auto posY = + positionYParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); + const auto posZ = + positionZParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); + const auto orientX = + orientationXParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); + const auto orientY = + orientationYParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); + const auto orientZ = + orientationZParam_->processARateParam(framesToProcess, time)->getChannel(0)->span(); + + const auto listenerPosX = listener_->positionXValues(); + const auto listenerPosY = listener_->positionYValues(); + const auto listenerPosZ = listener_->positionZValues(); + const auto listenerForwardX = listener_->forwardXValues(); + const auto listenerForwardY = listener_->forwardYValues(); + const auto listenerForwardZ = listener_->forwardZValues(); + const auto listenerUpX = listener_->upXValues(); + const auto listenerUpY = listener_->upYValues(); + const auto listenerUpZ = listener_->upZValues(); + + auto outputLeft = outputBuffer_->getChannelByType(AudioBuffer::ChannelLeft)->span(); + auto outputRight = outputBuffer_->getChannelByType(AudioBuffer::ChannelRight)->span(); + + auto inputLeftSpan = monoInput ? audioBuffer_->getChannelByType(AudioBuffer::ChannelMono)->span() + : audioBuffer_->getChannelByType(AudioBuffer::ChannelLeft)->span(); + auto inputRightSpan = + monoInput ? inputLeftSpan : audioBuffer_->getChannelByType(AudioBuffer::ChannelRight)->span(); + + // HRTF is not implemented yet — equal-power panning is used for all models. + (void)panningModel_; + + for (int i = 0; i < framesToProcess; ++i) { + const size_t idx = static_cast(i); + const Vec3 sourcePosition{posX[idx], posY[idx], posZ[idx]}; + const Vec3 sourceOrientation{orientX[idx], orientY[idx], orientZ[idx]}; + const Vec3 listenerPosition{listenerPosX[idx], listenerPosY[idx], listenerPosZ[idx]}; + const Vec3 listenerForward{listenerForwardX[idx], listenerForwardY[idx], listenerForwardZ[idx]}; + const Vec3 listenerUp{listenerUpX[idx], listenerUpY[idx], listenerUpZ[idx]}; + + const float azimuth = + panner::computeAzimuth(sourcePosition, listenerPosition, listenerForward, listenerUp); + + float gainL = 0.0f; + float gainR = 0.0f; + // Spec §6.3.1: stereo mix branch uses azimuth after wrapping to [-90, 90]. + const float wrappedAzimuth = panner::computeEqualPowerGains(azimuth, monoInput, gainL, gainR); + + const float distance = panner::computeDistance(sourcePosition, listenerPosition); + const float distanceGain = panner::computeDistanceGain( + distanceModel_, distance, refDistance_, maxDistance_, rolloffFactor_); + const float coneGain = panner::computeConeGain( + sourcePosition, + listenerPosition, + sourceOrientation, + coneInnerAngle_, + coneOuterAngle_, + coneOuterGain_); + const float totalGain = distanceGain * coneGain; + + const float inputL = inputLeftSpan[idx]; + const float inputR = inputRightSpan[idx]; + + if (monoInput) { + outputLeft[idx] = inputL * gainL * totalGain; + outputRight[idx] = inputL * gainR * totalGain; + } else if (wrappedAzimuth <= 0.0f) { + outputLeft[idx] = (inputL + inputR * gainL) * totalGain; + outputRight[idx] = inputR * gainR * totalGain; + } else { + outputLeft[idx] = inputL * gainL * totalGain; + outputRight[idx] = (inputR + inputL * gainR) * totalGain; + } + } } -} // namespace audioapi \ No newline at end of file +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.h b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.h index b9740c6da..7bbdf8dda 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerNode.h @@ -10,9 +10,14 @@ namespace audioapi { +class AudioListener; + class PannerNode : public AudioNode { public: - PannerNode(const std::shared_ptr &context, const PannerOptions &options); + PannerNode( + const std::shared_ptr &context, + AudioListener *listener, + const PannerOptions &options); ~PannerNode() override = default; @@ -60,6 +65,8 @@ class PannerNode : public AudioNode { } private: + AudioListener *listener_ = nullptr; + const std::shared_ptr positionXParam_; const std::shared_ptr positionYParam_; const std::shared_ptr positionZParam_; @@ -79,4 +86,4 @@ class PannerNode : public AudioNode { const std::shared_ptr outputBuffer_; }; -} // namespace audioapi \ No newline at end of file +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerSpatialization.h b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerSpatialization.h new file mode 100644 index 000000000..3f516556d --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/effects/PannerSpatialization.h @@ -0,0 +1,199 @@ +#pragma once + +#include +#include + +#include +#include + +namespace audioapi::panner { + +struct Vec3 { + float x = 0.0f; + float y = 0.0f; + float z = 0.0f; +}; + +inline float dot(const Vec3 &a, const Vec3 &b) { + return a.x * b.x + a.y * b.y + a.z * b.z; +} + +inline float magnitude(const Vec3 &v) { + return std::sqrt(dot(v, v)); +} + +inline Vec3 subtract(const Vec3 &a, const Vec3 &b) { + return {a.x - b.x, a.y - b.y, a.z - b.z}; +} + +inline Vec3 scale(const Vec3 &v, float s) { + return {v.x * s, v.y * s, v.z * s}; +} + +inline Vec3 normalize(const Vec3 &v) { + const float mag = magnitude(v); + if (mag == 0.0f) { + return {}; + } + return scale(v, 1.0f / mag); +} + +inline Vec3 cross(const Vec3 &a, const Vec3 &b) { + return { + a.y * b.z - a.z * b.y, + a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x, + }; +} + +/// Azimuth in degrees — https://webaudio.github.io/web-audio-api/#Spatialization-azimuth-elevation +inline float computeAzimuth( + const Vec3 &sourcePosition, + const Vec3 &listenerPosition, + const Vec3 &listenerForward, + const Vec3 &listenerUp) { + Vec3 sourceListener = subtract(sourcePosition, listenerPosition); + if (magnitude(sourceListener) == 0.0f) { + return 0.0f; + } + sourceListener = normalize(sourceListener); + + Vec3 listenerRight = cross(listenerForward, listenerUp); + if (magnitude(listenerRight) == 0.0f) { + return 0.0f; + } + + const Vec3 listenerRightNorm = normalize(listenerRight); + const Vec3 listenerForwardNorm = normalize(listenerForward); + const Vec3 up = cross(listenerRightNorm, listenerForwardNorm); + + const float upProjection = dot(sourceListener, up); + Vec3 projectedSource = normalize(subtract(sourceListener, scale(up, upProjection))); + + float azimuth = + 180.0f * std::acos(std::clamp(dot(projectedSource, listenerRightNorm), -1.0f, 1.0f)) / PI; + + if (dot(projectedSource, listenerForwardNorm) < 0.0f) { + azimuth = 360.0f - azimuth; + } + + if (azimuth >= 0.0f && azimuth <= 270.0f) { + azimuth = 90.0f - azimuth; + } else { + azimuth = 450.0f - azimuth; + } + + return azimuth; +} + +inline float wrapAzimuthForEqualPower(float azimuth) { + azimuth = std::max(-180.0f, azimuth); + azimuth = std::min(180.0f, azimuth); + if (azimuth < -90.0f) { + azimuth = -180.0f - azimuth; + } else if (azimuth > 90.0f) { + azimuth = 180.0f - azimuth; + } + return azimuth; +} + +/// Returns azimuth wrapped to [-90, 90] and writes equal-power L/R gains. +inline float computeEqualPowerGains(float azimuth, bool monoInput, float &gainL, float &gainR) { + azimuth = wrapAzimuthForEqualPower(azimuth); + + float x = 0.0f; + if (monoInput) { + x = (azimuth + 90.0f) / 180.0f; + } else if (azimuth <= 0.0f) { + x = (azimuth + 90.0f) / 90.0f; + } else { + x = azimuth / 90.0f; + } + + const float angle = x * (PI / 2.0f); + gainL = std::cos(angle); + gainR = std::sin(angle); + return azimuth; +} + +inline float computeDistance(const Vec3 &sourcePosition, const Vec3 &listenerPosition) { + return magnitude(subtract(sourcePosition, listenerPosition)); +} + +inline float computeDistanceGain( + DistanceModelType model, + float distance, + double refDistance, + double maxDistance, + double rolloffFactor) { + const float dRef = static_cast(refDistance); + const float dMax = static_cast(maxDistance); + float f = static_cast(rolloffFactor); + + switch (model) { + case DistanceModelType::Linear: { + const float dRefClamped = std::min(dRef, dMax); + const float dMaxClamped = std::max(dRef, dMax); + distance = std::clamp(distance, dRefClamped, dMaxClamped); + f = std::clamp(f, 0.0f, 1.0f); + if (dRefClamped == dMaxClamped) { + return 1.0f - f; + } + return 1.0f - f * (distance - dRefClamped) / (dMaxClamped - dRefClamped); + } + case DistanceModelType::Inverse: { + if (dRef == 0.0f) { + return 0.0f; + } + f = std::max(f, 0.0f); + distance = std::max(distance, dRef); + return dRef / (dRef + f * (distance - dRef)); + } + case DistanceModelType::Exponential: { + if (dRef == 0.0f) { + return 0.0f; + } + f = std::max(f, 0.0f); + distance = std::max(distance, dRef); + return std::pow(distance / dRef, -f); + } + } + return 1.0f; +} + +inline float computeConeGain( + const Vec3 &sourcePosition, + const Vec3 &listenerPosition, + const Vec3 &sourceOrientation, + double coneInnerAngle, + double coneOuterAngle, + double coneOuterGain) { + if (magnitude(sourceOrientation) == 0.0f) { + return 1.0f; + } + if (coneInnerAngle == 360.0 && coneOuterAngle == 360.0) { + return 1.0f; + } + + // Vector from the source toward the listener (matches browser ConeEffect). + const Vec3 sourceToListener = normalize(subtract(listenerPosition, sourcePosition)); + const Vec3 normalizedOrientation = normalize(sourceOrientation); + + const float angle = 180.0f * + std::acos(std::clamp(dot(sourceToListener, normalizedOrientation), -1.0f, 1.0f)) / PI; + const float absAngle = std::abs(angle); + const float absInnerAngle = static_cast(std::abs(coneInnerAngle) / 2.0); + const float absOuterAngle = static_cast(std::abs(coneOuterAngle) / 2.0); + + if (absAngle <= absInnerAngle) { + return 1.0f; + } + if (absAngle >= absOuterAngle) { + return static_cast(coneOuterGain); + } + + const float x = (absAngle - absInnerAngle) / (absOuterAngle - absInnerAngle); + return (1.0f - x) + static_cast(coneOuterGain) * x; +} + +} // namespace audioapi::panner diff --git a/packages/react-native-audio-api/common/cpp/audioapi/types/NodeOptions.h b/packages/react-native-audio-api/common/cpp/audioapi/types/NodeOptions.h index 6d097d155..5a6c1786b 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/types/NodeOptions.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/types/NodeOptions.h @@ -55,16 +55,6 @@ struct StereoPannerOptions : AudioNodeOptions { } }; -// enum class PanningModelType { -// EqualPower, -// HRTF -// }; - -// enum class DistanceModelType { -// Inverse, -// Linear, -// Exponential -// }; enum class PanningModelType : std::uint8_t { EqualPower, HRTF }; enum class DistanceModelType : std::uint8_t { Inverse, Linear, Exponential }; diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/effects/PannerTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/effects/PannerTest.cpp new file mode 100644 index 000000000..690a162e2 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/core/effects/PannerTest.cpp @@ -0,0 +1,94 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +using namespace audioapi; + +// NOLINTBEGIN + +class PannerTest : public ::testing::Test { + protected: + std::shared_ptr eventRegistry; + std::shared_ptr context; + std::shared_ptr destination; + std::shared_ptr listener; + static constexpr int sampleRate = 44100; + + void SetUp() override { + eventRegistry = std::make_shared(); + context = std::make_shared(2, 5 * sampleRate, sampleRate, eventRegistry); + destination = std::make_shared(context); + context->initialize(destination.get()); + listener = std::make_shared(context); + } +}; + +class TestablePannerNode : public PannerNode { + public: + TestablePannerNode(const std::shared_ptr &context, AudioListener *listener) + : PannerNode(context, listener, PannerOptions()) {} + + void setInputBuffer(const std::shared_ptr &input) { + audioBuffer_ = input; + } + + using PannerNode::processNode; +}; + +TEST_F(PannerTest, PannerCanBeCreated) { + auto panner = std::make_shared(context, listener.get(), PannerOptions()); + ASSERT_NE(panner, nullptr); +} + +TEST_F(PannerTest, MonoSourcePannedByPosition) { + static constexpr int FRAMES_TO_PROCESS = 4; + TestablePannerNode panNode(context, listener.get()); + panNode.getPositionXParam()->setValue(1.0f); + + auto buffer = std::make_shared(FRAMES_TO_PROCESS, 1, sampleRate); + for (size_t i = 0; i < buffer->getSize(); ++i) { + (*buffer->getChannelByType(AudioBuffer::ChannelMono))[i] = 1.0f; + } + + panNode.setInputBuffer(buffer); + panNode.processNode(FRAMES_TO_PROCESS); + + auto resultBuffer = panNode.getOutputBuffer(); + const float left = (*resultBuffer->getChannelByType(AudioBuffer::ChannelLeft))[0]; + const float right = (*resultBuffer->getChannelByType(AudioBuffer::ChannelRight))[0]; + + EXPECT_GT(right, left); + EXPECT_NEAR(left + right, 1.0f, 0.05f); +} + +TEST_F(PannerTest, DistanceAttenuatesSignal) { + static constexpr int FRAMES_TO_PROCESS = 4; + TestablePannerNode panNode(context, listener.get()); + panNode.getPositionZParam()->setValue(-10.0f); + panNode.setRefDistance(1.0); + panNode.setRolloffFactor(1.0); + panNode.setDistanceModel(DistanceModelType::Inverse); + + auto buffer = std::make_shared(FRAMES_TO_PROCESS, 1, sampleRate); + for (size_t i = 0; i < buffer->getSize(); ++i) { + (*buffer->getChannelByType(AudioBuffer::ChannelMono))[i] = 1.0f; + } + + panNode.setInputBuffer(buffer); + panNode.processNode(FRAMES_TO_PROCESS); + + auto resultBuffer = panNode.getOutputBuffer(); + const float left = (*resultBuffer->getChannelByType(AudioBuffer::ChannelLeft))[0]; + const float right = (*resultBuffer->getChannelByType(AudioBuffer::ChannelRight))[0]; + + EXPECT_LT(left + right, 1.0f); +} + +// NOLINTEND diff --git a/packages/react-native-audio-api/src/api.ts b/packages/react-native-audio-api/src/api.ts index 4e2c7ea51..4f559f064 100644 --- a/packages/react-native-audio-api/src/api.ts +++ b/packages/react-native-audio-api/src/api.ts @@ -25,6 +25,7 @@ export { default as OfflineAudioContext } from './core/OfflineAudioContext'; export { default as OscillatorNode } from './core/OscillatorNode'; export { default as PeriodicWave } from './core/PeriodicWave'; export { default as StereoPannerNode } from './core/StereoPannerNode'; +export { default as PannerNode } from './core/PannerNode'; export { default as WaveShaperNode } from './core/WaveShaperNode'; export * from './errors'; diff --git a/packages/react-native-audio-api/src/api.web.ts b/packages/react-native-audio-api/src/api.web.ts index b5101b28d..04a03f4c9 100644 --- a/packages/react-native-audio-api/src/api.web.ts +++ b/packages/react-native-audio-api/src/api.web.ts @@ -16,6 +16,7 @@ export { default as MediaElementAudioSourceNode } from './web-core/MediaElementA export type { MediaElementAudioSourceOptions } from './web-core/MediaElementAudioSourceNode.web'; export { default as OscillatorNode } from './web-core/OscillatorNode.web'; export { default as StereoPannerNode } from './web-core/StereoPannerNode.web'; +export { default as PannerNode } from './web-core/PannerNode.web'; export { default as ConstantSourceNode } from './web-core/ConstantSourceNode.web'; export { default as ConvolverNode } from './web-core/ConvolverNode.web'; export { default as PeriodicWave } from './web-core/PeriodicWave.web'; diff --git a/packages/react-native-audio-api/src/core/BaseAudioContext.ts b/packages/react-native-audio-api/src/core/BaseAudioContext.ts index 79b3441a2..138dfc333 100644 --- a/packages/react-native-audio-api/src/core/BaseAudioContext.ts +++ b/packages/react-native-audio-api/src/core/BaseAudioContext.ts @@ -8,6 +8,7 @@ import { ContextState, DecodeDataInput, AudioBufferQueueSourceOptions, + PannerOptions, } from '../types'; import AnalyserNode from './AnalyserNode'; import AudioBuffer from './AudioBuffer'; @@ -25,6 +26,7 @@ import IIRFilterNode from './IIRFilterNode'; import OscillatorNode from './OscillatorNode'; import PeriodicWave from './PeriodicWave'; import StereoPannerNode from './StereoPannerNode'; +import PannerNode from './PannerNode'; import WaveShaperNode from './WaveShaperNode'; export default class BaseAudioContext { @@ -93,6 +95,10 @@ export default class BaseAudioContext { return new StereoPannerNode(this); } + createPanner(options?: PannerOptions): PannerNode { + return new PannerNode(this, options); + } + createBiquadFilter(): BiquadFilterNode { return new BiquadFilterNode(this); } diff --git a/packages/react-native-audio-api/src/core/PannerNode.ts b/packages/react-native-audio-api/src/core/PannerNode.ts new file mode 100644 index 000000000..005abb776 --- /dev/null +++ b/packages/react-native-audio-api/src/core/PannerNode.ts @@ -0,0 +1,116 @@ +import { IPannerNode } from '../jsi-interfaces'; +import { DistanceModelType, PannerOptions, PanningModelType } from '../types'; +import { InvalidStateError, RangeError } from '../errors'; +import { PannerOptionsValidator } from '../utils/validation'; +import AudioNode from './AudioNode'; +import AudioParam from './AudioParam'; +import type BaseAudioContext from './BaseAudioContext'; + +export default class PannerNode extends AudioNode { + readonly positionX: AudioParam; + readonly positionY: AudioParam; + readonly positionZ: AudioParam; + readonly orientationX: AudioParam; + readonly orientationY: AudioParam; + readonly orientationZ: AudioParam; + + constructor(context: BaseAudioContext, options?: PannerOptions) { + PannerOptionsValidator.validate(options); + const panner: IPannerNode = context.context.createPanner(options || {}); + super(context, panner); + this.positionX = new AudioParam(panner.positionX, context, this); + this.positionY = new AudioParam(panner.positionY, context, this); + this.positionZ = new AudioParam(panner.positionZ, context, this); + this.orientationX = new AudioParam(panner.orientationX, context, this); + this.orientationY = new AudioParam(panner.orientationY, context, this); + this.orientationZ = new AudioParam(panner.orientationZ, context, this); + } + + public get panningModel(): PanningModelType { + return (this.node as IPannerNode).panningModel; + } + + public set panningModel(value: PanningModelType) { + (this.node as IPannerNode).panningModel = value; + } + + public get distanceModel(): DistanceModelType { + return (this.node as IPannerNode).distanceModel; + } + + public set distanceModel(value: DistanceModelType) { + (this.node as IPannerNode).distanceModel = value; + } + + public get refDistance(): number { + return (this.node as IPannerNode).refDistance; + } + + public set refDistance(value: number) { + if (value < 0) { + throw new RangeError('refDistance must be non-negative'); + } + (this.node as IPannerNode).refDistance = value; + } + + public get maxDistance(): number { + return (this.node as IPannerNode).maxDistance; + } + + public set maxDistance(value: number) { + if (value <= 0) { + throw new RangeError('maxDistance must be positive'); + } + (this.node as IPannerNode).maxDistance = value; + } + + public get rolloffFactor(): number { + return (this.node as IPannerNode).rolloffFactor; + } + + public set rolloffFactor(value: number) { + if (value < 0) { + throw new RangeError('rolloffFactor must be non-negative'); + } + (this.node as IPannerNode).rolloffFactor = value; + } + + public get coneInnerAngle(): number { + return (this.node as IPannerNode).coneInnerAngle; + } + + public set coneInnerAngle(value: number) { + (this.node as IPannerNode).coneInnerAngle = value; + } + + public get coneOuterAngle(): number { + return (this.node as IPannerNode).coneOuterAngle; + } + + public set coneOuterAngle(value: number) { + (this.node as IPannerNode).coneOuterAngle = value; + } + + public get coneOuterGain(): number { + return (this.node as IPannerNode).coneOuterGain; + } + + public set coneOuterGain(value: number) { + if (value < 0 || value > 1) { + throw new InvalidStateError('coneOuterGain must be between 0 and 1'); + } + (this.node as IPannerNode).coneOuterGain = value; + } + + public setPosition(x: number, y: number, z: number): void { + this.positionX.value = x; + this.positionY.value = y; + this.positionZ.value = z; + } + + public setOrientation(x: number, y: number, z: number): void { + this.orientationX.value = x; + this.orientationY.value = y; + this.orientationZ.value = z; + } +} diff --git a/packages/react-native-audio-api/src/jsi-interfaces.ts b/packages/react-native-audio-api/src/jsi-interfaces.ts index 32b139aa3..7fcbef56f 100644 --- a/packages/react-native-audio-api/src/jsi-interfaces.ts +++ b/packages/react-native-audio-api/src/jsi-interfaces.ts @@ -22,6 +22,9 @@ import type { OverSampleType, Result, StereoPannerOptions, + PannerOptions, + PanningModelType, + DistanceModelType, WaveShaperOptions, AudioFileSourceOptions, } from './types'; @@ -45,6 +48,7 @@ export interface IBaseAudioContext { createStereoPanner( stereoPannerOptions: StereoPannerOptions ): IStereoPannerNode; + createPanner(pannerOptions: PannerOptions): IPannerNode; createBiquadFilter: ( biquadFilterOptions: BiquadFilterOptions ) => IBiquadFilterNode; @@ -109,6 +113,23 @@ export interface IStereoPannerNode extends IAudioNode { readonly pan: IAudioParam; } +export interface IPannerNode extends IAudioNode { + readonly positionX: IAudioParam; + readonly positionY: IAudioParam; + readonly positionZ: IAudioParam; + readonly orientationX: IAudioParam; + readonly orientationY: IAudioParam; + readonly orientationZ: IAudioParam; + panningModel: PanningModelType; + distanceModel: DistanceModelType; + refDistance: number; + maxDistance: number; + rolloffFactor: number; + coneInnerAngle: number; + coneOuterAngle: number; + coneOuterGain: number; +} + export interface IBiquadFilterNode extends IAudioNode { readonly frequency: IAudioParam; readonly detune: IAudioParam; diff --git a/packages/react-native-audio-api/src/mock/index.ts b/packages/react-native-audio-api/src/mock/index.ts index 8b556e705..134329396 100644 --- a/packages/react-native-audio-api/src/mock/index.ts +++ b/packages/react-native-audio-api/src/mock/index.ts @@ -27,6 +27,9 @@ import { OscillatorOptions, PeriodicWaveOptions, StereoPannerOptions, + PannerOptions, + PanningModelType, + DistanceModelType, WaveShaperOptions, } from '../types'; @@ -358,6 +361,153 @@ class StereoPannerNodeMock extends AudioNodeMock { } } +class PannerNodeMock extends AudioNodeMock { + readonly positionX: AudioParamMock; + readonly positionY: AudioParamMock; + readonly positionZ: AudioParamMock; + readonly orientationX: AudioParamMock; + readonly orientationY: AudioParamMock; + readonly orientationZ: AudioParamMock; + private _panningModel: PanningModelType = 'equalpower'; + private _distanceModel: DistanceModelType = 'inverse'; + private _refDistance = 1; + private _maxDistance = 10000; + private _rolloffFactor = 1; + private _coneInnerAngle = 360; + private _coneOuterAngle = 360; + private _coneOuterGain = 0; + + constructor(context: BaseAudioContextMock, options?: PannerOptions) { + super(context, {}); + this.positionX = new AudioParamMock({}, context); + this.positionY = new AudioParamMock({}, context); + this.positionZ = new AudioParamMock({}, context); + this.orientationX = new AudioParamMock({}, context); + this.orientationY = new AudioParamMock({}, context); + this.orientationZ = new AudioParamMock({}, context); + this.orientationX.value = 1; + + if (options?.panningModel !== undefined) { + this._panningModel = options.panningModel; + } + if (options?.distanceModel !== undefined) { + this._distanceModel = options.distanceModel; + } + if (options?.positionX !== undefined) { + this.positionX.value = options.positionX; + } + if (options?.positionY !== undefined) { + this.positionY.value = options.positionY; + } + if (options?.positionZ !== undefined) { + this.positionZ.value = options.positionZ; + } + if (options?.orientationX !== undefined) { + this.orientationX.value = options.orientationX; + } + if (options?.orientationY !== undefined) { + this.orientationY.value = options.orientationY; + } + if (options?.orientationZ !== undefined) { + this.orientationZ.value = options.orientationZ; + } + if (options?.refDistance !== undefined) { + this._refDistance = options.refDistance; + } + if (options?.maxDistance !== undefined) { + this._maxDistance = options.maxDistance; + } + if (options?.rolloffFactor !== undefined) { + this._rolloffFactor = options.rolloffFactor; + } + if (options?.coneInnerAngle !== undefined) { + this._coneInnerAngle = options.coneInnerAngle; + } + if (options?.coneOuterAngle !== undefined) { + this._coneOuterAngle = options.coneOuterAngle; + } + if (options?.coneOuterGain !== undefined) { + this._coneOuterGain = options.coneOuterGain; + } + } + + get panningModel(): PanningModelType { + return this._panningModel; + } + + set panningModel(value: PanningModelType) { + this._panningModel = value; + } + + get distanceModel(): DistanceModelType { + return this._distanceModel; + } + + set distanceModel(value: DistanceModelType) { + this._distanceModel = value; + } + + get refDistance(): number { + return this._refDistance; + } + + set refDistance(value: number) { + this._refDistance = value; + } + + get maxDistance(): number { + return this._maxDistance; + } + + set maxDistance(value: number) { + this._maxDistance = value; + } + + get rolloffFactor(): number { + return this._rolloffFactor; + } + + set rolloffFactor(value: number) { + this._rolloffFactor = value; + } + + get coneInnerAngle(): number { + return this._coneInnerAngle; + } + + set coneInnerAngle(value: number) { + this._coneInnerAngle = value; + } + + get coneOuterAngle(): number { + return this._coneOuterAngle; + } + + set coneOuterAngle(value: number) { + this._coneOuterAngle = value; + } + + get coneOuterGain(): number { + return this._coneOuterGain; + } + + set coneOuterGain(value: number) { + this._coneOuterGain = value; + } + + setPosition(x: number, y: number, z: number): void { + this.positionX.value = x; + this.positionY.value = y; + this.positionZ.value = z; + } + + setOrientation(x: number, y: number, z: number): void { + this.orientationX.value = x; + this.orientationY.value = y; + this.orientationZ.value = z; + } +} + class OscillatorNodeMock extends AudioScheduledSourceNodeMock { private _type: OscillatorType = 'sine'; readonly frequency: AudioParamMock; @@ -633,6 +783,10 @@ class BaseAudioContextMock { return new StereoPannerNodeMock(this, options); } + createPanner(options?: PannerOptions): PannerNodeMock { + return new PannerNodeMock(this, options); + } + createWaveShaper(options?: WaveShaperOptions): WaveShaperNodeMock { return new WaveShaperNodeMock(this, options); } @@ -1043,6 +1197,7 @@ export const OfflineAudioContext = OfflineAudioContextMock; export const OscillatorNode = OscillatorNodeMock; export const RecorderAdapterNode = RecorderAdapterNodeMock; export const StereoPannerNode = StereoPannerNodeMock; +export const PannerNode = PannerNodeMock; export const WaveShaperNode = WaveShaperNodeMock; export const PeriodicWave = PeriodicWaveMock; @@ -1094,6 +1249,7 @@ export type OfflineAudioContext = OfflineAudioContextMock; export type OscillatorNode = OscillatorNodeMock; export type RecorderAdapterNode = RecorderAdapterNodeMock; export type StereoPannerNode = StereoPannerNodeMock; +export type PannerNode = PannerNodeMock; export type WaveShaperNode = WaveShaperNodeMock; export type PeriodicWave = PeriodicWaveMock; @@ -1126,6 +1282,9 @@ export { OscillatorOptions, PeriodicWaveOptions, StereoPannerOptions, + PannerOptions, + PanningModelType, + DistanceModelType, WaveShaperOptions, }; @@ -1152,6 +1311,7 @@ export default { OscillatorNode: OscillatorNodeMock, RecorderAdapterNode: RecorderAdapterNodeMock, StereoPannerNode: StereoPannerNodeMock, + PannerNode: PannerNodeMock, WaveShaperNode: WaveShaperNodeMock, PeriodicWave: PeriodicWaveMock, diff --git a/packages/react-native-audio-api/src/types.ts b/packages/react-native-audio-api/src/types.ts index 42ff4d907..bbda39ed0 100644 --- a/packages/react-native-audio-api/src/types.ts +++ b/packages/react-native-audio-api/src/types.ts @@ -138,6 +138,27 @@ export interface StereoPannerOptions extends AudioNodeOptions { pan?: number; } +export type PanningModelType = 'equalpower' | 'HRTF'; + +export type DistanceModelType = 'linear' | 'inverse' | 'exponential'; + +export interface PannerOptions extends AudioNodeOptions { + panningModel?: PanningModelType; + distanceModel?: DistanceModelType; + positionX?: number; + positionY?: number; + positionZ?: number; + orientationX?: number; + orientationY?: number; + orientationZ?: number; + refDistance?: number; + maxDistance?: number; + rolloffFactor?: number; + coneInnerAngle?: number; + coneOuterAngle?: number; + coneOuterGain?: number; +} + export interface AnalyserOptions extends AudioNodeOptions { fftSize?: number; minDecibels?: number; diff --git a/packages/react-native-audio-api/src/utils/validation/index.ts b/packages/react-native-audio-api/src/utils/validation/index.ts index c706d60fc..7a52b5de2 100644 --- a/packages/react-native-audio-api/src/utils/validation/index.ts +++ b/packages/react-native-audio-api/src/utils/validation/index.ts @@ -18,6 +18,8 @@ export { export { OscillatorOptionsValidator } from './oscillator'; +export { PannerOptionsValidator } from './panner'; + export { PeriodicWaveOptionsValidator } from './periodicWave'; export { validateWaveShaperCurve } from './waveShaper'; diff --git a/packages/react-native-audio-api/src/utils/validation/panner.ts b/packages/react-native-audio-api/src/utils/validation/panner.ts new file mode 100644 index 000000000..53c34f51a --- /dev/null +++ b/packages/react-native-audio-api/src/utils/validation/panner.ts @@ -0,0 +1,29 @@ +import { InvalidStateError, RangeError } from '../../errors'; +import type { OptionsValidator, PannerOptions } from '../../types'; + +export const PannerOptionsValidator: OptionsValidator = { + validate(options?: PannerOptions): void { + if (!options) { + return; + } + + if (options.refDistance !== undefined && options.refDistance < 0) { + throw new RangeError('refDistance cannot be set to a negative value'); + } + + if (options.maxDistance !== undefined && options.maxDistance <= 0) { + throw new RangeError('maxDistance cannot be set to a non-positive value'); + } + + if (options.rolloffFactor !== undefined && options.rolloffFactor < 0) { + throw new RangeError('rolloffFactor cannot be set to a negative value'); + } + + if ( + options.coneOuterGain !== undefined && + (options.coneOuterGain < 0 || options.coneOuterGain > 1) + ) { + throw new InvalidStateError('coneOuterGain must be in [0, 1]'); + } + }, +}; diff --git a/packages/react-native-audio-api/src/web-core/AudioContext.web.ts b/packages/react-native-audio-api/src/web-core/AudioContext.web.ts index 7638fbf1f..8f1b0c9bf 100644 --- a/packages/react-native-audio-api/src/web-core/AudioContext.web.ts +++ b/packages/react-native-audio-api/src/web-core/AudioContext.web.ts @@ -16,6 +16,7 @@ import MediaElementAudioSourceNode from './MediaElementAudioSourceNode.web'; import OscillatorNode from './OscillatorNode.web'; import PeriodicWave from './PeriodicWave.web'; import StereoPannerNode from './StereoPannerNode.web'; +import PannerNode from './PannerNode.web'; import ConstantSourceNode from './ConstantSourceNode.web'; import WaveShaperNode from './WaveShaperNode.web'; @@ -66,6 +67,10 @@ export default class AudioContext implements BaseAudioContext { return new StereoPannerNode(this); } + createPanner(): PannerNode { + return new PannerNode(this); + } + createBiquadFilter(): BiquadFilterNode { return new BiquadFilterNode(this); } diff --git a/packages/react-native-audio-api/src/web-core/BaseAudioContext.web.ts b/packages/react-native-audio-api/src/web-core/BaseAudioContext.web.ts index 9d635371a..4faf4673e 100644 --- a/packages/react-native-audio-api/src/web-core/BaseAudioContext.web.ts +++ b/packages/react-native-audio-api/src/web-core/BaseAudioContext.web.ts @@ -13,6 +13,7 @@ import IIRFilterNode from './IIRFilterNode.web'; import OscillatorNode from './OscillatorNode.web'; import PeriodicWave from './PeriodicWave.web'; import StereoPannerNode from './StereoPannerNode.web'; +import PannerNode from './PannerNode.web'; import WaveShaperNode from './WaveShaperNode.web'; export default interface BaseAudioContext { @@ -29,6 +30,7 @@ export default interface BaseAudioContext { createGain(): GainNode; createDelay(maxDelayTime?: number): DelayNode; createStereoPanner(): StereoPannerNode; + createPanner(): PannerNode; createBiquadFilter(): BiquadFilterNode; createIIRFilter(feedforward: number[], feedback: number[]): IIRFilterNode; createConvolver(): ConvolverNode; diff --git a/packages/react-native-audio-api/src/web-core/OfflineAudioContext.web.ts b/packages/react-native-audio-api/src/web-core/OfflineAudioContext.web.ts index a380850ed..a967f1a3b 100644 --- a/packages/react-native-audio-api/src/web-core/OfflineAudioContext.web.ts +++ b/packages/react-native-audio-api/src/web-core/OfflineAudioContext.web.ts @@ -13,6 +13,7 @@ import GainNode from './GainNode.web'; import OscillatorNode from './OscillatorNode.web'; import PeriodicWave from './PeriodicWave.web'; import StereoPannerNode from './StereoPannerNode.web'; +import PannerNode from './PannerNode.web'; import ConstantSourceNode from './ConstantSourceNode.web'; import WaveShaperNode from './WaveShaperNode.web'; @@ -80,6 +81,10 @@ export default class OfflineAudioContext implements BaseAudioContext { return new StereoPannerNode(this); } + createPanner(): PannerNode { + return new PannerNode(this); + } + createBiquadFilter(): BiquadFilterNode { return new BiquadFilterNode(this); } diff --git a/packages/react-native-audio-api/src/web-core/PannerNode.web.ts b/packages/react-native-audio-api/src/web-core/PannerNode.web.ts new file mode 100644 index 000000000..d79b216f2 --- /dev/null +++ b/packages/react-native-audio-api/src/web-core/PannerNode.web.ts @@ -0,0 +1,98 @@ +import { DistanceModelType, PannerOptions, PanningModelType } from '../types'; +import AudioNode from './AudioNode.web'; +import AudioParam from './AudioParam.web'; +import BaseAudioContext from './BaseAudioContext.web'; + +export default class PannerNode extends AudioNode { + readonly positionX: AudioParam; + readonly positionY: AudioParam; + readonly positionZ: AudioParam; + readonly orientationX: AudioParam; + readonly orientationY: AudioParam; + readonly orientationZ: AudioParam; + + constructor(context: BaseAudioContext, pannerOptions?: PannerOptions) { + const panner = new globalThis.PannerNode(context.context, pannerOptions); + super(context, panner); + this.positionX = new AudioParam(panner.positionX, context); + this.positionY = new AudioParam(panner.positionY, context); + this.positionZ = new AudioParam(panner.positionZ, context); + this.orientationX = new AudioParam(panner.orientationX, context); + this.orientationY = new AudioParam(panner.orientationY, context); + this.orientationZ = new AudioParam(panner.orientationZ, context); + } + + get panningModel(): PanningModelType { + return (this.node as globalThis.PannerNode) + .panningModel as PanningModelType; + } + + set panningModel(value: PanningModelType) { + (this.node as globalThis.PannerNode).panningModel = value; + } + + get distanceModel(): DistanceModelType { + return (this.node as globalThis.PannerNode) + .distanceModel as DistanceModelType; + } + + set distanceModel(value: DistanceModelType) { + (this.node as globalThis.PannerNode).distanceModel = value; + } + + get refDistance(): number { + return (this.node as globalThis.PannerNode).refDistance; + } + + set refDistance(value: number) { + (this.node as globalThis.PannerNode).refDistance = value; + } + + get maxDistance(): number { + return (this.node as globalThis.PannerNode).maxDistance; + } + + set maxDistance(value: number) { + (this.node as globalThis.PannerNode).maxDistance = value; + } + + get rolloffFactor(): number { + return (this.node as globalThis.PannerNode).rolloffFactor; + } + + set rolloffFactor(value: number) { + (this.node as globalThis.PannerNode).rolloffFactor = value; + } + + get coneInnerAngle(): number { + return (this.node as globalThis.PannerNode).coneInnerAngle; + } + + set coneInnerAngle(value: number) { + (this.node as globalThis.PannerNode).coneInnerAngle = value; + } + + get coneOuterAngle(): number { + return (this.node as globalThis.PannerNode).coneOuterAngle; + } + + set coneOuterAngle(value: number) { + (this.node as globalThis.PannerNode).coneOuterAngle = value; + } + + get coneOuterGain(): number { + return (this.node as globalThis.PannerNode).coneOuterGain; + } + + set coneOuterGain(value: number) { + (this.node as globalThis.PannerNode).coneOuterGain = value; + } + + setPosition(x: number, y: number, z: number): void { + (this.node as globalThis.PannerNode).setPosition(x, y, z); + } + + setOrientation(x: number, y: number, z: number): void { + (this.node as globalThis.PannerNode).setOrientation(x, y, z); + } +} diff --git a/packages/react-native-audio-api/wpt_tests/wpt-api.js b/packages/react-native-audio-api/wpt_tests/wpt-api.js index f91f4fb74..73d2f8dfc 100644 --- a/packages/react-native-audio-api/wpt_tests/wpt-api.js +++ b/packages/react-native-audio-api/wpt_tests/wpt-api.js @@ -35,6 +35,7 @@ const WEB_AUDIO_CLASSES = [ 'OscillatorNode', 'PeriodicWave', 'StereoPannerNode', + 'PannerNode', 'WaveShaperNode', ]; From af50e3ad5e1240076c66fe92e73d36b109708577 Mon Sep 17 00:00:00 2001 From: Barbara Wojtarowicz Date: Mon, 10 Aug 2026 16:53:01 +0200 Subject: [PATCH 04/13] feat: added panner node component --- .claude/README.md | 3 + .claude/skills/audio-nodes/SKILL.md | 66 ++- .../skills/audio-nodes/gainnode-example.md | 6 +- .../build-compilation-dependencies/SKILL.md | 20 + .../maintenance.md | 7 +- .claude/skills/expressive-code/SKILL.md | 85 +++ .claude/skills/expressive-code/maintenance.md | 11 + .claude/skills/flow/SKILL.md | 11 +- .claude/skills/host-objects/SKILL.md | 4 + .claude/skills/host-objects/examples.md | 4 +- .claude/skills/thread-safety-itc/SKILL.md | 25 +- .claude/skills/utilities/SKILL.md | 28 +- .claude/skills/utilities/api.md | 4 +- .claude/skills/utilities/maintenance.md | 2 +- .claude/skills/web-audio-api/SKILL.md | 4 +- .cursor/rules/expressive-code.mdc | 8 + .github/workflows/cpp-coverage-job.yml | 38 ++ .github/workflows/js-job.yml | 20 + .github/workflows/tests.yml | 33 +- .gitignore | 10 +- CLAUDE.md | 6 + .../demos/Record/RecordingVisualization.tsx | 173 ++++-- .../common-app/src/demos/Record/constants.tsx | 21 +- apps/common-app/src/demos/index.ts | 5 +- .../src/examples/AudioFile/AudioPlayer.ts | 8 +- .../AudioVisualizer/AudioVisualizer.tsx | 118 +++- .../src/examples/AudioVisualizer/Charts.tsx | 35 +- .../AudioVisualizer/FreqTimeChart.tsx | 88 +-- .../src/examples/AudioVisualizer/layout.ts | 4 + .../examples/ChannelCount/ChannelCount.tsx | 349 ++++++++++++ .../src/examples/ChannelCount/index.ts | 1 + .../ChannelMergerSplitter.tsx | 245 ++++++++ .../examples/ChannelMergerSplitter/index.tsx | 1 + .../src/examples/PannerNode/PannerNode.tsx | 411 ++++++++++++++ .../src/examples/PannerNode/index.tsx | 1 + .../src/examples/Worklets/Worklets.tsx | 14 +- apps/common-app/src/examples/index.ts | 25 + .../LatencyValidation/CorrelationPlot.tsx | 149 +++++ .../LatencyValidation/LatencyValidation.tsx | 198 +++++++ .../LoopbackAnalysisPanel.tsx | 238 ++++++++ .../other/LatencyValidation/WaveformPlot.tsx | 218 ++++++++ .../src/other/LatencyValidation/helpers.ts | 403 +++++++++++++ .../src/other/LatencyValidation/index.ts | 1 + .../other/LatencyValidation/latencyTests.ts | 528 ++++++++++++++++++ .../src/other/LatencyValidation/types.ts | 60 ++ apps/common-app/src/other/index.ts | 7 + .../IOSAudioRecorderTests.mm | 8 +- apps/fabric-example/ios/Podfile.lock | 10 +- .../audiodocs/docs/core/audio-context.mdx | 11 +- .../docs/core/base-audio-context.mdx | 27 +- .../docs/effects/channel-merger-node.mdx | 73 +++ .../docs/effects/channel-splitter-node.mdx | 74 +++ .../audiodocs/docs/effects/convolver-node.mdx | 2 +- packages/audiodocs/docs/effects/gain-node.mdx | 4 +- .../docs/effects/iir-filter-node.mdx | 2 +- .../audiodocs/docs/effects/periodic-wave.mdx | 2 +- .../docs/effects/stereo-panner-node.mdx | 2 +- .../docs/effects/wave-shaper-node.mdx | 2 +- .../docs/fundamentals/getting-started.mdx | 3 +- .../audiodocs/docs/inputs/audio-recorder.mdx | 5 +- .../docs/other/web-audio-api-coverage.mdx | 38 +- .../audio-buffer-queue-source-node.mdx | 35 +- .../docs/sources/audio-buffer-source-node.mdx | 4 +- .../media-element-audio-source-node.mdx | 2 +- .../audiodocs/docs/worklets/introduction.mdx | 9 +- .../docs/worklets/worklet-audio-context.mdx | 149 +++++ .../audiodocs/docs/worklets/worklet-node.mdx | 81 ++- .../docs/worklets/worklet-processing-node.mdx | 2 +- .../docs/worklets/worklet-source-node.mdx | 2 +- packages/audiodocs/docusaurus.config.js | 3 +- .../.clang-format-ignore | 2 +- .../react-native-audio-api/RNAudioAPI.podspec | 3 +- .../src/main/cpp/audioapi/CMakeLists.txt | 7 +- .../android/core/AndroidAudioRecorder.cpp | 65 ++- .../android/core/AndroidAudioRecorder.h | 15 +- .../cpp/audioapi/android/core/AudioPlayer.cpp | 58 +- .../cpp/audioapi/android/core/AudioPlayer.h | 11 + .../core/utils/AndroidFileWriterBackend.cpp | 2 +- .../core/utils/AndroidFileWriterBackend.h | 2 +- .../core/utils/AndroidRecorderCallback.cpp | 4 +- .../core/utils/AndroidRecorderCallback.h | 4 +- .../core/utils/AndroidRotatingFileWriter.cpp | 2 +- .../core/utils/AndroidRotatingFileWriter.h | 2 +- .../utils/ffmpegBackend/FFmpegFileWriter.cpp | 2 +- .../utils/ffmpegBackend/FFmpegFileWriter.h | 2 +- .../miniaudioBackend/MiniAudioFileWriter.cpp | 2 +- .../miniaudioBackend/MiniAudioFileWriter.h | 2 +- .../cpp/audioapi/AudioAPIModuleInstaller.h | 10 +- .../HostObjects/AudioContextHostObject.cpp | 62 +- .../HostObjects/AudioContextHostObject.h | 3 + .../HostObjects/AudioNodeHostObject.cpp | 199 ++++++- .../HostObjects/AudioNodeHostObject.h | 33 +- .../BaseAudioContextHostObject.cpp | 45 +- .../HostObjects/BaseAudioContextHostObject.h | 6 +- .../OfflineAudioContextHostObject.cpp | 55 +- ...edAudioNodePtr.h => TypedAudioNodePtr.hpp} | 0 .../analysis/AnalyserNodeHostObject.cpp | 2 +- .../analysis/AnalyserNodeHostObject.h | 2 +- .../AudioDestinationNodeHostObject.h | 6 +- .../effects/BiquadFilterNodeHostObject.cpp | 2 +- .../effects/BiquadFilterNodeHostObject.h | 2 +- .../effects/ChannelMergerNodeHostObject.cpp | 50 ++ .../effects/ChannelMergerNodeHostObject.h | 37 ++ .../effects/ChannelSplitterNodeHostObject.cpp | 51 ++ .../effects/ChannelSplitterNodeHostObject.h | 37 ++ .../effects/ConvolverNodeHostObject.cpp | 2 +- .../effects/ConvolverNodeHostObject.h | 2 +- .../effects/DelayNodeHostObject.cpp | 10 +- .../HostObjects/effects/DelayNodeHostObject.h | 9 +- .../effects/GainNodeHostObject.cpp | 2 +- .../HostObjects/effects/GainNodeHostObject.h | 2 +- .../effects/IIRFilterNodeHostObject.cpp | 2 +- .../effects/IIRFilterNodeHostObject.h | 2 +- .../effects/PannerNodeHostObject.cpp | 2 +- .../effects/StereoPannerNodeHostObject.cpp | 2 +- .../effects/StereoPannerNodeHostObject.h | 2 +- .../effects/WaveShaperNodeHostObject.cpp | 2 +- .../effects/WaveShaperNodeHostObject.h | 2 +- .../AudioEventHandlerRegistryHostObject.cpp | 4 +- .../AudioEventHandlerRegistryHostObject.h | 10 +- .../inputs/AudioRecorderHostObject.cpp | 10 +- .../inputs/AudioRecorderHostObject.h | 6 +- .../AudioBufferBaseSourceNodeHostObject.cpp | 3 +- .../AudioBufferBaseSourceNodeHostObject.h | 2 +- .../sources/AudioBufferHostObject.cpp | 30 +- .../AudioBufferQueueSourceNodeHostObject.cpp | 15 +- .../AudioBufferQueueSourceNodeHostObject.h | 8 +- .../AudioBufferSourceNodeHostObject.cpp | 12 +- .../sources/AudioBufferSourceNodeHostObject.h | 2 +- .../sources/AudioFileSourceNodeHostObject.cpp | 2 +- .../sources/AudioFileSourceNodeHostObject.h | 2 +- .../AudioScheduledSourceNodeHostObject.cpp | 2 +- .../AudioScheduledSourceNodeHostObject.h | 6 +- .../sources/ConstantSourceNodeHostObject.cpp | 2 +- .../sources/ConstantSourceNodeHostObject.h | 2 +- .../sources/OscillatorNodeHostObject.cpp | 2 +- .../sources/OscillatorNodeHostObject.h | 2 +- .../HostObjects/utils/JsEnumParser.cpp | 23 + .../audioapi/HostObjects/utils/JsEnumParser.h | 2 + .../HostObjects/utils/NodeOptionsParser.h | 44 +- .../cpp/audioapi/compatibility/StableAPI.h | 1 + .../common/cpp/audioapi/core/AudioContext.cpp | 103 +++- .../common/cpp/audioapi/core/AudioContext.h | 16 +- .../common/cpp/audioapi/core/AudioListener.h | 2 +- .../common/cpp/audioapi/core/AudioNode.cpp | 9 +- .../common/cpp/audioapi/core/AudioNode.h | 80 ++- .../common/cpp/audioapi/core/AudioParam.cpp | 35 +- .../common/cpp/audioapi/core/AudioParam.h | 57 +- .../cpp/audioapi/core/BaseAudioContext.cpp | 17 +- .../cpp/audioapi/core/BaseAudioContext.h | 41 +- .../common/cpp/audioapi/core/CommonPlayer.h | 3 + .../cpp/audioapi/core/CompositeAudioParam.hpp | 111 ++++ .../audioapi/core/GeneralizedAudioParam.cpp | 28 + .../cpp/audioapi/core/GeneralizedAudioParam.h | 95 ++++ .../cpp/audioapi/core/OfflineAudioContext.cpp | 92 ++- .../cpp/audioapi/core/OfflineAudioContext.h | 17 +- .../audioapi/core/analysis/AnalyserNode.cpp | 59 +- .../cpp/audioapi/core/analysis/AnalyserNode.h | 12 +- .../core/destinations/AudioDestinationNode.h | 6 +- .../core/effects/BiquadFilterNode.cpp | 38 +- .../audioapi/core/effects/BiquadFilterNode.h | 11 +- .../audioapi/core/effects/PeriodicWave.cpp | 27 +- .../cpp/audioapi/core/effects/PeriodicWave.h | 4 +- .../channel_merger/ChannelMergerInputNode.cpp | 28 + .../channel_merger/ChannelMergerInputNode.h | 40 ++ .../ChannelMergerOutputNode.cpp | 29 + .../channel_merger/ChannelMergerOutputNode.h | 28 + .../ChannelSplitterInputNode.cpp | 20 + .../ChannelSplitterInputNode.h | 33 ++ .../ChannelSplitterOutputNode.cpp | 31 + .../ChannelSplitterOutputNode.h | 35 ++ .../core/effects/delay/DelayWriter.cpp | 3 +- .../cpp/audioapi/core/inputs/AudioRecorder.h | 9 +- .../sources/AudioBufferBaseSourceNode.cpp | 54 +- .../core/sources/AudioBufferBaseSourceNode.h | 20 +- .../sources/AudioBufferQueueSourceNode.cpp | 8 +- .../core/sources/AudioBufferQueueSourceNode.h | 2 + .../core/sources/AudioBufferSourceNode.cpp | 7 +- .../core/sources/AudioFileSourceNode.cpp | 2 +- .../core/sources/AudioScheduledSourceNode.cpp | 23 +- .../core/sources/AudioScheduledSourceNode.h | 2 - .../core/sources/ConstantSourceNode.cpp | 2 +- .../audioapi/core/sources/OscillatorNode.cpp | 30 +- .../audioapi/core/sources/OscillatorNode.h | 13 +- .../audioapi/core/types/ContextPromiseTask.h | 21 + .../audioapi/core/utils/AudioFileWriter.cpp | 4 +- .../cpp/audioapi/core/utils/AudioFileWriter.h | 4 +- .../core/utils/AudioRecorderCallback.cpp | 4 +- .../core/utils/AudioRecorderCallback.h | 4 +- .../core/utils/buffer/BufferProcessorBase.cpp | 2 +- .../core/utils/buffer/BufferProcessorBase.h | 2 +- .../audioapi/core/utils/graph/AudioGraph.cpp | 1 + .../audioapi/core/utils/graph/AudioGraph.h | 3 +- .../cpp/audioapi/core/utils/graph/Graph.cpp | 12 + .../cpp/audioapi/core/utils/graph/Graph.h | 46 ++ .../audioapi/core/utils/graph/GraphObject.h | 19 +- .../audioapi/core/utils/graph/HostGraph.cpp | 54 +- .../cpp/audioapi/core/utils/graph/HostGraph.h | 8 + .../audioapi/core/utils/graph/HostNode.cpp | 4 + .../cpp/audioapi/core/utils/graph/HostNode.h | 5 + ...tFactory.hpp => ParamRenderEventFactory.h} | 2 +- .../core/utils/param/ParamRenderQueue.cpp | 2 +- .../dsp/{AudioUtils.hpp => AudioUtils.h} | 0 .../cpp/audioapi/dsp/SpectrumAnalyser.cpp | 62 ++ .../cpp/audioapi/dsp/SpectrumAnalyser.h | 57 ++ .../common/cpp/audioapi/dsp/VectorMath.cpp | 2 +- ...Mapping.h => AudioEventPayloadMapping.hpp} | 0 .../cpp/audioapi/events/EventCaller.hpp | 2 +- .../events/IAudioEventHandlerRegistry.h | 6 +- .../audioapi/jsi/ContextPromiseResolver.cpp | 65 +++ .../audioapi/jsi/ContextPromiseResolver.hpp | 119 ++++ .../common/cpp/audioapi/jsi/HostObject.h | 2 +- .../common/cpp/audioapi/jsi/JsiPromise.cpp | 21 + .../common/cpp/audioapi/jsi/JsiPromise.h | 7 + .../common/cpp/audioapi/jsi/JsiUtils.cpp | 11 + .../common/cpp/audioapi/jsi/JsiUtils.h | 3 + ...stanceCache.h => RuntimeInstanceCache.hpp} | 0 .../common/cpp/audioapi/types/NodeOptions.h | 83 ++- .../utils}/AudioThreadGuard.cpp | 2 +- .../utils}/AudioThreadGuard.h | 0 .../common/cpp/audioapi/utils/SpscChannel.hpp | 10 +- .../events/PositionChangedDispatcher.cpp | 2 +- .../common/cpp/clangd/generate-and-copy.sh | 29 +- .../common/cpp/test/CMakeLists.txt | 61 +- .../common/cpp/test/RunCoverage.sh | 102 ++++ .../common/cpp/test/RunTests.sh | 12 +- .../cpp/test/src/core/AudioParamTest.cpp | 173 +++++- .../test/src/core/CompositeAudioParamTest.cpp | 131 +++++ .../src/core/effects/ChannelMergerTest.cpp | 83 +++ .../src/core/effects/ChannelSplitterTest.cpp | 126 +++++ .../sources/AudioBufferSourceNodeTest.cpp | 168 ++++++ .../src/core/sources/ConstantSourceTest.cpp | 5 +- .../test/src/core/sources/OscillatorTest.cpp | 37 ++ .../cpp/test/src/events/EventCallerTest.cpp | 2 +- .../test/src/graph/GraphNodeGrowthTest.cpp | 2 +- .../common/cpp/test/src/graph/GraphTest.cpp | 49 ++ .../cpp/test/src/graph/MockGraphProcessor.h | 2 +- .../test/src/graph/SettleProcessableTest.cpp | 24 + .../test/src/utils/SpectrumAnalyserTest.cpp | 140 +++++ .../ios/audioapi/ios/core/IOSAudioPlayer.h | 14 +- .../ios/audioapi/ios/core/IOSAudioPlayer.mm | 21 + .../ios/audioapi/ios/core/IOSAudioRecorder.h | 11 +- .../ios/audioapi/ios/core/IOSAudioRecorder.mm | 33 +- .../audioapi/ios/core/utils/IOSFileWriter.h | 4 +- .../audioapi/ios/core/utils/IOSFileWriter.mm | 4 +- .../ios/core/utils/IOSRecorderCallback.h | 4 +- .../ios/core/utils/IOSRecorderCallback.mm | 4 +- .../ios/core/utils/IOSRotatingFileWriter.h | 2 +- .../ios/core/utils/IOSRotatingFileWriter.mm | 4 +- .../audioapi/ios/system/AudioSessionManager.h | 4 + .../ios/system/AudioSessionManager.mm | 16 + packages/react-native-audio-api/package.json | 42 +- .../react-native-audio-api/scripts/build.sh | 5 + .../react-native-audio-api/scripts/cpplint.sh | 2 +- .../scripts/create-package.sh | 2 +- .../src/Audio/useAudioSourceLoader.ts | 3 +- packages/react-native-audio-api/src/api.ts | 2 + .../react-native-audio-api/src/api.web.ts | 2 + .../src/core/AnalyserNode.ts | 2 +- .../src/core/AudioBuffer.ts | 32 +- .../src/core/AudioBufferBaseSourceNode.ts | 9 +- .../src/core/AudioBufferQueueSourceNode.ts | 42 +- .../src/core/AudioBufferSourceNode.ts | 2 +- .../src/core/AudioContext.ts | 18 +- .../src/core/AudioNode.ts | 153 ++++- .../src/core/AudioParam.ts | 1 + .../src/core/AudioRecorder.ts | 19 +- .../src/core/BaseAudioContext.ts | 27 +- .../src/core/BiquadFilterNode.ts | 4 +- .../src/core/ChannelMergerNode.ts | 15 + .../src/core/ChannelSplitterNode.ts | 15 + .../src/core/ConstantSourceNode.ts | 2 +- .../src/core/ConvolverNode.ts | 2 +- .../src/core/DelayNode.ts | 2 +- .../src/core/GainNode.ts | 2 +- .../src/core/IIRFilterNode.ts | 2 +- .../src/core/MediaElementAudioSourceNode.ts | 25 +- .../src/core/OfflineAudioContext.ts | 4 +- .../src/core/OscillatorNode.ts | 10 +- .../src/core/PeriodicWave.ts | 35 +- .../src/core/StereoPannerNode.ts | 2 +- .../src/core/WaveShaperNode.ts | 8 +- .../src/jsi-interfaces.ts | 56 +- .../react-native-audio-api/src/mock/index.ts | 188 ++++++- packages/react-native-audio-api/src/types.ts | 25 +- .../src/utils/audioConstants.ts | 16 + .../react-native-audio-api/src/utils/index.ts | 26 +- .../src/utils/periodicWave.ts | 41 ++ .../src/utils/validation/analyser.ts | 3 - .../src/utils/validation/audioNodeOptions.ts | 26 +- .../src/utils/validation/biquadFilter.ts | 9 - .../utils/validation/channelMergerSplitter.ts | 96 ++++ .../src/utils/validation/index.ts | 2 - .../src/utils/validation/oscillator.ts | 15 +- .../src/utils/validation/periodicWave.ts | 57 +- .../src/web-core/AudioBuffer.web.ts | 16 +- .../src/web-core/AudioContext.web.ts | 30 +- .../src/web-core/AudioNode.web.ts | 70 ++- .../src/web-core/BaseAudioContext.web.ts | 4 + .../src/web-core/ChannelMergerNode.web.ts | 10 + .../src/web-core/ChannelSplitterNode.web.ts | 10 + .../src/web-core/OfflineAudioContext.web.ts | 24 +- .../src/web-core/PeriodicWave.web.ts | 10 +- .../src/web-core/WaveShaperNode.web.ts | 5 +- .../tests/channel-merger-splitter.test.ts | 91 +++ .../tests/integration.test.ts | 20 +- .../react-native-audio-api/tests/mock.test.ts | 4 + .../wpt_tests/README.md | 2 +- .../wpt_tests/src/NodeAudioPlayer.cpp | 29 +- .../wpt_tests/src/NodeAudioPlayer.h | 2 + .../wpt_tests/src/jsi_install.cpp | 8 +- .../wpt_tests/wpt-api.js | 2 + .../wpt_tests/wpt/skip-list.json | 16 +- .../wpt_tests/wpt/wpt-only/README.md | 8 + ...hannel-merger-splitter-attribute-locks.mjs | 153 +++++ .../wpt_tests/wpt/wpt-results.mjs | 14 +- .../wpt_tests/wpt/wpt-shared.mjs | 2 + .../wpt_tests/wpt/wpt-utils.mjs | 29 +- .../wpt/wrap-audio-node-constructors.mjs | 2 + .../RNAudioWorklets.podspec | 1 + .../audioworklets/AudioWorkletsInstaller.h | 88 ++- .../WorkletAudioContextHostObject.cpp | 70 +++ .../WorkletAudioContextHostObject.h | 25 + .../HostObjects/WorkletNodeHostObject.cpp | 49 ++ .../HostObjects/WorkletNodeHostObject.h | 35 +- .../HostObjects/utils/NodeOptionsParser.h | 30 + .../NativeAudioWorkletsModule.cpp | 4 +- .../audioworklets/NativeAudioWorkletsModule.h | 3 + .../cpp/audioworklets/UIWorkletsRunner.cpp | 25 +- .../cpp/audioworklets/UIWorkletsRunner.h | 20 +- .../core/WorkletAudioContext.cpp | 135 +++++ .../audioworklets/core/WorkletAudioContext.h | 36 ++ .../cpp/audioworklets/core/WorkletNode.cpp | 112 +++- .../cpp/audioworklets/core/WorkletNode.h | 58 +- .../audioworklets/core/WorkletNodeDomain.h | 12 + .../cpp/audioworklets/types/NodeOptions.h | 14 + .../audioworklets/utils/AudioChannelViews.cpp | 8 + .../audioworklets/utils/AudioChannelViews.h | 3 + .../common/cpp/clangd/SETUP.md | 3 +- .../common/cpp/clangd/generate-and-copy.sh | 27 +- .../src/AudioWorkletsModule.ts | 2 +- .../src/WorkletAudioContext.ts | 65 +++ .../src/WorkletNode.ts | 35 +- .../src/globals.d.ts | 9 + .../react-native-audio-worklets/src/index.ts | 13 +- .../react-native-audio-worklets/src/types.ts | 42 +- .../react-native-audio-worklets/src/utils.ts | 94 +++- 347 files changed, 9858 insertions(+), 1238 deletions(-) create mode 100644 .claude/skills/expressive-code/SKILL.md create mode 100644 .claude/skills/expressive-code/maintenance.md create mode 100644 .cursor/rules/expressive-code.mdc create mode 100644 .github/workflows/cpp-coverage-job.yml create mode 100644 .github/workflows/js-job.yml create mode 100644 apps/common-app/src/examples/ChannelCount/ChannelCount.tsx create mode 100644 apps/common-app/src/examples/ChannelCount/index.ts create mode 100644 apps/common-app/src/examples/ChannelMergerSplitter/ChannelMergerSplitter.tsx create mode 100644 apps/common-app/src/examples/ChannelMergerSplitter/index.tsx create mode 100644 apps/common-app/src/examples/PannerNode/PannerNode.tsx create mode 100644 apps/common-app/src/examples/PannerNode/index.tsx create mode 100644 apps/common-app/src/other/LatencyValidation/CorrelationPlot.tsx create mode 100644 apps/common-app/src/other/LatencyValidation/LatencyValidation.tsx create mode 100644 apps/common-app/src/other/LatencyValidation/LoopbackAnalysisPanel.tsx create mode 100644 apps/common-app/src/other/LatencyValidation/WaveformPlot.tsx create mode 100644 apps/common-app/src/other/LatencyValidation/helpers.ts create mode 100644 apps/common-app/src/other/LatencyValidation/index.ts create mode 100644 apps/common-app/src/other/LatencyValidation/latencyTests.ts create mode 100644 apps/common-app/src/other/LatencyValidation/types.ts create mode 100644 packages/audiodocs/docs/effects/channel-merger-node.mdx create mode 100644 packages/audiodocs/docs/effects/channel-splitter-node.mdx create mode 100644 packages/audiodocs/docs/worklets/worklet-audio-context.mdx rename packages/react-native-audio-api/common/cpp/audioapi/HostObjects/{TypedAudioNodePtr.h => TypedAudioNodePtr.hpp} (100%) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ChannelMergerNodeHostObject.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ChannelMergerNodeHostObject.h create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ChannelSplitterNodeHostObject.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/HostObjects/effects/ChannelSplitterNodeHostObject.h create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/CompositeAudioParam.hpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/GeneralizedAudioParam.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/GeneralizedAudioParam.h create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/effects/channel_merger/ChannelMergerInputNode.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/effects/channel_merger/ChannelMergerInputNode.h create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/effects/channel_merger/ChannelMergerOutputNode.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/effects/channel_merger/ChannelMergerOutputNode.h create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/effects/channel_splitter/ChannelSplitterInputNode.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/effects/channel_splitter/ChannelSplitterInputNode.h create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/effects/channel_splitter/ChannelSplitterOutputNode.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/effects/channel_splitter/ChannelSplitterOutputNode.h create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/types/ContextPromiseTask.h rename packages/react-native-audio-api/common/cpp/audioapi/core/utils/param/{ParamRenderEventFactory.hpp => ParamRenderEventFactory.h} (99%) rename packages/react-native-audio-api/common/cpp/audioapi/dsp/{AudioUtils.hpp => AudioUtils.h} (100%) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/dsp/SpectrumAnalyser.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/dsp/SpectrumAnalyser.h rename packages/react-native-audio-api/common/cpp/audioapi/events/{AudioEventPayloadMapping.h => AudioEventPayloadMapping.hpp} (100%) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/jsi/ContextPromiseResolver.cpp create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/jsi/ContextPromiseResolver.hpp rename packages/react-native-audio-api/common/cpp/audioapi/jsi/{RuntimeInstanceCache.h => RuntimeInstanceCache.hpp} (100%) rename packages/react-native-audio-api/common/cpp/{test/src/graph => audioapi/utils}/AudioThreadGuard.cpp (98%) rename packages/react-native-audio-api/common/cpp/{test/src/graph => audioapi/utils}/AudioThreadGuard.h (100%) create mode 100755 packages/react-native-audio-api/common/cpp/test/RunCoverage.sh create mode 100644 packages/react-native-audio-api/common/cpp/test/src/core/CompositeAudioParamTest.cpp create mode 100644 packages/react-native-audio-api/common/cpp/test/src/core/effects/ChannelMergerTest.cpp create mode 100644 packages/react-native-audio-api/common/cpp/test/src/core/effects/ChannelSplitterTest.cpp create mode 100644 packages/react-native-audio-api/common/cpp/test/src/core/sources/AudioBufferSourceNodeTest.cpp create mode 100644 packages/react-native-audio-api/common/cpp/test/src/utils/SpectrumAnalyserTest.cpp create mode 100755 packages/react-native-audio-api/scripts/build.sh create mode 100644 packages/react-native-audio-api/src/core/ChannelMergerNode.ts create mode 100644 packages/react-native-audio-api/src/core/ChannelSplitterNode.ts create mode 100644 packages/react-native-audio-api/src/utils/audioConstants.ts create mode 100644 packages/react-native-audio-api/src/utils/periodicWave.ts delete mode 100644 packages/react-native-audio-api/src/utils/validation/biquadFilter.ts create mode 100644 packages/react-native-audio-api/src/utils/validation/channelMergerSplitter.ts create mode 100644 packages/react-native-audio-api/src/web-core/ChannelMergerNode.web.ts create mode 100644 packages/react-native-audio-api/src/web-core/ChannelSplitterNode.web.ts create mode 100644 packages/react-native-audio-api/tests/channel-merger-splitter.test.ts create mode 100644 packages/react-native-audio-api/wpt_tests/wpt/wpt-only/README.md create mode 100644 packages/react-native-audio-api/wpt_tests/wpt/wpt-only/channel-merger-splitter-attribute-locks.mjs create mode 100644 packages/react-native-audio-worklets/common/cpp/audioworklets/HostObjects/WorkletAudioContextHostObject.cpp create mode 100644 packages/react-native-audio-worklets/common/cpp/audioworklets/HostObjects/WorkletAudioContextHostObject.h create mode 100644 packages/react-native-audio-worklets/common/cpp/audioworklets/HostObjects/WorkletNodeHostObject.cpp create mode 100644 packages/react-native-audio-worklets/common/cpp/audioworklets/HostObjects/utils/NodeOptionsParser.h create mode 100644 packages/react-native-audio-worklets/common/cpp/audioworklets/core/WorkletAudioContext.cpp create mode 100644 packages/react-native-audio-worklets/common/cpp/audioworklets/core/WorkletAudioContext.h create mode 100644 packages/react-native-audio-worklets/common/cpp/audioworklets/core/WorkletNodeDomain.h create mode 100644 packages/react-native-audio-worklets/common/cpp/audioworklets/types/NodeOptions.h create mode 100644 packages/react-native-audio-worklets/src/WorkletAudioContext.ts diff --git a/.claude/README.md b/.claude/README.md index c4c65a35c..a476b29d8 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -48,6 +48,9 @@ This directory contains project-specific configuration for [Claude Code](https:/ │ ├── flow/ │ │ ├── SKILL.md # End-to-end feature flow │ │ └── maintenance.md +│ ├── expressive-code/ +│ │ ├── SKILL.md # Naming and comment style guidelines +│ │ └── maintenance.md │ └── writing-skills/ │ ├── SKILL.md # How to write and maintain skill files (meta) │ └── maintenance.md diff --git a/.claude/skills/audio-nodes/SKILL.md b/.claude/skills/audio-nodes/SKILL.md index b27465fad..da108b451 100644 --- a/.claude/skills/audio-nodes/SKILL.md +++ b/.claude/skills/audio-nodes/SKILL.md @@ -39,6 +39,8 @@ common/cpp/audioapi/core/ │ ├── 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 @@ -106,7 +108,7 @@ Settle algorithm (allocation-free): 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** `excludeFromProcessablePull_ = 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. +- **`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). --- @@ -175,7 +177,7 @@ When the node finishes, fire the `ENDED` event to JS via `audioEventHandlerRegis ```cpp protected: - // Audio-thread only + /// @note Audio Thread only virtual std::shared_ptr processNode( const std::shared_ptr &processingBuffer, int framesToProcess) = 0; @@ -194,12 +196,12 @@ protected: ```cpp class MyNode : public AudioNode { public: - // JS-thread only + /// @note JS Thread only void setSomething(float value); float getSomething() const; protected: - // Audio-thread only + /// @note Audio Thread only std::shared_ptr processNode( const std::shared_ptr &processingBuffer, int framesToProcess) override; @@ -208,14 +210,14 @@ class MyNode : public AudioNode { In `AudioParam.h` the pattern is: ```cpp -/// JS-Thread only methods +/// @note JS Thread only [[nodiscard]] inline float getValue() const noexcept { ... } void setValue(float value); void setValueAtTime(float value, double startTime); -/// Audio-Thread only methods -std::shared_ptr processARateParam(int framesToProcess, double time); -float processKRateParam(int framesToProcess, double time); +/// Audio-Thread only methods (idempotent per quantum — see below) +std::shared_ptr processARateParam(int framesToProcess, double time); +float processKRateParam(double time); // k-rate is quantum-wide ``` --- @@ -245,11 +247,37 @@ gainParam_ = std::make_shared( - **K-rate (control-rate)**: one value per render quantum — use when the parameter changes slowly ```cpp - // Call processKRateParam() for a single block-wide value - float gain = gainParam_->processKRateParam(framesToProcess, time); + // 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` 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`** (`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. + ### JS → Audio Thread parameter updates `CrossThreadEventScheduler` 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. @@ -289,6 +317,20 @@ Callback IDs are stored as `std::atomic` on the node. `0` means no lis ### JS → Audio (graph mutations: connect/disconnect) All graph mutations are queued via `AudioGraphManager` using its own SPSC channel (`addPendingNodeConnection`, `addPendingParamConnection`). The audio thread calls `graphManager_->preProcessGraph()` before each render pass to apply pending changes. +### Settable channel attributes (channelCount / channelCountMode / channelInterpretation) +These are mutable after construction. `AudioNode` (core) exposes virtual `setChannelCount` / `setChannelCountMode` / `setChannelInterpretation`. `channelCount` and `channelCountMode` are read only on the host thread during negotiation, so the JSI setter updates the core field directly then calls `HostNode::renegotiate()` → `Graph::renegotiateNodeChannels()` → `HostGraph::renegotiateNodeChannels()` (reuses `collectNegotiations` + an `AGEvent` buffer swap, self-drain aware when there is no audio/render consumer — offline construction/suspend and realtime suspended/stopped windows). When `AudioBufferSourceNode` `setBuffer` changes channel width, update `channelCount_` on the host thread then `renegotiate()` so MAX/CLAMPED_MAX downstream nodes update; the audio event still installs the prebuilt buffer (no audio-thread alloc). `channelInterpretation` is read on the audio thread in `processInputs` (`getInputBuffer()->sum(*input, channelInterpretation_)`), so it MUST be applied via `scheduleAudioEvent`, not mutated directly. + +### Idle-node stale-buffer zeroing (settleProcessableState) +`AudioGraph::iter()` filters to `isProcessable()` nodes, so a node that has gone idle (e.g. a finished source) is skipped and its output buffer is NOT refreshed — it keeps the samples from an earlier quantum. Downstream consumers still read that buffer via `getOutput()` when collecting inputs, which would re-sum ghost echoes every quantum (this broke the `audionode-channel-rules` ~170-node WPT test). + +Fix: after the reverse-topo pull in `AudioGraph::settleProcessableState()`, zero the output buffer of every node that is still `!isProcessable()`. Active CONDITIONAL nodes have already been pulled, so they are left intact; tail-bearing nodes remain `isProcessable()` while draining and are also left intact. + +Do **not** gate `GraphObject::process()` on `isProcessable()` of inputs: CONDITIONAL nodes demote themselves to `NOT_PROCESSABLE` at the end of their own `process()` call, before downstream consumers run in the same topological pass — an `isProcessable()` gate would drop every live conditional input every quantum. + +`AudioNode::disable()` only sets `alwaysNotProcessable_` (sticky: settle must not re-activate a finished source). The current quantum's output stays intact for downstream mixing; the next settle zeros the idle buffer. No deferred `pendingDisable_` flag is needed once settle performs idle zeroing. + +Tail-bearing nodes (Delay/Convolver/Biquad) need no special handling: while connected they stay `CONDITIONAL_PROCESSABLE`; after disconnect they stay `isProcessable()` via the tail override until the impulse decays, so settle does not zero them mid-tail. + --- ## Implementing a New Node — Checklist @@ -299,7 +341,7 @@ All graph mutations are queued via `AudioGraphManager` using its own SPSC channe - `AudioBufferBaseSourceNode` — source that plays back an AudioBuffer with pitch control 2. **Header file** (`core//MyNode.h`) - - Annotate every method with `// JS-thread only` or `// Audio-thread only` + - Annotate every method with `/// @note JS Thread only` or `/// @note Audio Thread only` - Declare `processNode()` in `protected:` - Declare `AudioParam` members for automatable properties - Preallocate all buffers you'll need in `private:` state @@ -328,7 +370,7 @@ All graph mutations are queued via `AudioGraphManager` using its own SPSC channe 7. **Spec compliance** - Check the Web Audio API spec for default values, parameter ranges, and behavior - - See `web-audio-api.md` skill + - See `web-audio-api` skill 8. **Tests and docs** — see the `flow` skill diff --git a/.claude/skills/audio-nodes/gainnode-example.md b/.claude/skills/audio-nodes/gainnode-example.md index a9656d79a..c5af524ea 100644 --- a/.claude/skills/audio-nodes/gainnode-example.md +++ b/.claude/skills/audio-nodes/gainnode-example.md @@ -13,16 +13,16 @@ namespace audioapi { class GainNode : public AudioNode { public: - // JS-thread only + /// @note JS Thread only explicit GainNode( const std::shared_ptr &context, const GainOptions &options); - // JS-thread only + /// @note JS Thread only [[nodiscard]] std::shared_ptr getGainParam() const; protected: - // Audio-thread only + /// @note Audio Thread only std::shared_ptr processNode( const std::shared_ptr &processingBuffer, int framesToProcess) override; diff --git a/.claude/skills/build-compilation-dependencies/SKILL.md b/.claude/skills/build-compilation-dependencies/SKILL.md index 6ca29e541..ae2b48122 100644 --- a/.claude/skills/build-compilation-dependencies/SKILL.md +++ b/.claude/skills/build-compilation-dependencies/SKILL.md @@ -54,6 +54,12 @@ react-native-audio-api/ --- +## C++ Header File Extensions + +In `common/cpp/audioapi/`: `.hpp` = header-only templates; `.h` = non-template (usually with a `.cpp`). Vendored code is excluded. + +--- + ## Prebuilt Binaries External libraries (Opus, Ogg, Vorbis, OpenSSL, FFmpeg) are **not compiled from source** — they are downloaded as prebuilt `.a` / `.so` / `.xcframework` archives from: @@ -193,6 +199,7 @@ Script: [`scripts/validate.sh`](../../../scripts/validate.sh) at monorepo root. |---|---|---| | TS build (`bob build`) | Yes | `--fast` | | C++ test subset (`RunTests.sh`) | Yes | `--fast` | +| C++ coverage (`RunCoverage.sh`, Clang) | Yes (`cpp-coverage` artifact) | `yarn test:cpp:coverage` | | Jest | Yes | `--fast` | | Graph tests | No, path-filtered in `graph-tests.yml` | `--graph` | | HostObjects (26 JSI `.cpp` files) | **No** | `--android` + `--ios` | @@ -245,6 +252,19 @@ cd build && make -j10 The `build/` directory is deleted after each run. +### Coverage (Clang / llvm-cov) + +```bash +yarn workspace react-native-audio-api test:cpp:coverage +# open packages/react-native-audio-api/common/cpp/test/coverage-html/index.html +``` + +`RunCoverage.sh` configures a separate `build-coverage/` tree with `-DENABLE_COVERAGE=ON` (Clang-only LLVM source-based coverage: `-fprofile-instr-generate -fcoverage-mapping`), defaults `CC`/`CXX` to `clang`/`clang++` when unset, runs the same gtest filter as `RunTests.sh`, then prints `llvm-cov report` and writes HTML via `llvm-cov show -format=html`. When `GITHUB_STEP_SUMMARY` is set, the report is also appended there. Sanitizer targets are skipped when coverage is enabled. Requires Apple Clang / `xcrun llvm-profdata` and `xcrun llvm-cov` on macOS (or the same tools on PATH for Linux). + +CI runs a parallel `cpp-coverage` job via `.github/workflows/cpp-coverage-job.yml` (called from `tests.yml` on pull requests; Clang + LLVM apt packages, separate from the GCC `cpp-tests` job). It uploads the HTML tree as the `cpp-coverage-html` artifact (14-day retention); download the zip from the Actions run and open `index.html`. Manual `workflow_dispatch` on `tests.yml` accepts booleans `run_cpp_tests` / `run_cpp_coverage` / `run_js_tests` (default true); PRs always run all three. + +> **Generated build trees must be named `build*`.** The C++ linters walk the filesystem with `find` and never consult git, so a `.gitignore` entry does not keep generated sources out of them. Exclusion happens by directory name in two places that must stay in sync: `**/build*/**` in `.clang-format-ignore` (used by `format:check:common`) and `-type d -name 'build*' -prune` in `scripts/cpplint.sh`. A CMake binary directory outside that prefix makes the pre-commit hook fail on generated files such as `CMakeFiles/*/CompilerIdCXX/CMakeCXXCompilerId.cpp`. CI never hits this because it checks out a clean tree. + ### Key design decisions - Completely standalone — no Gradle, no Xcode, no prebuilt Android libraries needed - Sources resolved from `node_modules` (symlinked to `packages/` in yarn workspaces) diff --git a/.claude/skills/build-compilation-dependencies/maintenance.md b/.claude/skills/build-compilation-dependencies/maintenance.md index cf6787859..a7e3bf44d 100644 --- a/.claude/skills/build-compilation-dependencies/maintenance.md +++ b/.claude/skills/build-compilation-dependencies/maintenance.md @@ -11,6 +11,11 @@ Review this skill when `pre-push-update` reports changes in: | `android/build.gradle` | Feature flag detection, CMake arg forwarding, BuildConfig fields, worklets task dependency, packaging options | | `RNAudioAPI.podspec` | Subspecs table, `miniaudio_impl` workaround, `-force_load` list, xcframeworks list, `rnaa_utils.rb` dynamic paths | | `apps/fabric-example/ios/Podfile` | New Architecture enablement, minimum iOS version helper | -| `common/cpp/test/CMakeLists.txt` | Excluded sources list, compile definitions, GoogleTest fetch URL, include paths | +| `common/cpp/test/CMakeLists.txt` | Excluded sources list, compile definitions, GoogleTest fetch URL, include paths, `ENABLE_COVERAGE` | +| `common/cpp/test/RunCoverage.sh` | Coverage build dir, Clang CC/CXX defaults, llvm-profdata/llvm-cov report+HTML, `GITHUB_STEP_SUMMARY`, ignore regexes | +| `.github/workflows/cpp-coverage-job.yml` | Reusable coverage job (Clang/LLVM install, artifact `cpp-coverage-html`) | +| `.github/workflows/js-job.yml` | Reusable JS integration tests job (draft-PR skip) | +| `.github/workflows/tests.yml` | Calls cpp/js/coverage jobs; `workflow_dispatch` booleans select jobs | +| `.clang-format-ignore` / `scripts/cpplint.sh` | Autogenerated files exclusions | | `common/cpp/test/src/MockAudioEventHandlerRegistry.h` | Mock interface — update fixture boilerplate in `build-details.md` if signature changes | | `scripts/download-prebuilt-binaries.sh` | New download artifacts, new TAG version | diff --git a/.claude/skills/expressive-code/SKILL.md b/.claude/skills/expressive-code/SKILL.md new file mode 100644 index 000000000..03c1045af --- /dev/null +++ b/.claude/skills/expressive-code/SKILL.md @@ -0,0 +1,85 @@ +--- +name: expressive-code +description: > + Guidance for making source code self-documenting and deciding when comments are appropriate. + Covers naming principles for classes, functions, variables, and constants; cases where syntax + needs supporting explanation; language-specific documentation styles; and common comment + anti-patterns. Use when choosing or reviewing names, documenting complex behavior or contracts, + or adding, editing, reviewing, or removing comments. + Trigger phrases: "naming principles", "naming conventions", "name this", "rename this", + "class name", "function name", "variable name", "constant name", "magic constant", + "self-documenting code", "comment style", "add a comment", "write comments", + "comment guidelines", "document this", "too many comments", "remove comment", "JSDoc", + "Doxygen". +--- + +# Skill: Expressive Code + +## Principles + +### Self-Documenting Code +Prefer **clean code over comments**. Prefer using **language syntax features** to emphasize semantics, including: +* **Class** and type names describe an object's *traits*. +* **Function** names describe *verbs*. +* **Variable** names describe their *purpose*. + * In particular, language **constants** explain the *purpose* of values (as opposed to **magic constants**). + +#### Names +When possible, a name should **capture semantics fully**. + +If the **semantics are complex**, it might be due to one of the following: +* The entity has **too many responsibilities**. +* It is **impossible or infeasible to divide it** further. + * In this scenario, don't be afraid to use slightly longer names. + * At the same time, keep in mind that too many long names tend to make code difficult to grasp. + * Consider adding comments according to the rules. + +Generally, prefer standard, idiomatic names. + +### Frequency of Comments +Use **comments only when necessary**. Add them only when something cannot be expressed easily in code. Their purpose is to make complex fragments easier to understand. Most code fragments are relatively easy to understand simply by **reading them like prose** (as explained above). + +### When Syntax is Not Enough +Some code fragments require additional non-obvious explanations that cannot be expressed through syntax. Common scenarios include: +* Reasons behind **decisions** when other options were available. + * Including intentional specification deviations. +* Explanations of steps in a **complex algorithm** and/or **abstract** mathematical concepts. +* Documentation of non-obvious **side effects** and/or **implications**. +* **References** to specifications, literature, other relevant sources of knowledge, and credits. +* Complex semantics not easily expressible through short name and/or syntax. + * Invariants not expressible through assertions, etc. + * Including assumptions about the environment, timing, threads, and locks. + * Behavioral contracts. + * Including **interfaces**, **abstract classes**, and **virtual methods**. + +### Depth of Comments +When you decide a fragment requires a comment, make sure that it is helpful for a person, who didn't participate in the process of writing it. It must not contain mental shortcuts. It should be explicit and easy to understand. It is better to write a medium-sized in-depth comment, than to write a short comment that takes long time to parse it. + +### Comment Style +Use the appropriate documentation style for each programming language. C and C++ typically use Doxygen, while JS and TS use JSDoc. + +## Common Anti-Patterns +* Discarding important and/or unobvious details. + * Class/method signature doesn't always explain semantics fully. In such a case it is mandatory to make a note about an unobvious behavior and/or requirement. +* Increasing coupling, which makes maintenance harder. + * Repeating the same or similar comments in many places about the same entity. + * When adding a comment about a method, for example, place it only in the most important location (such as the header file), so it is easy to find. + * Restating what can be read explicitly in the code. + * Taking over Git's responsibilities. + * Leaving commented-out code. + * Writing changelog or authorship information. + * Non-local information. + * It is dangerous to reference a decision made in a distant location. When the decision changes, it is not obvious that all relevant comments must be updated. +* Information noise. + * Referencing inaccessible context. + * Don't record a line of reasoning, as there are many ways to reach a conclusion. + * Agents tend to reference conversations with their users in code. + * There is no requirement that *every* function have Javadoc, etc. + * Imprecision. + * A general feeling. + * Example: "The function calculates it well." + * Reflecting a subjective mental image. + * Writing a TODO without an actionable next step (and preferably an issue or concrete constraint). + * A wrong comment is worse than none. + +*Maintenance: see [maintenance.md](maintenance.md).* diff --git a/.claude/skills/expressive-code/maintenance.md b/.claude/skills/expressive-code/maintenance.md new file mode 100644 index 000000000..3b793529c --- /dev/null +++ b/.claude/skills/expressive-code/maintenance.md @@ -0,0 +1,11 @@ +# Maintenance — expressive-code + +> Used by `/pre-push-update` only — not loaded when the `expressive-code` skill is active. + +Review this skill when `pre-push-update` reports changes in: + +| Path | What to check | +|---|---| +| `.claude/skills/expressive-code/SKILL.md` | Principles, anti-patterns, trigger phrases | +| `CLAUDE.md` | Skills table row | +| `.claude/README.md` | Skills tree lists `expressive-code/` | diff --git a/.claude/skills/flow/SKILL.md b/.claude/skills/flow/SKILL.md index c3b884ca5..093931b89 100644 --- a/.claude/skills/flow/SKILL.md +++ b/.claude/skills/flow/SKILL.md @@ -74,7 +74,7 @@ See the `audio-nodes` skill for the full contract. **If unsure which base class 1. Create `core//MyNode.h` and `MyNode.cpp`. 2. Subclass the right base (`AudioNode`, `AudioScheduledSourceNode`, `AudioBufferBaseSourceNode`). -3. Annotate every method with `// JS-thread only` or `// Audio-thread only`. +3. Annotate every method with `/// @note JS Thread only` or `/// @note Audio Thread only`. 4. Declare `processNode()` in `protected:` — audio thread. 5. Preallocate all `AudioParam`s and scratch buffers in the constructor (JS thread). 6. Add `createMyNode(const MyNodeOptions &options)` factory to `BaseAudioContext`. @@ -112,14 +112,17 @@ Files: `packages/react-native-audio-api/src/core/` import { MyNodeOptions } from '../types'; export default class MyNode extends AudioNode implements IMyNode { - constructor(context: BaseAudioContext, options: MyNodeOptions) { - const node = context.context.createMyNode(options); - super(context, node); + constructor(context: BaseAudioContext, options?: MyNodeOptions) { + // Node-specific validation (if any) goes here, before create. + const node = context.context.createMyNode(options || {}); + // Pass options so AudioNode validates channelCount / mode / interpretation. + super(context, node, options); } // getters/setters forwarded to (this.node as IMyNode) } ``` + `MyNodeOptions` must extend `AudioNodeOptions`. Shared `AudioNodeOptions` validation lives in `AudioNode`'s constructor (`validateAudioNodeOptions`) — do not re-validate those fields in per-node validators. 2. Add a factory method `createMyNode(options?)` to `src/core/BaseAudioContext.ts`. 3. Export from `src/index.ts`. diff --git a/.claude/skills/host-objects/SKILL.md b/.claude/skills/host-objects/SKILL.md index 856ab2f98..fc10b693c 100644 --- a/.claude/skills/host-objects/SKILL.md +++ b/.claude/skills/host-objects/SKILL.md @@ -29,7 +29,9 @@ Golden references: `GainNodeHostObject.h/.cpp` (effect node), `OscillatorNodeHos - **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. +- **Use `jsiutils::throwException()`** from `audioapi/jsi/JsiUtils.h` when throwing a named JS error (for example, `InvalidAccessError`) instead of rebuilding the `Error` object in a HostObject. - **Shadow state must be initialized** from `options` in the constructor — JS may read a property before ever setting it. +- **Typed node pointers are `T *const`.** Mirror `AudioNode *const audioNode_` — declare as `GainNode *const gainNode_;` (const pointer, mutable object), initialize in the ctor initializer list via `typedAudioNode(node_)`. Never use `T *foo_ = nullptr`. --- @@ -73,6 +75,8 @@ HostObjects/ │ ├── PannerNodeHostObject.h/.cpp │ ├── WaveShaperNodeHostObject.h/.cpp │ ├── ConvolverNodeHostObject.h/.cpp +│ ├── ChannelMergerNodeHostObject.h/.cpp # Composite: routes inputs to slot hosts +│ ├── ChannelSplitterNodeHostObject.h/.cpp # Composite: routes outputs from slot hosts │ ├── WorkletNodeHostObject.h/.cpp │ └── WorkletProcessingNodeHostObject.h/.cpp ├── sources/ diff --git a/.claude/skills/host-objects/examples.md b/.claude/skills/host-objects/examples.md index ac2e5779d..f064cba4a 100644 --- a/.claude/skills/host-objects/examples.md +++ b/.claude/skills/host-objects/examples.md @@ -20,7 +20,7 @@ class GainNodeHostObject : public AudioNodeHostObject { const std::shared_ptr &context, const GainOptions &options); - // JS-thread only + /// @note JS Thread only JSI_PROPERTY_GETTER_DECL(gain); }; @@ -66,7 +66,7 @@ class OscillatorNodeHostObject : public AudioScheduledSourceNodeHostObject { const std::shared_ptr &context, const OscillatorOptions &options); - // JS-thread only + /// @note JS Thread only JSI_PROPERTY_GETTER_DECL(frequency); JSI_PROPERTY_GETTER_DECL(detune); JSI_PROPERTY_GETTER_DECL(type); diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index fe1f9ea1f..86d1c4bd6 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -69,7 +69,7 @@ oscillatorNode->scheduleAudioEvent(std::move(event)); Send events from the audio thread back to JS (e.g. `ended`, `loopEnded`, `positionChanged`). -**Prefer `EventCaller`** — a small RAII helper templated on the event type. `dispatch()` requires a payload matching `EventPayloadFor` (see `AudioEventPayloadMapping.h`). +**Prefer `EventCaller`** — a small RAII helper templated on the event type. `dispatch()` requires a payload matching `EventPayloadFor` (see `AudioEventPayloadMapping.hpp`). ```cpp // Node member (composition — one EventCaller per event) @@ -129,6 +129,7 @@ Per-quantum processable state (`ALWAYS_`/`CONDITIONAL_`/`NOT_PROCESSABLE`) is de | Property written by audio thread, JS reads it | `std::atomic` on C++ node; getter reads directly | | Non-primitive, can be written by audio thread | Triple buffer (see `AnalyserNode` for reference) | | CPU-heavy work, must not block JS or audio | `TaskOffloader` on a dedicated worker thread | +| Context lifecycle (`resume`/`suspend`/`close`) | `scheduleContextPromise` → `pendingPromisesOffloader_` | --- @@ -159,12 +160,32 @@ Control-plane synchronization uses two layers — both are non-recursive `std::m | Context | `BaseAudioContext::driverMutex_` (`AudioContext` + `OfflineAudioContext`) | `start` / `resume` / `suspend` / `close` (live); `resume` / `suspend` / `startRendering` (offline) — JS thread vs promise-pool | | Engine | `AudioEngine` mutex (iOS only) | Process-wide `AVAudioEngine` graph: attach/detach, engine start/stop, interruptions, recorder paths | -`AudioContext::initialize()`, `createMediaElementSource()`, and `isDriverRunning()` are JS-thread-only — do not take `driverMutex_`. `getState()` reads atomics and `isDriverRunning()` lock-free; do not acquire `driverMutex_` from there. +`AudioContext::initialize()`, `createMediaElementSource()`, and `isDriverRunning()` are JS-thread-only — do not take `driverMutex_`. `getState()` returns the atomic control-thread state only (do not gate on `isDriverRunning()`); do not acquire `driverMutex_` from there. On Android, `AudioPlayer::onErrorAfterClose` also takes `driverMutex_` because Oboe error callbacks bypass `AudioContext`. **Live `AudioContext` render quiescence:** `currentRenders_` on `AudioContext` is incremented at the start of each platform I/O callback (`IOSAudioPlayer::deliverOutputBuffers` / `AudioPlayer::onAudioReady`) via a reference passed in `initialize()`, and decremented when the callback returns (RAII scope). `suspend()` and `close()` call `waitForRenderQuiescence()` (under `driverMutex_`) before `processAudioEvents()` / `cleanup()`. Platform drivers share the `CommonPlayer` abstract base (`common/cpp/audioapi/core/CommonPlayer.h`). +**Graph Channel A producer self-drain:** `Graph::setProducerSelfDrain(true)` makes the JS/main producer drain Channel A after each enqueue. Enable only when there is no audio/render consumer (realtime: construction + after `suspend`/`close` quiescence; offline: before `startRendering` and after a scheduled suspend). Before disabling for `start`/`resume`/`renderAudio`, call `processEvents()` once (still as sole consumer) so the bounded channel is empty, then disable, then start the audio/render consumer; re-enable if start/resume fails. After enabling, call `processEvents()` once to flush backlog (avoids `WAIT_ON_FULL` deadlock if the channel was already full). + +**Context lifecycle promises:** HostObjects wrap JSI `Promise`s via `ContextPromiseResolver` +(`jsi/ContextPromiseResolver.hpp`); tasks are queued as `ContextPromiseTask` +(`core/types/ContextPromiseTask.h`). Lifecycle +ops (`resume` / `suspend` / `close` / offline start) are **control messages** on +`pendingPromisesOffloader_` via `scheduleContextPromise` from the JS thread +(`PromiseVendor::createPromise`, not the multi-worker `createAsyncPromise` — that would violate +SPSC single-producer). A dedicated `TaskOffloader` worker thread drains the queue under +`driverMutex_`, runs `collectDisposedNodes()` (host-graph ghost cleanup — never on the audio +thread), then executes the lifecycle body and settles the promise on the CallInvoker. The +`TaskOffloader` destructor joins the worker and drains any queued control messages. When the driver +is stopped, `scheduleAudioEvent` still drains already-queued SPSC events then runs +the new event synchronously under `driverMutex_` (FIFO with prior messages). Control-message +bodies must not re-lock `driverMutex_` or `waitForRenderQuiescence()` while `currentRenders_ > 0` +(audio-callback self-deadlock). Apply the visible `state` attribute and settle the promise +together in the `ContextPromise` resolve task (CallInvoker), after driver work — so `.state` +still reads the prior value until settlement (needed when `resume()` then `suspend()` are issued +back-to-back). + --- ## Common Mistakes diff --git a/.claude/skills/utilities/SKILL.md b/.claude/skills/utilities/SKILL.md index 653c1698a..a7d5733e0 100644 --- a/.claude/skills/utilities/SKILL.md +++ b/.claude/skills/utilities/SKILL.md @@ -211,7 +211,7 @@ RAII mutex wrapper that can hold `nullptr` (no-op). Supports `Locker::tryLock(mu ## `common/cpp/audioapi/dsp/` — DSP helpers -### `AudioUtils.hpp` — inline DSP math +### `AudioUtils.h` — inline DSP math Provides `timeToSampleFrame()`, `sampleFrameToTime()`, `linearInterpolate()`, `linearToDecibels()`, `decibelsToLinear()`. @@ -231,8 +231,34 @@ Higher-level DSP blocks. Read each header before use. --- +### `SpectrumAnalyser.h` — shared windowed-FFT magnitude spectrum + +Owns FFT scratch state (Blackman window, temp array, complex scratch, magnitude +output) for the windowed-FFT → linear-magnitude → exponential-smoothing pipeline. +Shared by `AnalyserNode` (`core/analysis/`) and `WorkletNode` +(`react-native-audio-worklets`, frequency-domain mode) to avoid duplicating that +math — each node keeps its own input buffering/threading and just calls +`analyze(timeDomain, smoothingTimeConstant)`, then reads `getMagnitudeData()`. +Exported via `StableAPI.h` for the worklets extension package. Not thread-safe; +call `analyze()`/`setFFTSize()` from a single thread. + +--- + ## `src/utils/` — TypeScript utilities +### `index.ts` + +```ts +import { clamp, toFloat32Array, assertFiniteSequence } from './utils'; + +clamp(value, min, max) // clamp a number to [min, max] +toFloat32Array(values) // number[] → Float32Array (passthrough if already) +toFloat32Array(undefined) // → undefined (overload) +assertFiniteSequence(values, errorMessage) // throws TypeError if any value is non-finite +``` + +Use `toFloat32Array` when accepting `number[] | Float32Array` options. Use `assertFiniteSequence` in options validators (e.g. PeriodicWave `real`/`imag`). + ### `paths.ts` ```ts diff --git a/.claude/skills/utilities/api.md b/.claude/skills/utilities/api.md index 3d7c8a04d..99e0ca794 100644 --- a/.claude/skills/utilities/api.md +++ b/.claude/skills/utilities/api.md @@ -239,10 +239,10 @@ Used exclusively within `AudioParamEventQueue`. Do not construct outside of `Aud --- -## `AudioUtils.hpp` — inline DSP math +## `AudioUtils.h` — inline DSP math ```cpp -#include +#include using namespace audioapi::dsp; size_t frame = timeToSampleFrame(time, sampleRate); // double → size_t diff --git a/.claude/skills/utilities/maintenance.md b/.claude/skills/utilities/maintenance.md index e1f08ca92..6a28f80b8 100644 --- a/.claude/skills/utilities/maintenance.md +++ b/.claude/skills/utilities/maintenance.md @@ -17,6 +17,6 @@ Review this skill when `pre-push-update` reports changes in: | `common/cpp/audioapi/utils/Benchmark.hpp` | `api.md` — function names, return type | | `common/cpp/audioapi/core/utils/AudioDestructor.hpp` | `api.md` — `tryAddForDeconstruction` signature, capacity | | `common/cpp/audioapi/core/utils/ParamChangeEvent.hpp` | `api.md` — constructor args, getters/setters | -| `common/cpp/audioapi/dsp/AudioUtils.hpp` | `api.md` — new DSP helpers added or signatures changed | +| `common/cpp/audioapi/dsp/AudioUtils.h` | `api.md` — new DSP helpers added or signatures changed | | `common/cpp/audioapi/core/utils/Constants.h` | Constants section in `SKILL.md` | | `src/utils/**` | TypeScript utils section in `SKILL.md` | diff --git a/.claude/skills/web-audio-api/SKILL.md b/.claude/skills/web-audio-api/SKILL.md index 6ac1e13cb..d54eb0bbe 100644 --- a/.claude/skills/web-audio-api/SKILL.md +++ b/.claude/skills/web-audio-api/SKILL.md @@ -108,7 +108,7 @@ When adding a new RN-specific feature that should also work on web, implement th Current status (from `packages/audiodocs/docs/other/web-audio-api-coverage.mdx`): ### Fully implemented ✅ -`AnalyserNode`, `AudioBuffer`, `AudioBufferSourceNode`, `AudioDestinationNode`, `AudioNode`, `AudioParam`, `AudioScheduledSourceNode`, `BiquadFilterNode`, `ConstantSourceNode`, `ConvolverNode`, `DelayNode`, `GainNode`, `IIRFilterNode`, `OfflineAudioContext`, `OscillatorNode`, `PeriodicWave`, `StereoPannerNode`, `WaveShaperNode` +`AnalyserNode`, `AudioBuffer`, `AudioBufferSourceNode`, `AudioDestinationNode`, `AudioNode`, `AudioParam`, `AudioScheduledSourceNode`, `BiquadFilterNode`, `ChannelMergerNode`, `ChannelSplitterNode`, `ConstantSourceNode`, `ConvolverNode`, `DelayNode`, `GainNode`, `IIRFilterNode`, `OfflineAudioContext`, `OscillatorNode`, `PeriodicWave`, `StereoPannerNode`, `WaveShaperNode`, `MediaElementAudioSourceNode` ### Partially implemented 🚧 | Interface | What's available | @@ -119,7 +119,7 @@ Current status (from `packages/audiodocs/docs/other/web-audio-api-coverage.mdx`) | `PannerNode` | Equal-power spatialization, distance models, cone gain; `HRTF` accepted but falls back to equal-power | ### Not yet implemented ❌ -`AudioSinkInfo`, `AudioWorklet`, `AudioWorkletGlobalScope`, `AudioWorkletNode`, `AudioWorkletProcessor`, `ChannelMergerNode`, `ChannelSplitterNode`, `DynamicsCompressorNode`, `MediaElementAudioSourceNode`, `MediaStreamAudioDestinationNode`, `MediaStreamAudioSourceNode` +`AudioSinkInfo`, `AudioWorklet`, `AudioWorkletGlobalScope`, `AudioWorkletNode`, `AudioWorkletProcessor`, `DynamicsCompressorNode`, `MediaStreamAudioDestinationNode`, `MediaStreamAudioSourceNode`, `PannerNode` **Goal**: everything in the Web Audio API spec should eventually be in this library. If you implement a node from the ❌ list, update the coverage table in `packages/audiodocs/docs/other/web-audio-api-coverage.mdx`. diff --git a/.cursor/rules/expressive-code.mdc b/.cursor/rules/expressive-code.mdc new file mode 100644 index 000000000..75d65312f --- /dev/null +++ b/.cursor/rules/expressive-code.mdc @@ -0,0 +1,8 @@ +--- +description: Apply expressive-code standards to every coding task +alwaysApply: true +--- + +# Expressive Code + +Before adding or modifying source code, read and follow `.claude/skills/expressive-code/SKILL.md`. diff --git a/.github/workflows/cpp-coverage-job.yml b/.github/workflows/cpp-coverage-job.yml new file mode 100644 index 000000000..dd3cc85ef --- /dev/null +++ b/.github/workflows/cpp-coverage-job.yml @@ -0,0 +1,38 @@ +name: C++ Coverage Job + +on: + workflow_call: + +jobs: + run: + name: C++ coverage + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Checkout with Node + uses: ./.github/actions/checkout-with-node + + - name: Setup Yarn + uses: ./.github/actions/setup-yarn + + - name: Setup CMake + uses: ./.github/actions/setup-cmake + + - name: Install Clang and LLVM coverage tools + run: | + sudo apt-get update + sudo apt-get install -y clang llvm + + - name: Run C++ coverage + working-directory: packages/react-native-audio-api + run: yarn test:cpp:coverage + + - name: Upload coverage HTML + uses: actions/upload-artifact@v4 + with: + name: cpp-coverage-html + path: packages/react-native-audio-api/common/cpp/test/coverage-html + retention-days: 14 diff --git a/.github/workflows/js-job.yml b/.github/workflows/js-job.yml new file mode 100644 index 000000000..a0d35e1d8 --- /dev/null +++ b/.github/workflows/js-job.yml @@ -0,0 +1,20 @@ +name: JS Job + +on: + workflow_call: + +jobs: + run: + name: JS tests + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup + uses: ./.github/actions/setup + + - name: Run JS integration tests + working-directory: packages/react-native-audio-api + run: yarn build && yarn test:js diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 20dceaab8..599ee43e9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,24 +3,33 @@ name: Tests on: pull_request: workflow_dispatch: + inputs: + run_cpp_tests: + description: Run C++ tests (GCC) + type: boolean + default: true + run_cpp_coverage: + description: Run C++ coverage (Clang / llvm-cov) + type: boolean + default: true + run_js_tests: + description: Run JS integration tests + type: boolean + default: true jobs: cpp-tests: + if: github.event_name == 'pull_request' || inputs.run_cpp_tests uses: ./.github/workflows/cpp-job.yml with: name: C++ tests working-directory: packages/react-native-audio-api - run: bash common/cpp/test/RunTests.sh + run: yarn test:cpp - js-tests: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup - uses: ./.github/actions/setup + cpp-coverage: + if: github.event_name == 'pull_request' || inputs.run_cpp_coverage + uses: ./.github/workflows/cpp-coverage-job.yml - - name: Run JS integration tests - working-directory: packages/react-native-audio-api - run: yarn build && yarn test:js + js-tests: + if: github.event_name == 'pull_request' || inputs.run_js_tests + uses: ./.github/workflows/js-job.yml diff --git a/.gitignore b/.gitignore index 53d559932..1c20b79b9 100644 --- a/.gitignore +++ b/.gitignore @@ -87,7 +87,9 @@ react-native-audio-api*.tgz # Envs .env -.cursor +.cursor/* +!.cursor/rules/ +!.cursor/rules/*.mdc compile_commands.json openssl-prebuilt/ @@ -102,3 +104,9 @@ packages/react-native-audio-api/common/cpp/audioapi/external/ffmpeg_ios/ # Clangd cache .cache + +# C++ tests coverage (Clang LLVM source-based + llvm-cov) +build-coverage/ +*.profraw +*.profdata +coverage-html/ diff --git a/CLAUDE.md b/CLAUDE.md index aec80112e..26a3aa4eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,7 @@ packages/custom-node-generator/ # Code generation tooling - **New Architecture Ready**: Supports both old Bridge and new TurboModules/Fabric - **Optional FFmpeg**: Audio decoding via FFmpeg can be conditionally compiled out - **Audio Worklets**: JavaScript runs on the audio thread via React Native Worklets +- **Testable C++ dependencies**: consumers take interface types (`std::shared_ptr`); construct concrete implementations only at platform bootstrap. Example: audio event registry (use `IAudioEventHandlerRegistry` more often than `AudioEventHandlerRegistry`). ### Native Module Entry Points - iOS: `ios/audioapi/ios/AudioAPIModule.mm` @@ -73,6 +74,10 @@ When implementing anything new, mirror structure and style from these proven fil | New scheduled source node | `common/cpp/audioapi/core/sources/OscillatorNode.h` + `.cpp` | | New TypeScript API class | `packages/react-native-audio-api/src/core/GainNode.ts` | +### Writing Expressive Code + +Before adding or modifying source code, read and follow `.claude/skills/expressive-code/SKILL.md`. This applies to every coding task. + --- ## Skills @@ -90,6 +95,7 @@ Detailed skill files live in `.claude/skills/`. Each skill lives in its own dire | `post-work-checks/` | Ordered checklist to run after every change | | `flow/` | End-to-end feature implementation flow (tests + docs required) | | `utilities/` | Shared DSP and C++/TS utility helpers | +| `expressive-code/` | Naming and comment style — self-documenting code, when to comment, anti-patterns | | `writing-skills/` | How to write, structure, and maintain skill files | See `.claude/README.md` for a full description of the Claude Code setup and the `/pre-push-update` command. diff --git a/apps/common-app/src/demos/Record/RecordingVisualization.tsx b/apps/common-app/src/demos/Record/RecordingVisualization.tsx index e5540c4b8..747026742 100644 --- a/apps/common-app/src/demos/Record/RecordingVisualization.tsx +++ b/apps/common-app/src/demos/Record/RecordingVisualization.tsx @@ -6,8 +6,12 @@ import { useCanvasRef, useCanvasSize, } from '@shopify/react-native-skia'; -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { Dimensions, StyleSheet, View } from 'react-native'; +import { + WorkletAudioContext, + WorkletNode, +} from 'react-native-audio-worklets'; import { cancelAnimation, Easing, @@ -26,9 +30,7 @@ import { RecordingState } from './types'; const { width: windowWidth } = Dimensions.get('window'); -const defaultNumBars = Math.floor( - windowWidth / (constants.barWidth + constants.barGap) -); +const defaultNumBars = Math.floor(windowWidth / constants.barStep); const historyNumBars = Math.floor( windowWidth / (constants.historyBarWidth + constants.historyBarGap) @@ -48,7 +50,7 @@ interface RecordingVisualizationProps { interface DrawDefaultWaveformParams { normalized: number; - size: { width: number; height: number }; + canvasHeight: number; barHeights: number[]; translateX: SharedValue; lastIndex: SharedValue; @@ -57,7 +59,7 @@ interface DrawDefaultWaveformParams { interface DrawHistoryWaveformParams { normalized: number; - lifetimeSize: { width: number; height: number }; + lifetimeCanvasHeight: number; history: number[]; historyHead: SharedValue; durationMS: SharedValue; @@ -66,14 +68,18 @@ interface DrawHistoryWaveformParams { function drawDefaultWaveform(params: DrawDefaultWaveformParams) { 'worklet'; - const { normalized, size, barHeights, translateX, lastIndex, numBars } = + const { normalized, canvasHeight, barHeights, translateX, lastIndex, numBars } = params; - const value = normalized * size.height * 0.8; + if (canvasHeight <= 0 || numBars <= 0) { + return barHeights; + } + + const value = normalized * canvasHeight * 0.8; let currentIndex = barHeights.length / 2 - 1 + - Math.floor(-translateX.value / (constants.barWidth + constants.barGap)); + Math.floor(-translateX.value / constants.barStep); barHeights[currentIndex] = value; @@ -108,13 +114,17 @@ function drawHistoryWaveform(params: DrawHistoryWaveformParams) { const { history, normalized, - lifetimeSize, + lifetimeCanvasHeight, historyHead, durationMS, historyMidpointMS, } = params; - const value = normalized * lifetimeSize.height * 0.8; + if (lifetimeCanvasHeight <= 0) { + return history; + } + + const value = normalized * lifetimeCanvasHeight * 0.8; history[historyHead.value] = value; historyHead.value += 1; @@ -153,13 +163,21 @@ const RecordingVisualization: React.FC = ({ const translateX = useSharedValue(0); const lastIndex = useSharedValue(-1); const durationMS = useSharedValue(0); + const canvasHeightSV = useSharedValue(0); + const lifetimeCanvasHeightSV = useSharedValue(0); + const numBarsSV = useSharedValue(0); + + const stateRef = useRef(state); + const workletContextRef = useRef(null); + const workletNodeRef = useRef(null); + const workletReadyRef = useRef(false); const numBars = useMemo(() => { if (size.width === 0) { return 0; } - return Math.ceil(size.width / (constants.barWidth + constants.barGap)) * 2; + return Math.ceil(size.width / constants.barStep) * 2; }, [size.width]); const waveformPath = useDerivedValue(() => { @@ -171,14 +189,12 @@ const RecordingVisualization: React.FC = ({ return path; } - currentHeights.forEach((height, index) => { + currentHeights.forEach((height: number, index: number) => { if (height < 0) { return; } - const x = - index * (constants.barWidth + constants.barGap) + - constants.barWidth / 2; + const x = index * constants.barStep + constants.barWidth / 2; const y1 = (canvasHeight - height) / 2; const y2 = (canvasHeight + height) / 2; @@ -249,6 +265,16 @@ const RecordingVisualization: React.FC = ({ return path; }, [lifetimeSize]); + useEffect(() => { + stateRef.current = state; + }, [state]); + + useEffect(() => { + numBarsSV.value = numBars; + canvasHeightSV.value = size.height; + lifetimeCanvasHeightSV.value = lifetimeSize.height; + }, [numBars, size.height, lifetimeSize.height, numBarsSV, canvasHeightSV, lifetimeCanvasHeightSV]); + useEffect(() => { if (numBars <= 0) { return; @@ -260,29 +286,39 @@ const RecordingVisualization: React.FC = ({ }, [numBars, barHeights]); useEffect(() => { - if (numBars <= 0) { - return () => {}; + if (workletContextRef.current != null) { + return; } - Recorder.onAudioReady( - { - sampleRate: constants.sampleRate, - channelCount: 1, - bufferLength: - (constants.updateIntervalMS / 1000.0) * constants.sampleRate, - }, - (event) => { - durationMS.value += (event.numFrames / constants.sampleRate) * 1000; - const { buffer } = event; - const audioData = buffer.getChannelData(0); + const workletContext = new WorkletAudioContext({ + sampleRate: constants.sampleRate, + }); + const workletNode = new WorkletNode( + workletContext, + (audioData) => { + 'worklet'; + + const canvasHeight = canvasHeightSV.value; + const lifetimeCanvasHeight = lifetimeCanvasHeightSV.value; + const activeNumBars = numBarsSV.value; + + if (canvasHeight <= 0 || activeNumBars <= 0) { + return; + } + + durationMS.value += + (audioData.length / constants.sampleRate) * 1000; let maxValue = 0; for (let i = 0; i < audioData.length; i++) { - const val = Math.abs(audioData[i]); - if (val > maxValue) maxValue = val; + const val = Math.abs(audioData[i]!); + if (val > maxValue) { + maxValue = val; + } } - const db = maxValue > 0 ? 20 * Math.log10(maxValue) : constants.minDb; + const db = + maxValue > 0 ? 20 * Math.log10(maxValue) : constants.minDb; let normalized = (db - constants.minDb) / (constants.maxDb - constants.minDb); normalized = Math.max(0, Math.min(1, normalized)); @@ -292,11 +328,11 @@ const RecordingVisualization: React.FC = ({ return drawDefaultWaveform({ normalized, - size, + canvasHeight, barHeights: heights, translateX, lastIndex, - numBars, + numBars: activeNumBars, }) as T; }); @@ -305,21 +341,81 @@ const RecordingVisualization: React.FC = ({ return drawHistoryWaveform({ normalized, - lifetimeSize, + lifetimeCanvasHeight, history: hist, historyHead, durationMS, historyMidpointMS, }) as T; }); + }, + { + domain: 'time-domain', + bufferLength: constants.workletBufferLength, } ); + workletContextRef.current = workletContext; + workletNodeRef.current = workletNode; + + let cancelled = false; + + const startWorkletGraph = async () => { + await workletContext.resume(); + if (cancelled) { + return; + } + + workletNode.connect(workletContext.destination); + workletReadyRef.current = true; + + const currentState = stateRef.current; + if ( + currentState === RecordingState.Recording || + currentState === RecordingState.Paused + ) { + Recorder.connect(workletContext, workletNode); + } + }; + + startWorkletGraph(); + return () => { - Recorder.clearOnAudioReady(); + cancelled = true; + workletReadyRef.current = false; + Recorder.disconnect(); + workletNode.disconnect(); + workletContextRef.current = null; + workletNodeRef.current = null; + workletContext.close().catch(() => {}); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [numBars, size, lifetimeSize]); + }, []); + + useEffect(() => { + const workletContext = workletContextRef.current; + const workletNode = workletNodeRef.current; + + if (!workletReadyRef.current || !workletContext || !workletNode) { + return; + } + + const shouldRouteAudio = + state === RecordingState.Recording || state === RecordingState.Paused; + + if (!shouldRouteAudio) { + Recorder.disconnect(); + return; + } + + workletContext.resume().then(() => { + Recorder.connect(workletContext, workletNode); + }); + + return () => { + Recorder.disconnect(); + }; + }, [state]); useEffect(() => { if (state === RecordingState.Recording) { @@ -337,8 +433,7 @@ const RecordingVisualization: React.FC = ({ } else if (state === RecordingState.Paused) { cancelAnimation(translateX); - const currentIndexOffset = - -translateX.value / (constants.barWidth + constants.barGap); + const currentIndexOffset = -translateX.value / constants.barStep; const newBarHeights = [...barHeights.value]; diff --git a/apps/common-app/src/demos/Record/constants.tsx b/apps/common-app/src/demos/Record/constants.tsx index ead02bcb1..4bd332c91 100644 --- a/apps/common-app/src/demos/Record/constants.tsx +++ b/apps/common-app/src/demos/Record/constants.tsx @@ -1,5 +1,9 @@ +const WORKLET_ALLOWED_BUFFER_LENGTH = [ + 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384, 32768, +] as const; + const constants = { - sampleRate: 3125, + sampleRate: 48000, updateIntervalMS: 32, barWidth: 2, barGap: 2, @@ -7,8 +11,21 @@ const constants = { maxDb: 0, historyBarWidth: 2, historyBarGap: 2, + get barStep() { + return this.barWidth + this.barGap; + }, + get bufferLength() { + return (this.updateIntervalMS / 1000) * this.sampleRate; + }, + get workletBufferLength() { + const target = this.bufferLength; + return ( + WORKLET_ALLOWED_BUFFER_LENGTH.find((length) => length >= target) ?? + WORKLET_ALLOWED_BUFFER_LENGTH[WORKLET_ALLOWED_BUFFER_LENGTH.length - 1] + ); + }, get pixelsPerSecond() { - return (1000 / this.updateIntervalMS) * (this.barWidth + this.barGap); + return (1000 / this.updateIntervalMS) * this.barStep; }, get pixelsPerMS() { return this.pixelsPerSecond / 1000; diff --git a/apps/common-app/src/demos/index.ts b/apps/common-app/src/demos/index.ts index 8f1d989b8..c338d6f7d 100644 --- a/apps/common-app/src/demos/index.ts +++ b/apps/common-app/src/demos/index.ts @@ -37,9 +37,8 @@ export const demos: DemoScreen[] = [ { key: 'Crossfade', title: 'Crossfade', - subtitle: - 'Demonstrates crossfading between two audio files.', + subtitle: 'Demonstrates crossfading between two audio files.', icon: icons.ArrowLeftRight, screen: Crossfade, - } + }, ] as const; diff --git a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts index d58d8f962..a0121db7a 100644 --- a/apps/common-app/src/examples/AudioFile/AudioPlayer.ts +++ b/apps/common-app/src/examples/AudioFile/AudioPlayer.ts @@ -50,10 +50,6 @@ class AudioPlayer { }); this.sourceNode.buffer = this.audioBuffer; this.sourceNode.playbackRate.value = this.playbackRate; - const volume1 = this.audioContext.createGain(); - const volume2 = this.audioContext.createGain(); - volume1.gain.value = 0.5; - volume2.gain.value = 0.5; this.volumeNode = this.audioContext.createGain(); this.volumeNode.gain.value = this.volume; @@ -73,8 +69,6 @@ class AudioPlayer { } }; - this.sourceNode.playbackRate.linearRampToValueAtTime(2, this.audioContext.currentTime + 5); - this.sourceNode.start( this.audioContext.currentTime, this.currentElapsedTime @@ -88,6 +82,8 @@ class AudioPlayer { } this.sourceNode?.stop(this.audioContext.currentTime); + this.sourceNode?.disconnect(); + this.volumeNode?.disconnect(); await this.audioContext.suspend(); PlaybackNotificationManager.show({ diff --git a/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx b/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx index 1a22f27c6..00182a7e2 100644 --- a/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx +++ b/apps/common-app/src/examples/AudioVisualizer/AudioVisualizer.tsx @@ -1,11 +1,12 @@ import React, { useEffect, useRef, useState } from 'react'; import { ActivityIndicator, StyleSheet, View } from 'react-native'; import { - AnalyserNode, AudioBuffer, AudioBufferSourceNode, AudioContext, } from 'react-native-audio-api'; +import { WorkletNode } from 'react-native-audio-worklets'; +import { useSharedValue } from 'react-native-reanimated'; import { Button, Container } from '../../components'; import { layout } from '../../styles'; @@ -17,47 +18,78 @@ const FREQUENCY_BIN_COUNT = FFT_SIZE / 2; const URL = 'https://software-mansion.github.io/react-native-audio-api/audio/music/example-music-02.mp3'; +const ANALYSER_MIN_DB = -100; +const ANALYSER_MAX_DB = -30; + +function linearMagnitudeToByte(linear: number) { + 'worklet'; + + const db = linear > 0 ? 20 * Math.log10(linear) : ANALYSER_MIN_DB; + const normalized = Math.max( + 0, + Math.min( + 1, + (db - ANALYSER_MIN_DB) / (ANALYSER_MAX_DB - ANALYSER_MIN_DB) + ) + ); + + return Math.round(normalized * 255); +} + const AudioVisualizer: React.FC = () => { const [isPlaying, setIsPlaying] = useState(false); const [isLoading, setIsLoading] = useState(false); const [audioBuffer, setAudioBuffer] = useState(null); - const [chartReady, setChartReady] = useState(false); + const [visualizerReady, setVisualizerReady] = useState(false); const [startTime, setStartTime] = useState(0); const [offset, setOffset] = useState(0); const audioContextRef = useRef(null); - const analyserRef = useRef(null); + const timeWorkletRef = useRef(null); + const frequencyWorkletRef = useRef(null); const bufferSourceRef = useRef(null); + const timeDataSV = useSharedValue(new Uint8Array(FFT_SIZE).fill(127)); + const frequencyDataSV = useSharedValue( + new Uint8Array(FREQUENCY_BIN_COUNT).fill(0) + ); + const timeDataTickSV = useSharedValue(0); + const frequencyDataTickSV = useSharedValue(0); + const handlePlayPause = async () => { if (isPlaying) { const stopTime = audioContextRef.current!.currentTime; + audioContextRef.current?.suspend(); bufferSourceRef.current?.stop(stopTime); setOffset((prev) => prev + stopTime - startTime); setIsPlaying(false); return; } - if (!audioContextRef.current || !analyserRef.current || !audioBuffer) { + const ctx = audioContextRef.current; + const timeWorklet = timeWorkletRef.current; + const frequencyWorklet = frequencyWorkletRef.current; + + if (!ctx || !timeWorklet || !frequencyWorklet || !audioBuffer) { return; } - await audioContextRef.current.resume(); + await ctx.resume(); - bufferSourceRef.current = audioContextRef.current.createBufferSource(); + bufferSourceRef.current = ctx.createBufferSource(); bufferSourceRef.current.buffer = audioBuffer; - bufferSourceRef.current.connect(analyserRef.current); - bufferSourceRef.current.connect(audioContextRef.current.destination); + bufferSourceRef.current.connect(timeWorklet); - const when = audioContextRef.current.currentTime; + const when = ctx.currentTime; setStartTime(when); bufferSourceRef.current.start(when, offset); setIsPlaying(true); }; const fetchAudioBuffer = async () => { - if (!audioContextRef.current) { + const ctx = audioContextRef.current; + if (!ctx) { return; } @@ -65,7 +97,7 @@ const AudioVisualizer: React.FC = () => { const buffer = await fetch(URL) .then((response) => response.arrayBuffer()) - .then((arrayBuffer) => audioContextRef.current!.decodeAudioData(arrayBuffer)) + .then((arrayBuffer) => ctx.decodeAudioData(arrayBuffer)) .catch((error) => { console.error('Error decoding audio data source:', error); return null; @@ -80,29 +112,69 @@ const AudioVisualizer: React.FC = () => { audioContextRef.current = new AudioContext(); } - if (!analyserRef.current) { - analyserRef.current = new AnalyserNode(audioContextRef.current, { - fftSize: FFT_SIZE, - smoothingTimeConstant: 0.2, - }); - } + timeWorkletRef.current = new WorkletNode( + audioContextRef.current!, + (audioData) => { + 'worklet'; + + const snapshot = timeDataSV.value; + + for (let i = 0; i < audioData.length; i++) { + const sample = Math.max(-1, Math.min(1, audioData[i]!)); + snapshot[i] = Math.round((sample + 1) * 127.5); + } + + timeDataTickSV.value += 1; + }, + { domain: 'time-domain', bufferLength: FFT_SIZE } + ); + + frequencyWorkletRef.current = new WorkletNode( + audioContextRef.current!, + (audioData) => { + 'worklet'; + + const snapshot = frequencyDataSV.value; + + for (let i = 0; i < audioData.length; i++) { + snapshot[i] = linearMagnitudeToByte(audioData[i]!); + } + + frequencyDataTickSV.value += 1; + }, + { + domain: 'frequency-domain', + bufferLength: FREQUENCY_BIN_COUNT, + } + ); + frequencyWorkletRef.current.smoothingTimeConstant = 0.2; + + timeWorkletRef.current.connect(frequencyWorkletRef.current); + frequencyWorkletRef.current.connect(audioContextRef.current!.destination); fetchAudioBuffer(); - setChartReady(true); + setVisualizerReady(true); return () => { - audioContextRef.current?.close(); + timeWorkletRef.current?.disconnect(); + frequencyWorkletRef.current?.disconnect(); + timeWorkletRef.current = null; + frequencyWorkletRef.current = null; + audioContextRef.current!.close(); + audioContextRef.current = null; }; - }, []); + }, [frequencyDataSV, frequencyDataTickSV, timeDataSV, timeDataTickSV]); return ( - {chartReady && analyserRef.current ? ( + {visualizerReady ? ( diff --git a/apps/common-app/src/examples/AudioVisualizer/Charts.tsx b/apps/common-app/src/examples/AudioVisualizer/Charts.tsx index 6798e9605..360bb7958 100644 --- a/apps/common-app/src/examples/AudioVisualizer/Charts.tsx +++ b/apps/common-app/src/examples/AudioVisualizer/Charts.tsx @@ -1,5 +1,6 @@ import React, { useMemo } from 'react'; import { Circle, Path, Skia, PaintStyle } from '@shopify/react-native-skia'; +import { SharedValue, useDerivedValue } from 'react-native-reanimated'; import { useCanvas } from './Canvas'; import { colors } from '../../styles'; @@ -11,8 +12,10 @@ import { } from './layout'; interface ChartProps { - timeData: Uint8Array; - frequencyData: Uint8Array; + timeDataSV: SharedValue; + timeDataTickSV: SharedValue; + frequencyDataSV: SharedValue; + frequencyDataTickSV: SharedValue; fftSize: number; frequencyBinCount: number; } @@ -22,6 +25,8 @@ function buildTimePath( fftSize: number, layout: ReturnType ) { + 'worklet'; + const path = Skia.PathBuilder.Make().build(); const { cx, cy, innerRadius } = layout; const span = innerRadius * 2; @@ -46,6 +51,8 @@ function buildFrequencyPath( frequencyBinCount: number, layout: ReturnType ) { + 'worklet'; + const path = Skia.PathBuilder.Make().build(); const { cx, cy, outerRadius } = layout; const maxSteps = 2 * (frequencyBinCount - 64); @@ -78,7 +85,7 @@ function buildFrequencyPath( const TimeChart: React.FC = (props) => { const { size } = useCanvas(); - const { timeData, fftSize } = props; + const { timeDataSV, timeDataTickSV, fftSize } = props; const layout = useMemo( () => getVisualizerLayout(size.width, size.height), [size.width, size.height] @@ -93,10 +100,10 @@ const TimeChart: React.FC = (props) => { return paint; }, []); - const timePath = useMemo( - () => buildTimePath(timeData, fftSize, layout), - [fftSize, layout, timeData] - ); + const timePath = useDerivedValue(() => { + timeDataTickSV.value; + return buildTimePath(timeDataSV.value, fftSize, layout); + }, [fftSize, layout, timeDataSV, timeDataTickSV]); return ( <> @@ -119,7 +126,7 @@ const TimeChart: React.FC = (props) => { const FrequencyChart: React.FC = (props) => { const { size } = useCanvas(); - const { frequencyData, frequencyBinCount } = props; + const { frequencyDataSV, frequencyDataTickSV, frequencyBinCount } = props; const layout = useMemo( () => getVisualizerLayout(size.width, size.height), [size.width, size.height] @@ -130,10 +137,14 @@ const FrequencyChart: React.FC = (props) => { [frequencyBinCount, layout.outerRadius] ); - const freqPath = useMemo( - () => buildFrequencyPath(frequencyData, frequencyBinCount, layout), - [frequencyBinCount, frequencyData, layout] - ); + const freqPath = useDerivedValue(() => { + frequencyDataTickSV.value; + return buildFrequencyPath( + frequencyDataSV.value, + frequencyBinCount, + layout + ); + }, [frequencyBinCount, frequencyDataSV, frequencyDataTickSV, layout]); return ( ; + timeDataTickSV: SharedValue; + frequencyDataSV: SharedValue; + frequencyDataTickSV: SharedValue; fftSize: number; frequencyBinCount: number; } const FreqTimeChart: React.FC = (props) => { - const { analyser, isPlaying, fftSize, frequencyBinCount } = props; - - const timeBufferRef = useRef(new Uint8Array(fftSize).fill(127)); - const freqBufferRef = useRef(new Uint8Array(frequencyBinCount).fill(0)); - const rafRef = useRef(null); - const lastFrameAtRef = useRef(0); - - const [timeData, setTimeData] = useState( - () => new Uint8Array(fftSize).fill(127) - ); - const [frequencyData, setFrequencyData] = useState( - () => new Uint8Array(frequencyBinCount).fill(0) - ); - - const publishAnalyserData = useCallback(() => { - const timeBuffer = timeBufferRef.current; - const freqBuffer = freqBufferRef.current; - - analyser.getByteTimeDomainData(timeBuffer); - analyser.getByteFrequencyData(freqBuffer); - - setTimeData(new Uint8Array(timeBuffer)); - setFrequencyData(new Uint8Array(freqBuffer)); - }, [analyser]); - - const draw = useCallback(() => { - const now = performance.now(); - - if (now - lastFrameAtRef.current >= FRAME_INTERVAL_MS) { - lastFrameAtRef.current = now; - publishAnalyserData(); - } - - rafRef.current = requestAnimationFrame(draw); - }, [publishAnalyserData]); - - useEffect(() => { - if (!isPlaying) { - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } - return; - } - - lastFrameAtRef.current = 0; - publishAnalyserData(); - rafRef.current = requestAnimationFrame(draw); - - return () => { - if (rafRef.current !== null) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } - }; - }, [draw, isPlaying, publishAnalyserData]); + const { + timeDataSV, + timeDataTickSV, + frequencyDataSV, + frequencyDataTickSV, + fftSize, + frequencyBinCount, + } = props; return ( diff --git a/apps/common-app/src/examples/AudioVisualizer/layout.ts b/apps/common-app/src/examples/AudioVisualizer/layout.ts index e6ae72994..533013645 100644 --- a/apps/common-app/src/examples/AudioVisualizer/layout.ts +++ b/apps/common-app/src/examples/AudioVisualizer/layout.ts @@ -22,10 +22,14 @@ export function getVisualizerLayout(width: number, height: number): VisualizerLa } export function sampleExp(value: number) { + 'worklet'; + return Math.exp(2.5 * value) / 4 - 0.2; } export function weightWithIndex(value: number, index: number, indexMax: number) { + 'worklet'; + if (index < indexMax / 2) { return value * Math.max(index / (indexMax / 2), 0.5); } diff --git a/apps/common-app/src/examples/ChannelCount/ChannelCount.tsx b/apps/common-app/src/examples/ChannelCount/ChannelCount.tsx new file mode 100644 index 000000000..9753ef876 --- /dev/null +++ b/apps/common-app/src/examples/ChannelCount/ChannelCount.tsx @@ -0,0 +1,349 @@ +import React, { useEffect, useRef, useState, FC } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { + AudioBuffer, + AudioBufferSourceNode, + AudioContext, + BaseAudioContext, + GainNode, + OfflineAudioContext, +} from 'react-native-audio-api'; +import type { + ChannelCountMode, + ChannelInterpretation, +} from 'react-native-audio-api'; + +import { Container, Slider, Spacer, Button, Select } from '../../components'; +import { colors, layout } from '../../styles'; + +// The source is a single buffer with SOURCE_CHANNELS channels, each carrying a +// distinct tone at an ascending amplitude (channel c peaks at (c + 1) / +// SOURCE_CHANNELS). Distinct per-channel content is what makes channelCount / +// channelCountMode / channelInterpretation changes measurable: with `discrete` +// the mix node keeps the first N channels and drops/zero-pads the rest, so the +// peak bars form a ramp that channelCount truncates. A stereo-only source would +// only ever populate 2 channels no matter how high channelCount goes. +const SOURCE_CHANNELS = 8; +const BASE_FREQUENCY = 220; + +const CHANNEL_COUNT_MODES: ChannelCountMode[] = [ + 'max', + 'clamped-max', + 'explicit', +]; +const CHANNEL_INTERPRETATIONS: ChannelInterpretation[] = [ + 'speakers', + 'discrete', +]; + +const ANALYSIS_CHANNELS = 8; +const labelWidth = 120; + +// Builds a multi-channel test buffer. Channel c is a sine at +// BASE_FREQUENCY * (c + 1) scaled to amplitude (c + 1) / SOURCE_CHANNELS. +function createTestBuffer(ctx: BaseAudioContext, frames: number): AudioBuffer { + const buffer = ctx.createBuffer(SOURCE_CHANNELS, frames, ctx.sampleRate); + for (let c = 0; c < SOURCE_CHANNELS; c += 1) { + const data = buffer.getChannelData(c); + const frequency = BASE_FREQUENCY * (c + 1); + const amplitude = (c + 1) / SOURCE_CHANNELS; + for (let i = 0; i < frames; i += 1) { + data[i] = + amplitude * Math.sin((2 * Math.PI * frequency * i) / ctx.sampleRate); + } + } + return buffer; +} + +const ChannelCount: FC = () => { + const [isPlaying, setIsPlaying] = useState(false); + const [channelCount, setChannelCount] = useState(2); + const [channelCountMode, setChannelCountMode] = + useState('explicit'); + const [channelInterpretation, setChannelInterpretation] = + useState('discrete'); + const [analyzing, setAnalyzing] = useState(false); + const [peaks, setPeaks] = useState(null); + + const audioContextRef = useRef(null); + const sourceRef = useRef(null); + const mixRef = useRef(null); + + const setup = () => { + if (!audioContextRef.current) { + audioContextRef.current = new AudioContext(); + } + const ctx = audioContextRef.current; + + const source = ctx.createBufferSource(); + source.buffer = createTestBuffer(ctx, Math.floor(ctx.sampleRate)); + source.loop = true; + + const mix = ctx.createGain(); + mix.channelCountMode = channelCountMode; + mix.channelInterpretation = channelInterpretation; + mix.channelCount = channelCount; + + source.connect(mix); + mix.connect(ctx.destination); + + sourceRef.current = source; + mixRef.current = mix; + }; + + const teardown = () => { + sourceRef.current?.stop(0); + sourceRef.current = null; + mixRef.current = null; + }; + + const handlePlayPause = () => { + if (isPlaying) { + teardown(); + } else { + setup(); + sourceRef.current?.start(0); + } + + setIsPlaying((prev) => !prev); + }; + + // Live-apply attribute changes to the currently playing mix node. This is the + // real exercise for the renegotiation path: the graph must re-negotiate the + // channel layout mid-render without a glitch or crash. + const handleChannelCountChange = (value: number) => { + const next = Math.round(value); + setChannelCount(next); + if (mixRef.current) { + mixRef.current.channelCount = next; + } + }; + + const handleModeChange = (value: ChannelCountMode) => { + setChannelCountMode(value); + if (mixRef.current) { + mixRef.current.channelCountMode = value; + } + }; + + const handleInterpretationChange = (value: ChannelInterpretation) => { + setChannelInterpretation(value); + if (mixRef.current) { + mixRef.current.channelInterpretation = value; + } + }; + + // Deterministic verification: render the same graph offline with the current + // settings and report the peak amplitude of every output channel. The + // destination is forced to discrete/explicit so per-channel content is + // preserved for measurement instead of being mixed down. + const handleAnalyze = async () => { + if (analyzing) { + return; + } + setAnalyzing(true); + + try { + const sampleRate = 44100; + const frames = 4096; + const ctx = new OfflineAudioContext( + ANALYSIS_CHANNELS, + frames, + sampleRate + ); + ctx.destination.channelCount = ANALYSIS_CHANNELS; + ctx.destination.channelCountMode = 'explicit'; + ctx.destination.channelInterpretation = 'discrete'; + + const source = ctx.createBufferSource(); + source.buffer = createTestBuffer(ctx, frames); + + const mix = ctx.createGain(); + mix.channelCountMode = channelCountMode; + mix.channelInterpretation = channelInterpretation; + mix.channelCount = channelCount; + + source.connect(mix); + mix.connect(ctx.destination); + + source.start(0); + + const rendered = await ctx.startRendering(); + + const nextPeaks: number[] = []; + for (let c = 0; c < rendered.numberOfChannels; c += 1) { + const data = rendered.getChannelData(c); + let peak = 0; + for (let i = 0; i < data.length; i += 1) { + const abs = Math.abs(data[i]); + if (abs > peak) { + peak = abs; + } + } + nextPeaks.push(peak); + } + setPeaks(nextPeaks); + } catch (error) { + console.error('Channel analysis failed:', error); + } finally { + setAnalyzing(false); + } + }; + + useEffect(() => { + return () => { + audioContextRef.current?.close(); + audioContextRef.current = null; + }; + }, []); + + return ( + +