From 5672c271f9ef27c878aa8ada8390417f98ef8baf Mon Sep 17 00:00:00 2001 From: "maciej.kucmierz" Date: Thu, 24 Sep 2026 15:55:29 +0200 Subject: [PATCH 1/4] feat(android): add per-AudioContext output profile --- .../audiodocs/docs/core/audio-context.mdx | 3 ++ .../cpp/audioapi/android/core/AudioPlayer.cpp | 10 ++++- .../cpp/audioapi/android/core/AudioPlayer.h | 5 ++- .../cpp/audioapi/AudioAPIModuleInstaller.h | 8 +++- .../HostObjects/AudioContextHostObject.cpp | 6 ++- .../HostObjects/AudioContextHostObject.h | 2 + .../common/cpp/audioapi/core/AudioContext.cpp | 8 +++- .../common/cpp/audioapi/core/AudioContext.h | 3 ++ .../cpp/audioapi/core/AudioContextOptions.h | 10 +++++ .../src/AudioAPIModule/globals.d.ts | 11 ++++- .../src/core/AudioContext.ts | 3 +- packages/react-native-audio-api/src/types.ts | 3 ++ .../tests/audio-context-options.test.ts | 40 +++++++++++++++++++ 13 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/AudioContextOptions.h create mode 100644 packages/react-native-audio-api/tests/audio-context-options.test.ts diff --git a/packages/audiodocs/docs/core/audio-context.mdx b/packages/audiodocs/docs/core/audio-context.mdx index 45eaebeca..071856960 100644 --- a/packages/audiodocs/docs/core/audio-context.mdx +++ b/packages/audiodocs/docs/core/audio-context.mdx @@ -21,6 +21,9 @@ constructor(options?: AudioContextOptions) | Parameter | Type | Default | | | :---: | :---: | :----: | :---- | | `sampleRate` | `number` | - | The preferred sample rate for the context. | +| `androidOutputProfile` | `'media'` \| `'voiceCommunication'` | `'media'` | Android output classification. Use `'voiceCommunication'` for interactive voice calls. | + +On Android, `voiceCommunication` configures the output stream with voice-communication usage and speech content type. It does not guarantee echo cancellation, noise suppression, or automatic gain control; those remain device- and route-dependent. #### Errors diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp index 38371018f..4bbdf0217 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp @@ -19,14 +19,16 @@ AudioPlayer::AudioPlayer( int channelCount, std::mutex *driverMutex, const std::shared_ptr &context, - std::atomic ¤tRenders) + std::atomic ¤tRenders, + AndroidOutputProfile outputProfile) : renderAudio_(renderAudio), currentRenders_(currentRenders), sampleRate_(sampleRate), channelCount_(channelCount), isRunning_(false), driverMutex_(driverMutex), - context_(context) {} + context_(context), + outputProfile_(outputProfile) {} bool AudioPlayer::openAudioStream() { std::scoped_lock lock(streamMutex_); @@ -43,6 +45,10 @@ bool AudioPlayer::openAudioStream() { ->setErrorCallback(shared_from_this()) ->setSampleRate(static_cast(sampleRate_)); + if (outputProfile_ == AndroidOutputProfile::VoiceCommunication) { + builder.setUsage(Usage::VoiceCommunication)->setContentType(ContentType::Speech); + } + auto result = builder.openStream(mStream_); if (result != oboe::Result::OK || mStream_ == nullptr) { __android_log_print( diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h index 2f9a4ac01..ee919a743 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -29,7 +30,8 @@ class AudioPlayer : public CommonPlayer, int channelCount, std::mutex *driverMutex, const std::shared_ptr &context, - std::atomic ¤tRenders); + std::atomic ¤tRenders, + AndroidOutputProfile outputProfile); ~AudioPlayer() override { cleanup(); @@ -67,6 +69,7 @@ class AudioPlayer : public CommonPlayer, std::atomic lastCallbackFrameCount_{0}; std::mutex *driverMutex_; std::weak_ptr context_; + AndroidOutputProfile outputProfile_; bool openAudioStream(); }; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h index ba15c738b..89c91d261 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h @@ -76,9 +76,15 @@ class AudioAPIModuleInstaller { const jsi::Value *args, size_t count) -> jsi::Value { auto sampleRate = static_cast(args[0].getNumber()); + auto androidOutputProfile = AndroidOutputProfile::Media; + + if (count > 1 && args[1].isString() && + args[1].getString(runtime).utf8(runtime) == "voiceCommunication") { + androidOutputProfile = AndroidOutputProfile::VoiceCommunication; + } auto audioContextHostObject = std::make_shared( - sampleRate, audioEventHandlerRegistry, &runtime, jsCallInvoker); + sampleRate, androidOutputProfile, audioEventHandlerRegistry, &runtime, jsCallInvoker); return jsi::Object::createFromHostObject(runtime, audioContextHostObject); }); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.cpp index 4bc1c6843..3f2d2ab9f 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.cpp @@ -11,11 +11,15 @@ namespace audioapi { AudioContextHostObject::AudioContextHostObject( float sampleRate, + AndroidOutputProfile androidOutputProfile, const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, const std::shared_ptr &callInvoker) : BaseAudioContextHostObject( - std::make_shared(sampleRate, audioEventHandlerRegistry), + std::make_shared( + sampleRate, + androidOutputProfile, + audioEventHandlerRegistry), runtime, callInvoker) { addGetters(JSI_EXPORT_PROPERTY_GETTER(AudioContextHostObject, outputLatency)); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.h index 4952c6c85..7da9cf909 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -15,6 +16,7 @@ class AudioContextHostObject : public BaseAudioContextHostObject { public: explicit AudioContextHostObject( float sampleRate, + AndroidOutputProfile androidOutputProfile, const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, const std::shared_ptr &callInvoker); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp index 6721bb9bf..0e175db41 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp @@ -15,8 +15,11 @@ namespace audioapi { AudioContext::AudioContext( float sampleRate, + AndroidOutputProfile androidOutputProfile, const std::shared_ptr &audioEventHandlerRegistry) - : BaseAudioContext(sampleRate, audioEventHandlerRegistry), isInitialized_(false) { + : BaseAudioContext(sampleRate, audioEventHandlerRegistry), + androidOutputProfile_(androidOutputProfile), + isInitialized_(false) { // Context starts SUSPENDED with no audio-thread consumer. Let the producer // drain the channels itself until start()/resume() hands draining to the // audio callback (same pattern as OfflineAudioContext before rendering). @@ -45,7 +48,8 @@ void AudioContext::initialize(const AudioDestinationNode *destination) { destination_->getChannelCount(), &driverMutex_, std::static_pointer_cast(shared_from_this()), - currentRenders_); + currentRenders_, + androidOutputProfile_); #else audioPlayer_ = std::make_shared( [this](DSPAudioBuffer *buf, int n) { processGraph(buf, n); }, diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h index 0cad163a2..a7b5dfcf9 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -15,6 +16,7 @@ class AudioContext : public BaseAudioContext { public: explicit AudioContext( float sampleRate, + AndroidOutputProfile androidOutputProfile, const std::shared_ptr &audioEventHandlerRegistry); ~AudioContext() override; DELETE_COPY_AND_MOVE(AudioContext); @@ -39,6 +41,7 @@ class AudioContext : public BaseAudioContext { private: std::shared_ptr audioPlayer_; + AndroidOutputProfile androidOutputProfile_; std::atomic isInitialized_{false}; /// Audio I/O callback thread increments around each platform render callback; /// control thread waits on suspend/close. diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContextOptions.h b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContextOptions.h new file mode 100644 index 000000000..9bc5f0c21 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContextOptions.h @@ -0,0 +1,10 @@ +#pragma once + +namespace audioapi { + +enum class AndroidOutputProfile { + Media, + VoiceCommunication, +}; + +} // namespace audioapi diff --git a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts index a3f23c4d4..b8482baa2 100644 --- a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts +++ b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts @@ -7,11 +7,18 @@ import type { IAudioBuffer, IOfflineAudioContext, } from '../jsi-interfaces'; -import type { AudioRecorderOptions, FileInfo } from '../types'; +import type { + AndroidOutputProfile, + AudioRecorderOptions, + FileInfo, +} from '../types'; /* eslint-disable no-var */ declare global { - var createAudioContext: (sampleRate: number) => IAudioContext; + var createAudioContext: ( + sampleRate: number, + androidOutputProfile?: AndroidOutputProfile + ) => IAudioContext; var createOfflineAudioContext: ( numberOfChannels: number, length: number, diff --git a/packages/react-native-audio-api/src/core/AudioContext.ts b/packages/react-native-audio-api/src/core/AudioContext.ts index f0b075bda..d22a5ec9a 100644 --- a/packages/react-native-audio-api/src/core/AudioContext.ts +++ b/packages/react-native-audio-api/src/core/AudioContext.ts @@ -15,7 +15,8 @@ export default class AudioContext extends BaseAudioContext { super( globalThis.createAudioContext( - options?.sampleRate || AudioManager.getDevicePreferredSampleRate() + options?.sampleRate || AudioManager.getDevicePreferredSampleRate(), + options?.androidOutputProfile ) ); } diff --git a/packages/react-native-audio-api/src/types.ts b/packages/react-native-audio-api/src/types.ts index d5db672e5..6c0d5f842 100644 --- a/packages/react-native-audio-api/src/types.ts +++ b/packages/react-native-audio-api/src/types.ts @@ -47,8 +47,11 @@ export type OscillatorType = export interface AudioContextOptions { sampleRate?: number; + androidOutputProfile?: AndroidOutputProfile; } +export type AndroidOutputProfile = 'media' | 'voiceCommunication'; + export interface OfflineAudioContextOptions { numberOfChannels: number; length: number; diff --git a/packages/react-native-audio-api/tests/audio-context-options.test.ts b/packages/react-native-audio-api/tests/audio-context-options.test.ts new file mode 100644 index 000000000..bd5c857e9 --- /dev/null +++ b/packages/react-native-audio-api/tests/audio-context-options.test.ts @@ -0,0 +1,40 @@ +jest.mock('react-native', () => ({ + TurboModuleRegistry: { + get: jest.fn(() => ({ + getDevicePreferredSampleRate: jest.fn(() => 44100), + })), + }, +})); + +jest.mock('../src/core/BaseAudioContext', () => { + return class BaseAudioContext { + constructor(_context: unknown) {} + }; +}); + +import type { IAudioContext } from '../src/jsi-interfaces'; + +describe('AudioContext options', () => { + const createAudioContext = globalThis.createAudioContext; + + afterEach(() => { + globalThis.createAudioContext = createAudioContext; + }); + + it('forwards the Android voice communication output profile to native', () => { + const nativeContext = {} as IAudioContext; + globalThis.createAudioContext = jest.fn(() => nativeContext); + const AudioContext = require('../src/core/AudioContext') + .default as typeof import('../src/core/AudioContext').default; + + new AudioContext({ + sampleRate: 24000, + androidOutputProfile: 'voiceCommunication', + }); + + expect(globalThis.createAudioContext).toHaveBeenCalledWith( + 24000, + 'voiceCommunication' + ); + }); +}); From 8c3fb33a7060f0fe89b2b09b1faec0590669714a Mon Sep 17 00:00:00 2001 From: michal Date: Mon, 28 Sep 2026 12:50:44 +0200 Subject: [PATCH 2/4] refactor: builder pattern when building player --- .claude/skills/thread-safety-itc/SKILL.md | 2 +- .../FabricExampleTests/AudioPlayerTests.mm | 22 ++--- .../audiodocs/docs/core/audio-context.mdx | 2 - .../cpp/audioapi/android/core/AudioPlayer.cpp | 23 ++---- .../cpp/audioapi/android/core/AudioPlayer.h | 19 +---- .../HostObjects/AudioContextHostObject.h | 2 +- .../common/cpp/audioapi/core/AudioContext.cpp | 36 ++++----- .../common/cpp/audioapi/core/AudioContext.h | 9 ++- .../cpp/audioapi/core/AudioPlayerBuilder.h | 81 +++++++++++++++++++ .../common/cpp/audioapi/core/CommonPlayer.h | 31 ++++++- .../core/{ => types}/AudioContextOptions.h | 3 +- .../ios/audioapi/ios/core/IOSAudioPlayer.h | 14 +--- .../ios/audioapi/ios/core/IOSAudioPlayer.mm | 31 +++---- .../tests/audio-context-options.test.ts | 40 --------- .../wpt_tests/src/NodeAudioPlayer.cpp | 10 +-- .../wpt_tests/src/NodeAudioPlayer.h | 14 +--- .../wpt_tests/src/jsi_install.cpp | 2 + 17 files changed, 170 insertions(+), 171 deletions(-) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/AudioPlayerBuilder.h rename packages/react-native-audio-api/common/cpp/audioapi/core/{ => types}/AudioContextOptions.h (58%) delete mode 100644 packages/react-native-audio-api/tests/audio-context-options.test.ts diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index 8d4d6b222..0bd12f4b4 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -167,7 +167,7 @@ Control-plane synchronization uses two layers — both are non-recursive `std::m 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`). +**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 the player receives through the `AudioPlayerBuilder` constructor (so it can never be missing), 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`), which inherits the shared properties (`renderAudio_`, `sampleRate_`, `channelCount_`, `currentRenders_`) from `CommonPlayerProperties`. `AudioContext` fills its `audioPlayerBuilder_` member (`common/cpp/audioapi/core/AudioPlayerBuilder.h`) in its constructor; `initialize()` adds the destination's channel count and the context (`shared_from_this()` is unusable in the constructor) and calls `build()`, constrained by the `BuildableAudioPlayer` concept. Every player's constructor takes the builder, forwards it to `CommonPlayer`, and reads only its platform-specific getters. `apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm` redeclares `IOSAudioPlayer` with its own member list (on top of the real `CommonPlayer.h`) to reach protected state, so any change to the members `IOSAudioPlayer` itself declares must be mirrored there, or the test subclass reads the wrong offsets. **Graph producer self-drain:** `Graph::enableProducerSelfDrain()` makes producer threads drain the event channels themselves after each enqueue; `disableProducerSelfDrain()` hands consumption back to the audio/render thread. Both flush the channels internally (no separate `processEvents()` call needed) and serialize with in-flight drains via `selfDrainMutex_`, because two producers can drain concurrently: the JS thread (mutations) and the GC finalizer thread (`removeNode`, which self-drains after its Channel B orphan send — otherwise a finalizer burst with no consumer fills the bounded channel and blocks forever, e.g. at process exit). Enable only when there is no audio/render consumer (realtime: construction + after `suspend`/`close` quiescence; offline: before `startRendering` and after a scheduled suspend); disable *before* starting the audio/render consumer; re-enable if start/resume fails. diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm index a115ec460..ea8430506 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm @@ -1,6 +1,7 @@ #import #import +#import #import #import #import @@ -21,11 +22,7 @@ class IOSAudioPlayer : public CommonPlayer { public: - IOSAudioPlayer( - const std::function &renderAudio, - float sampleRate, - int channelCount, - std::atomic ¤tRenders); + explicit IOSAudioPlayer(const AudioPlayerBuilder &builder); ~IOSAudioPlayer() override; bool start() override; @@ -40,13 +37,7 @@ [[nodiscard]] double getOutputLatency() const override; protected: - std::shared_ptr audioBuffer_; NativeAudioPlayer *audioPlayer_; - float sampleRate_; - std::function renderAudio_; - std::atomic ¤tRenders_; - int channelCount_; - std::atomic isRunning_; std::atomic flushOverflowNextPull_; int pendingSavedCount_; DSPAudioBuffer pendingSaved_; @@ -234,8 +225,11 @@ - (void)cleanup const std::function &renderAudio, float sampleRate, int channelCount) - : currentRendersStorage_(0), - IOSAudioPlayer(renderAudio, sampleRate, channelCount, currentRendersStorage_) {} + : IOSAudioPlayer(AudioPlayerBuilder(currentRendersStorage_) + .setRenderAudio(renderAudio) + .setSampleRate(sampleRate) + .setChannelCount(channelCount)), + currentRendersStorage_(0) {} NativeAudioPlayer *replaceAudioPlayer(NativeAudioPlayer *audioPlayer) { NativeAudioPlayer *previous = audioPlayer_; @@ -248,7 +242,7 @@ - (void)cleanup } std::shared_ptr getAudioBuffer() const { - return audioBuffer_; + return buffer_; } void setRunning(bool isRunning) { diff --git a/packages/audiodocs/docs/core/audio-context.mdx b/packages/audiodocs/docs/core/audio-context.mdx index 071856960..cb96ccb3c 100644 --- a/packages/audiodocs/docs/core/audio-context.mdx +++ b/packages/audiodocs/docs/core/audio-context.mdx @@ -23,8 +23,6 @@ constructor(options?: AudioContextOptions) | `sampleRate` | `number` | - | The preferred sample rate for the context. | | `androidOutputProfile` | `'media'` \| `'voiceCommunication'` | `'media'` | Android output classification. Use `'voiceCommunication'` for interactive voice calls. | -On Android, `voiceCommunication` configures the output stream with voice-communication usage and speech content type. It does not guarantee echo cancellation, noise suppression, or automatic gain control; those remain device- and route-dependent. - #### Errors | Error type | Description | diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp index 4bbdf0217..c5cd124f7 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp @@ -1,6 +1,7 @@ #include #include #include +#include #include #include #include @@ -13,22 +14,11 @@ namespace audioapi { -AudioPlayer::AudioPlayer( - const std::function &renderAudio, - float sampleRate, - int channelCount, - std::mutex *driverMutex, - const std::shared_ptr &context, - std::atomic ¤tRenders, - AndroidOutputProfile outputProfile) - : renderAudio_(renderAudio), - currentRenders_(currentRenders), - sampleRate_(sampleRate), - channelCount_(channelCount), - isRunning_(false), - driverMutex_(driverMutex), - context_(context), - outputProfile_(outputProfile) {} +AudioPlayer::AudioPlayer(const AudioPlayerBuilder &builder) + : CommonPlayer(builder), + driverMutex_(builder.getDriverMutex()), + context_(builder.getContext()), + outputProfile_(builder.getAndroidOutputProfile()) {} bool AudioPlayer::openAudioStream() { std::scoped_lock lock(streamMutex_); @@ -56,7 +46,6 @@ bool AudioPlayer::openAudioStream() { return false; } - buffer_ = std::make_shared(RENDER_QUANTUM_SIZE, channelCount_, sampleRate_); isInitialized_.store(true, std::memory_order_release); return true; } diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h index ee919a743..ba5a44921 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h @@ -5,12 +5,11 @@ #include #include #include -#include #include #include -#include #include +#include #include namespace audioapi { @@ -18,20 +17,14 @@ namespace audioapi { using namespace oboe; class AudioContext; +class AudioPlayerBuilder; class AudioPlayer : public CommonPlayer, public AudioStreamDataCallback, public AudioStreamErrorCallback, public std::enable_shared_from_this { public: - AudioPlayer( - const std::function &renderAudio, - float sampleRate, - int channelCount, - std::mutex *driverMutex, - const std::shared_ptr &context, - std::atomic ¤tRenders, - AndroidOutputProfile outputProfile); + explicit AudioPlayer(const AudioPlayerBuilder &builder); ~AudioPlayer() override { cleanup(); @@ -56,15 +49,9 @@ class AudioPlayer : public CommonPlayer, void onErrorAfterClose(AudioStream *audioStream, Result error) override; private: - std::function renderAudio_; - std::atomic ¤tRenders_; std::shared_ptr mStream_; mutable std::recursive_mutex streamMutex_; - std::shared_ptr buffer_; std::atomic isInitialized_{false}; - float sampleRate_; - int channelCount_; - std::atomic isRunning_; /// Updated on the audio thread from each Oboe callback `numFrames`. std::atomic lastCallbackFrameCount_{0}; std::mutex *driverMutex_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.h index 7da9cf909..b5467bb76 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/AudioContextHostObject.h @@ -1,7 +1,7 @@ #pragma once #include -#include +#include #include #include diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp index 0e175db41..83a2c484a 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp @@ -1,12 +1,16 @@ #ifdef RN_AUDIO_API_NODE #include "NodeAudioPlayer.h" +using PlatformAudioPlayer = audioapi::NodeAudioPlayer; #elif defined(ANDROID) #include +using PlatformAudioPlayer = audioapi::AudioPlayer; #else #include +using PlatformAudioPlayer = audioapi::IOSAudioPlayer; #endif #include +#include #include #include #include @@ -18,7 +22,12 @@ AudioContext::AudioContext( AndroidOutputProfile androidOutputProfile, const std::shared_ptr &audioEventHandlerRegistry) : BaseAudioContext(sampleRate, audioEventHandlerRegistry), - androidOutputProfile_(androidOutputProfile), + audioPlayerBuilder_( + AudioPlayerBuilder(currentRenders_) + .setRenderAudio([this](DSPAudioBuffer *buf, int n) { processGraph(buf, n); }) + .setSampleRate(getSampleRate()) + .setDriverMutex(&driverMutex_) + .setAndroidOutputProfile(androidOutputProfile)), isInitialized_(false) { // Context starts SUSPENDED with no audio-thread consumer. Let the producer // drain the channels itself until start()/resume() hands draining to the @@ -36,27 +45,10 @@ AudioContext::~AudioContext() { void AudioContext::initialize(const AudioDestinationNode *destination) { BaseAudioContext::initialize(destination); -#ifdef RN_AUDIO_API_NODE - audioPlayer_ = std::make_shared( - [this](DSPAudioBuffer *buf, int n) { processGraph(buf, n); }, - getSampleRate(), - destination_->getChannelCount()); -#elif defined(ANDROID) - audioPlayer_ = std::make_shared( - [this](DSPAudioBuffer *buf, int n) { processGraph(buf, n); }, - getSampleRate(), - destination_->getChannelCount(), - &driverMutex_, - std::static_pointer_cast(shared_from_this()), - currentRenders_, - androidOutputProfile_); -#else - audioPlayer_ = std::make_shared( - [this](DSPAudioBuffer *buf, int n) { processGraph(buf, n); }, - getSampleRate(), - destination_->getChannelCount(), - currentRenders_); -#endif + audioPlayer_ = + audioPlayerBuilder_.setChannelCount(static_cast(destination_->getChannelCount())) + .setContext(std::static_pointer_cast(shared_from_this())) + .build(); } bool AudioContext::tryStartDriver() { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h index a7b5dfcf9..d3d86cef5 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h @@ -1,8 +1,9 @@ #pragma once -#include +#include #include #include +#include #include #include #include @@ -40,12 +41,12 @@ class AudioContext : public BaseAudioContext { [[nodiscard]] double getOutputLatency() const; private: - std::shared_ptr audioPlayer_; - AndroidOutputProfile androidOutputProfile_; - std::atomic isInitialized_{false}; /// Audio I/O callback thread increments around each platform render callback; /// control thread waits on suspend/close. std::atomic currentRenders_{0}; + std::shared_ptr audioPlayer_; + AudioPlayerBuilder audioPlayerBuilder_; + std::atomic isInitialized_{false}; bool isDriverRunning() const override; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioPlayerBuilder.h b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioPlayerBuilder.h new file mode 100644 index 000000000..5794ecd88 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioPlayerBuilder.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include + +#include +#include +#include +#include + +namespace audioapi { + +class AudioContext; +class AudioPlayerBuilder; + +template +concept BuildableAudioPlayer = std::derived_from && + std::constructible_from; + +class AudioPlayerBuilder : public CommonPlayerProperties { + public: + explicit AudioPlayerBuilder(std::atomic ¤tRenders) + : CommonPlayerProperties(currentRenders) {} + + AudioPlayerBuilder &setRenderAudio(std::function renderAudio) { + renderAudio_ = std::move(renderAudio); + return *this; + } + + AudioPlayerBuilder &setSampleRate(float sampleRate) { + sampleRate_ = sampleRate; + return *this; + } + + AudioPlayerBuilder &setChannelCount(int channelCount) { + channelCount_ = channelCount; + return *this; + } + + /// @note Android only. + AudioPlayerBuilder &setDriverMutex(std::mutex *driverMutex) { + driverMutex_ = driverMutex; + return *this; + } + + /// @note Android only. + AudioPlayerBuilder &setContext(const std::shared_ptr &context) { + context_ = context; + return *this; + } + + AudioPlayerBuilder &setAndroidOutputProfile(AndroidOutputProfile androidOutputProfile) { + androidOutputProfile_ = androidOutputProfile; + return *this; + } + + /// @note Android only. + [[nodiscard]] std::mutex *getDriverMutex() const { + return driverMutex_; + } + + [[nodiscard]] const std::weak_ptr &getContext() const { + return context_; + } + + [[nodiscard]] AndroidOutputProfile getAndroidOutputProfile() const { + return androidOutputProfile_; + } + + template + [[nodiscard]] std::shared_ptr build() const { + return std::make_shared(*this); + } + + private: + std::mutex *driverMutex_{nullptr}; + std::weak_ptr context_; + AndroidOutputProfile androidOutputProfile_{AndroidOutputProfile::Media}; +}; + +} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/CommonPlayer.h b/packages/react-native-audio-api/common/cpp/audioapi/core/CommonPlayer.h index 632e9300c..cc7b5d0ca 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/CommonPlayer.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/CommonPlayer.h @@ -1,12 +1,28 @@ #pragma once +#include +#include #include +#include +#include + namespace audioapi { -class CommonPlayer { +/// Properties every platform player needs. +class CommonPlayerProperties { + protected: + explicit CommonPlayerProperties(std::atomic ¤tRenders) + : currentRenders_(currentRenders) {} + + std::function renderAudio_; + float sampleRate_{0}; + int channelCount_{0}; + std::reference_wrapper> currentRenders_; +}; + +class CommonPlayer : protected CommonPlayerProperties { public: - CommonPlayer() = default; DELETE_COPY_AND_MOVE(CommonPlayer); virtual ~CommonPlayer() = default; @@ -20,6 +36,17 @@ class CommonPlayer { [[nodiscard]] virtual double getOutputLatency() const = 0; [[nodiscard]] virtual bool isRunning() const = 0; + + protected: + explicit CommonPlayer(const CommonPlayerProperties &properties) + : CommonPlayerProperties(properties), + buffer_(std::make_shared(RENDER_QUANTUM_SIZE, channelCount_, sampleRate_)) { + } + + std::shared_ptr buffer_; + /// Whether the player was last started or resumed rather than stopped or suspended. + /// A platform's `isRunning()` may also check its own driver state. + std::atomic isRunning_{false}; }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContextOptions.h b/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioContextOptions.h similarity index 58% rename from packages/react-native-audio-api/common/cpp/audioapi/core/AudioContextOptions.h rename to packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioContextOptions.h index 9bc5f0c21..8fba15bfc 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContextOptions.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioContextOptions.h @@ -1,8 +1,9 @@ #pragma once +#include namespace audioapi { -enum class AndroidOutputProfile { +enum class AndroidOutputProfile : std::uint8_t { Media, VoiceCommunication, }; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h index d5203af6b..91cf51b00 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h @@ -13,18 +13,14 @@ typedef struct objc_object AudioBufferList; #include #include -#include namespace audioapi { class AudioContext; +class AudioPlayerBuilder; class IOSAudioPlayer : public CommonPlayer { public: - IOSAudioPlayer( - const std::function &renderAudio, - float sampleRate, - int channelCount, - std::atomic ¤tRenders); + explicit IOSAudioPlayer(const AudioPlayerBuilder &builder); ~IOSAudioPlayer() override; DELETE_COPY_AND_MOVE(IOSAudioPlayer); @@ -48,13 +44,7 @@ class IOSAudioPlayer : public CommonPlayer { /// is kept (max 128 frames) and played at the start of the next callback. void deliverOutputBuffers(AudioBufferList *outputData, int numFrames); - std::shared_ptr audioBuffer_; NativeAudioPlayer *audioPlayer_; - float sampleRate_; - std::function renderAudio_; - std::atomic ¤tRenders_; - int channelCount_; - std::atomic isRunning_; /// Set from main thread on start/resume; consumed on audio thread to drop stale pending audio. std::atomic flushOverflowNextPull_{false}; /// Frames valid at the front of each `pendingSaved_[ch]` (0 … RENDER_QUANTUM_SIZE). diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.mm index cfd860e3e..b172bb4e3 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.mm @@ -4,6 +4,7 @@ #include #include +#include #include #include #include @@ -13,28 +14,18 @@ namespace audioapi { -IOSAudioPlayer::IOSAudioPlayer( - const std::function &renderAudio, - float sampleRate, - int channelCount, - std::atomic ¤tRenders) - : audioBuffer_(nullptr), +IOSAudioPlayer::IOSAudioPlayer(const AudioPlayerBuilder &builder) + : CommonPlayer(builder), audioPlayer_(nullptr), - renderAudio_(renderAudio), - sampleRate_(sampleRate), - currentRenders_(currentRenders), - channelCount_(channelCount), - isRunning_(false), - pendingSaved_(RENDER_QUANTUM_SIZE, channelCount_, sampleRate) + pendingSaved_(RENDER_QUANTUM_SIZE, channelCount_, sampleRate_) { RenderAudioBlock renderAudioBlock = ^(AudioBufferList *outputData, int numFrames) { deliverOutputBuffers(outputData, numFrames); }; audioPlayer_ = [[NativeAudioPlayer alloc] initWithRenderAudio:renderAudioBlock - sampleRate:sampleRate + sampleRate:sampleRate_ channelCount:channelCount_]; - audioBuffer_ = std::make_shared(RENDER_QUANTUM_SIZE, channelCount_, sampleRate); } IOSAudioPlayer::~IOSAudioPlayer() @@ -92,18 +83,18 @@ continue; } - renderAudio_(audioBuffer_.get(), RENDER_QUANTUM_SIZE); + renderAudio_(buffer_.get(), RENDER_QUANTUM_SIZE); // Peak-normalize the rendered quantum before it reaches the hardware. This // limiting lives in the player (not the destination node) so offline // renders stay spec-accurate. - audioBuffer_->normalize(); + buffer_->normalize(); // normal rendering - take RENDER_QUANTUM_SIZE frames from the graph and copy to output const int stillNeed = numFrames - outPos; if (stillNeed >= RENDER_QUANTUM_SIZE) { for (int ch = 0; ch < channelCount_; ++ch) { - auto *src = (*audioBuffer_)[ch].begin(); + auto *src = (*buffer_)[ch].begin(); float *dst = static_cast(outputData->mBuffers[ch].mData) + outPos; std::memcpy(dst, src, RENDER_QUANTUM_SIZE * sizeof(float)); } @@ -112,11 +103,11 @@ // when output will be sliced, copy the remaining frames to pendingSaved const int tail = RENDER_QUANTUM_SIZE - stillNeed; for (int ch = 0; ch < channelCount_; ++ch) { - auto *src = (*audioBuffer_)[ch].begin(); + auto *src = (*buffer_)[ch].begin(); float *dst = static_cast(outputData->mBuffers[ch].mData) + outPos; std::memcpy(dst, src, stillNeed * sizeof(float)); } - pendingSaved_.copy(*audioBuffer_, stillNeed, 0, tail); + pendingSaved_.copy(*buffer_, stillNeed, 0, tail); pendingSavedCount_ = tail; outPos += stillNeed; } @@ -175,7 +166,7 @@ { stop(); [audioPlayer_ cleanup]; - audioBuffer_ = nullptr; + buffer_ = nullptr; } double IOSAudioPlayer::getBaseLatency() const diff --git a/packages/react-native-audio-api/tests/audio-context-options.test.ts b/packages/react-native-audio-api/tests/audio-context-options.test.ts deleted file mode 100644 index bd5c857e9..000000000 --- a/packages/react-native-audio-api/tests/audio-context-options.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -jest.mock('react-native', () => ({ - TurboModuleRegistry: { - get: jest.fn(() => ({ - getDevicePreferredSampleRate: jest.fn(() => 44100), - })), - }, -})); - -jest.mock('../src/core/BaseAudioContext', () => { - return class BaseAudioContext { - constructor(_context: unknown) {} - }; -}); - -import type { IAudioContext } from '../src/jsi-interfaces'; - -describe('AudioContext options', () => { - const createAudioContext = globalThis.createAudioContext; - - afterEach(() => { - globalThis.createAudioContext = createAudioContext; - }); - - it('forwards the Android voice communication output profile to native', () => { - const nativeContext = {} as IAudioContext; - globalThis.createAudioContext = jest.fn(() => nativeContext); - const AudioContext = require('../src/core/AudioContext') - .default as typeof import('../src/core/AudioContext').default; - - new AudioContext({ - sampleRate: 24000, - androidOutputProfile: 'voiceCommunication', - }); - - expect(globalThis.createAudioContext).toHaveBeenCalledWith( - 24000, - 'voiceCommunication' - ); - }); -}); diff --git a/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.cpp b/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.cpp index 0eb639e99..3ee4ca386 100644 --- a/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.cpp +++ b/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.cpp @@ -1,5 +1,6 @@ #include "NodeAudioPlayer.h" +#include #include #include @@ -7,14 +8,7 @@ namespace audioapi { -NodeAudioPlayer::NodeAudioPlayer( - const std::function &renderAudio, - float sampleRate, - int channelCount) - : renderAudio_(renderAudio), - buffer_(std::make_shared(RENDER_QUANTUM_SIZE, channelCount, sampleRate)), - sampleRate_(sampleRate), - channelCount_(channelCount) {} +NodeAudioPlayer::NodeAudioPlayer(const AudioPlayerBuilder &builder) : CommonPlayer(builder) {} NodeAudioPlayer::~NodeAudioPlayer() { // Always join here — do not gate on isInitialized_. After cleanup()/stop() diff --git a/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.h b/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.h index a0556292c..130fb91e5 100644 --- a/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.h +++ b/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.h @@ -4,18 +4,15 @@ #include #include -#include -#include #include namespace audioapi { +class AudioPlayerBuilder; + class NodeAudioPlayer final : public CommonPlayer { public: - NodeAudioPlayer( - const std::function &renderAudio, - float sampleRate, - int channelCount); + explicit NodeAudioPlayer(const AudioPlayerBuilder &builder); ~NodeAudioPlayer() override; bool start() override; @@ -33,12 +30,7 @@ class NodeAudioPlayer final : public CommonPlayer { /// Signal the worker to exit and join it. Safe to call repeatedly. void terminateWorker(); - std::function renderAudio_; - std::shared_ptr buffer_; - float sampleRate_; - int channelCount_; std::atomic isInitialized_{false}; - std::atomic isRunning_{false}; std::atomic isPaused_{true}; std::atomic shouldStop_{false}; std::thread worker_; diff --git a/packages/react-native-audio-api/wpt_tests/src/jsi_install.cpp b/packages/react-native-audio-api/wpt_tests/src/jsi_install.cpp index dccc27672..ba30e417d 100644 --- a/packages/react-native-audio-api/wpt_tests/src/jsi_install.cpp +++ b/packages/react-native-audio-api/wpt_tests/src/jsi_install.cpp @@ -20,6 +20,7 @@ namespace { +using audioapi::AndroidOutputProfile; using audioapi::AudioBuffer; using audioapi::AudioBufferHostObject; using audioapi::AudioContextHostObject; @@ -203,6 +204,7 @@ void installAudioContextBinding( const auto sampleRate = static_cast(args[0].getNumber()); auto hostObject = std::make_shared( sampleRate, + AndroidOutputProfile::Media, eventRegistry, &rt, callInvoker); From 52caa421e7ccc4ce81ddf6cff8066efc02e01d13 Mon Sep 17 00:00:00 2001 From: michal Date: Mon, 28 Sep 2026 12:57:43 +0200 Subject: [PATCH 3/4] fix: failing ci --- .../common/cpp/audioapi/core/AudioPlayerBuilder.h | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioPlayerBuilder.h b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioPlayerBuilder.h index 5794ecd88..f7c765f24 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioPlayerBuilder.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioPlayerBuilder.h @@ -4,6 +4,7 @@ #include #include +#include #include #include #include From 7f27e62d2f7c8570addcfbe1537cb4fd39413ec9 Mon Sep 17 00:00:00 2001 From: michal Date: Tue, 29 Sep 2026 10:47:03 +0200 Subject: [PATCH 4/4] fix: failing ci and revert player changes --- .claude/skills/thread-safety-itc/SKILL.md | 2 +- .../FabricExampleTests/AudioPlayerTests.mm | 22 +++-- .../cpp/audioapi/android/core/AudioPlayer.cpp | 23 ++++-- .../cpp/audioapi/android/core/AudioPlayer.h | 17 +++- .../common/cpp/audioapi/core/AudioContext.cpp | 36 ++++---- .../common/cpp/audioapi/core/AudioContext.h | 7 +- .../cpp/audioapi/core/AudioPlayerBuilder.h | 82 ------------------- .../common/cpp/audioapi/core/CommonPlayer.h | 31 +------ .../ios/audioapi/ios/core/IOSAudioPlayer.h | 14 +++- .../ios/audioapi/ios/core/IOSAudioPlayer.mm | 31 ++++--- .../wpt_tests/src/NodeAudioPlayer.cpp | 10 ++- .../wpt_tests/src/NodeAudioPlayer.h | 14 +++- 12 files changed, 125 insertions(+), 164 deletions(-) delete mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/AudioPlayerBuilder.h diff --git a/.claude/skills/thread-safety-itc/SKILL.md b/.claude/skills/thread-safety-itc/SKILL.md index 0bd12f4b4..8d4d6b222 100644 --- a/.claude/skills/thread-safety-itc/SKILL.md +++ b/.claude/skills/thread-safety-itc/SKILL.md @@ -167,7 +167,7 @@ Control-plane synchronization uses two layers — both are non-recursive `std::m 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 the player receives through the `AudioPlayerBuilder` constructor (so it can never be missing), 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`), which inherits the shared properties (`renderAudio_`, `sampleRate_`, `channelCount_`, `currentRenders_`) from `CommonPlayerProperties`. `AudioContext` fills its `audioPlayerBuilder_` member (`common/cpp/audioapi/core/AudioPlayerBuilder.h`) in its constructor; `initialize()` adds the destination's channel count and the context (`shared_from_this()` is unusable in the constructor) and calls `build()`, constrained by the `BuildableAudioPlayer` concept. Every player's constructor takes the builder, forwards it to `CommonPlayer`, and reads only its platform-specific getters. `apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm` redeclares `IOSAudioPlayer` with its own member list (on top of the real `CommonPlayer.h`) to reach protected state, so any change to the members `IOSAudioPlayer` itself declares must be mirrored there, or the test subclass reads the wrong offsets. +**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 producer self-drain:** `Graph::enableProducerSelfDrain()` makes producer threads drain the event channels themselves after each enqueue; `disableProducerSelfDrain()` hands consumption back to the audio/render thread. Both flush the channels internally (no separate `processEvents()` call needed) and serialize with in-flight drains via `selfDrainMutex_`, because two producers can drain concurrently: the JS thread (mutations) and the GC finalizer thread (`removeNode`, which self-drains after its Channel B orphan send — otherwise a finalizer burst with no consumer fills the bounded channel and blocks forever, e.g. at process exit). Enable only when there is no audio/render consumer (realtime: construction + after `suspend`/`close` quiescence; offline: before `startRendering` and after a scheduled suspend); disable *before* starting the audio/render consumer; re-enable if start/resume fails. diff --git a/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm b/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm index ea8430506..a115ec460 100644 --- a/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm +++ b/apps/fabric-example/ios/FabricExampleTests/AudioPlayerTests.mm @@ -1,7 +1,6 @@ #import #import -#import #import #import #import @@ -22,7 +21,11 @@ class IOSAudioPlayer : public CommonPlayer { public: - explicit IOSAudioPlayer(const AudioPlayerBuilder &builder); + IOSAudioPlayer( + const std::function &renderAudio, + float sampleRate, + int channelCount, + std::atomic ¤tRenders); ~IOSAudioPlayer() override; bool start() override; @@ -37,7 +40,13 @@ [[nodiscard]] double getOutputLatency() const override; protected: + std::shared_ptr audioBuffer_; NativeAudioPlayer *audioPlayer_; + float sampleRate_; + std::function renderAudio_; + std::atomic ¤tRenders_; + int channelCount_; + std::atomic isRunning_; std::atomic flushOverflowNextPull_; int pendingSavedCount_; DSPAudioBuffer pendingSaved_; @@ -225,11 +234,8 @@ - (void)cleanup const std::function &renderAudio, float sampleRate, int channelCount) - : IOSAudioPlayer(AudioPlayerBuilder(currentRendersStorage_) - .setRenderAudio(renderAudio) - .setSampleRate(sampleRate) - .setChannelCount(channelCount)), - currentRendersStorage_(0) {} + : currentRendersStorage_(0), + IOSAudioPlayer(renderAudio, sampleRate, channelCount, currentRendersStorage_) {} NativeAudioPlayer *replaceAudioPlayer(NativeAudioPlayer *audioPlayer) { NativeAudioPlayer *previous = audioPlayer_; @@ -242,7 +248,7 @@ - (void)cleanup } std::shared_ptr getAudioBuffer() const { - return buffer_; + return audioBuffer_; } void setRunning(bool isRunning) { diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp index c5cd124f7..4bbdf0217 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.cpp @@ -1,7 +1,6 @@ #include #include #include -#include #include #include #include @@ -14,11 +13,22 @@ namespace audioapi { -AudioPlayer::AudioPlayer(const AudioPlayerBuilder &builder) - : CommonPlayer(builder), - driverMutex_(builder.getDriverMutex()), - context_(builder.getContext()), - outputProfile_(builder.getAndroidOutputProfile()) {} +AudioPlayer::AudioPlayer( + const std::function &renderAudio, + float sampleRate, + int channelCount, + std::mutex *driverMutex, + const std::shared_ptr &context, + std::atomic ¤tRenders, + AndroidOutputProfile outputProfile) + : renderAudio_(renderAudio), + currentRenders_(currentRenders), + sampleRate_(sampleRate), + channelCount_(channelCount), + isRunning_(false), + driverMutex_(driverMutex), + context_(context), + outputProfile_(outputProfile) {} bool AudioPlayer::openAudioStream() { std::scoped_lock lock(streamMutex_); @@ -46,6 +56,7 @@ bool AudioPlayer::openAudioStream() { return false; } + buffer_ = std::make_shared(RENDER_QUANTUM_SIZE, channelCount_, sampleRate_); isInitialized_.store(true, std::memory_order_release); return true; } diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h index ba5a44921..b0c651fdb 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AudioPlayer.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -17,14 +18,20 @@ namespace audioapi { using namespace oboe; class AudioContext; -class AudioPlayerBuilder; class AudioPlayer : public CommonPlayer, public AudioStreamDataCallback, public AudioStreamErrorCallback, public std::enable_shared_from_this { public: - explicit AudioPlayer(const AudioPlayerBuilder &builder); + AudioPlayer( + const std::function &renderAudio, + float sampleRate, + int channelCount, + std::mutex *driverMutex, + const std::shared_ptr &context, + std::atomic ¤tRenders, + AndroidOutputProfile outputProfile); ~AudioPlayer() override { cleanup(); @@ -49,9 +56,15 @@ class AudioPlayer : public CommonPlayer, void onErrorAfterClose(AudioStream *audioStream, Result error) override; private: + std::function renderAudio_; + std::atomic ¤tRenders_; std::shared_ptr mStream_; mutable std::recursive_mutex streamMutex_; + std::shared_ptr buffer_; std::atomic isInitialized_{false}; + float sampleRate_; + int channelCount_; + std::atomic isRunning_; /// Updated on the audio thread from each Oboe callback `numFrames`. std::atomic lastCallbackFrameCount_{0}; std::mutex *driverMutex_; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp index 83a2c484a..0e175db41 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.cpp @@ -1,16 +1,12 @@ #ifdef RN_AUDIO_API_NODE #include "NodeAudioPlayer.h" -using PlatformAudioPlayer = audioapi::NodeAudioPlayer; #elif defined(ANDROID) #include -using PlatformAudioPlayer = audioapi::AudioPlayer; #else #include -using PlatformAudioPlayer = audioapi::IOSAudioPlayer; #endif #include -#include #include #include #include @@ -22,12 +18,7 @@ AudioContext::AudioContext( AndroidOutputProfile androidOutputProfile, const std::shared_ptr &audioEventHandlerRegistry) : BaseAudioContext(sampleRate, audioEventHandlerRegistry), - audioPlayerBuilder_( - AudioPlayerBuilder(currentRenders_) - .setRenderAudio([this](DSPAudioBuffer *buf, int n) { processGraph(buf, n); }) - .setSampleRate(getSampleRate()) - .setDriverMutex(&driverMutex_) - .setAndroidOutputProfile(androidOutputProfile)), + androidOutputProfile_(androidOutputProfile), isInitialized_(false) { // Context starts SUSPENDED with no audio-thread consumer. Let the producer // drain the channels itself until start()/resume() hands draining to the @@ -45,10 +36,27 @@ AudioContext::~AudioContext() { void AudioContext::initialize(const AudioDestinationNode *destination) { BaseAudioContext::initialize(destination); - audioPlayer_ = - audioPlayerBuilder_.setChannelCount(static_cast(destination_->getChannelCount())) - .setContext(std::static_pointer_cast(shared_from_this())) - .build(); +#ifdef RN_AUDIO_API_NODE + audioPlayer_ = std::make_shared( + [this](DSPAudioBuffer *buf, int n) { processGraph(buf, n); }, + getSampleRate(), + destination_->getChannelCount()); +#elif defined(ANDROID) + audioPlayer_ = std::make_shared( + [this](DSPAudioBuffer *buf, int n) { processGraph(buf, n); }, + getSampleRate(), + destination_->getChannelCount(), + &driverMutex_, + std::static_pointer_cast(shared_from_this()), + currentRenders_, + androidOutputProfile_); +#else + audioPlayer_ = std::make_shared( + [this](DSPAudioBuffer *buf, int n) { processGraph(buf, n); }, + getSampleRate(), + destination_->getChannelCount(), + currentRenders_); +#endif } bool AudioContext::tryStartDriver() { diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h index d3d86cef5..15ce73ac8 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioContext.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include @@ -41,12 +40,12 @@ class AudioContext : public BaseAudioContext { [[nodiscard]] double getOutputLatency() const; private: + std::shared_ptr audioPlayer_; + AndroidOutputProfile androidOutputProfile_; + std::atomic isInitialized_{false}; /// Audio I/O callback thread increments around each platform render callback; /// control thread waits on suspend/close. std::atomic currentRenders_{0}; - std::shared_ptr audioPlayer_; - AudioPlayerBuilder audioPlayerBuilder_; - std::atomic isInitialized_{false}; bool isDriverRunning() const override; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioPlayerBuilder.h b/packages/react-native-audio-api/common/cpp/audioapi/core/AudioPlayerBuilder.h deleted file mode 100644 index f7c765f24..000000000 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/AudioPlayerBuilder.h +++ /dev/null @@ -1,82 +0,0 @@ -#pragma once - -#include -#include - -#include -#include -#include -#include -#include - -namespace audioapi { - -class AudioContext; -class AudioPlayerBuilder; - -template -concept BuildableAudioPlayer = std::derived_from && - std::constructible_from; - -class AudioPlayerBuilder : public CommonPlayerProperties { - public: - explicit AudioPlayerBuilder(std::atomic ¤tRenders) - : CommonPlayerProperties(currentRenders) {} - - AudioPlayerBuilder &setRenderAudio(std::function renderAudio) { - renderAudio_ = std::move(renderAudio); - return *this; - } - - AudioPlayerBuilder &setSampleRate(float sampleRate) { - sampleRate_ = sampleRate; - return *this; - } - - AudioPlayerBuilder &setChannelCount(int channelCount) { - channelCount_ = channelCount; - return *this; - } - - /// @note Android only. - AudioPlayerBuilder &setDriverMutex(std::mutex *driverMutex) { - driverMutex_ = driverMutex; - return *this; - } - - /// @note Android only. - AudioPlayerBuilder &setContext(const std::shared_ptr &context) { - context_ = context; - return *this; - } - - AudioPlayerBuilder &setAndroidOutputProfile(AndroidOutputProfile androidOutputProfile) { - androidOutputProfile_ = androidOutputProfile; - return *this; - } - - /// @note Android only. - [[nodiscard]] std::mutex *getDriverMutex() const { - return driverMutex_; - } - - [[nodiscard]] const std::weak_ptr &getContext() const { - return context_; - } - - [[nodiscard]] AndroidOutputProfile getAndroidOutputProfile() const { - return androidOutputProfile_; - } - - template - [[nodiscard]] std::shared_ptr build() const { - return std::make_shared(*this); - } - - private: - std::mutex *driverMutex_{nullptr}; - std::weak_ptr context_; - AndroidOutputProfile androidOutputProfile_{AndroidOutputProfile::Media}; -}; - -} // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/CommonPlayer.h b/packages/react-native-audio-api/common/cpp/audioapi/core/CommonPlayer.h index cc7b5d0ca..632e9300c 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/CommonPlayer.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/CommonPlayer.h @@ -1,28 +1,12 @@ #pragma once -#include -#include #include -#include -#include - namespace audioapi { -/// Properties every platform player needs. -class CommonPlayerProperties { - protected: - explicit CommonPlayerProperties(std::atomic ¤tRenders) - : currentRenders_(currentRenders) {} - - std::function renderAudio_; - float sampleRate_{0}; - int channelCount_{0}; - std::reference_wrapper> currentRenders_; -}; - -class CommonPlayer : protected CommonPlayerProperties { +class CommonPlayer { public: + CommonPlayer() = default; DELETE_COPY_AND_MOVE(CommonPlayer); virtual ~CommonPlayer() = default; @@ -36,17 +20,6 @@ class CommonPlayer : protected CommonPlayerProperties { [[nodiscard]] virtual double getOutputLatency() const = 0; [[nodiscard]] virtual bool isRunning() const = 0; - - protected: - explicit CommonPlayer(const CommonPlayerProperties &properties) - : CommonPlayerProperties(properties), - buffer_(std::make_shared(RENDER_QUANTUM_SIZE, channelCount_, sampleRate_)) { - } - - std::shared_ptr buffer_; - /// Whether the player was last started or resumed rather than stopped or suspended. - /// A platform's `isRunning()` may also check its own driver state. - std::atomic isRunning_{false}; }; } // namespace audioapi diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h index 91cf51b00..d5203af6b 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h @@ -13,14 +13,18 @@ typedef struct objc_object AudioBufferList; #include #include +#include namespace audioapi { class AudioContext; -class AudioPlayerBuilder; class IOSAudioPlayer : public CommonPlayer { public: - explicit IOSAudioPlayer(const AudioPlayerBuilder &builder); + IOSAudioPlayer( + const std::function &renderAudio, + float sampleRate, + int channelCount, + std::atomic ¤tRenders); ~IOSAudioPlayer() override; DELETE_COPY_AND_MOVE(IOSAudioPlayer); @@ -44,7 +48,13 @@ class IOSAudioPlayer : public CommonPlayer { /// is kept (max 128 frames) and played at the start of the next callback. void deliverOutputBuffers(AudioBufferList *outputData, int numFrames); + std::shared_ptr audioBuffer_; NativeAudioPlayer *audioPlayer_; + float sampleRate_; + std::function renderAudio_; + std::atomic ¤tRenders_; + int channelCount_; + std::atomic isRunning_; /// Set from main thread on start/resume; consumed on audio thread to drop stale pending audio. std::atomic flushOverflowNextPull_{false}; /// Frames valid at the front of each `pendingSaved_[ch]` (0 … RENDER_QUANTUM_SIZE). diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.mm index b172bb4e3..cfd860e3e 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.mm @@ -4,7 +4,6 @@ #include #include -#include #include #include #include @@ -14,18 +13,28 @@ namespace audioapi { -IOSAudioPlayer::IOSAudioPlayer(const AudioPlayerBuilder &builder) - : CommonPlayer(builder), +IOSAudioPlayer::IOSAudioPlayer( + const std::function &renderAudio, + float sampleRate, + int channelCount, + std::atomic ¤tRenders) + : audioBuffer_(nullptr), audioPlayer_(nullptr), - pendingSaved_(RENDER_QUANTUM_SIZE, channelCount_, sampleRate_) + renderAudio_(renderAudio), + sampleRate_(sampleRate), + currentRenders_(currentRenders), + channelCount_(channelCount), + isRunning_(false), + pendingSaved_(RENDER_QUANTUM_SIZE, channelCount_, sampleRate) { RenderAudioBlock renderAudioBlock = ^(AudioBufferList *outputData, int numFrames) { deliverOutputBuffers(outputData, numFrames); }; audioPlayer_ = [[NativeAudioPlayer alloc] initWithRenderAudio:renderAudioBlock - sampleRate:sampleRate_ + sampleRate:sampleRate channelCount:channelCount_]; + audioBuffer_ = std::make_shared(RENDER_QUANTUM_SIZE, channelCount_, sampleRate); } IOSAudioPlayer::~IOSAudioPlayer() @@ -83,18 +92,18 @@ continue; } - renderAudio_(buffer_.get(), RENDER_QUANTUM_SIZE); + renderAudio_(audioBuffer_.get(), RENDER_QUANTUM_SIZE); // Peak-normalize the rendered quantum before it reaches the hardware. This // limiting lives in the player (not the destination node) so offline // renders stay spec-accurate. - buffer_->normalize(); + audioBuffer_->normalize(); // normal rendering - take RENDER_QUANTUM_SIZE frames from the graph and copy to output const int stillNeed = numFrames - outPos; if (stillNeed >= RENDER_QUANTUM_SIZE) { for (int ch = 0; ch < channelCount_; ++ch) { - auto *src = (*buffer_)[ch].begin(); + auto *src = (*audioBuffer_)[ch].begin(); float *dst = static_cast(outputData->mBuffers[ch].mData) + outPos; std::memcpy(dst, src, RENDER_QUANTUM_SIZE * sizeof(float)); } @@ -103,11 +112,11 @@ // when output will be sliced, copy the remaining frames to pendingSaved const int tail = RENDER_QUANTUM_SIZE - stillNeed; for (int ch = 0; ch < channelCount_; ++ch) { - auto *src = (*buffer_)[ch].begin(); + auto *src = (*audioBuffer_)[ch].begin(); float *dst = static_cast(outputData->mBuffers[ch].mData) + outPos; std::memcpy(dst, src, stillNeed * sizeof(float)); } - pendingSaved_.copy(*buffer_, stillNeed, 0, tail); + pendingSaved_.copy(*audioBuffer_, stillNeed, 0, tail); pendingSavedCount_ = tail; outPos += stillNeed; } @@ -166,7 +175,7 @@ { stop(); [audioPlayer_ cleanup]; - buffer_ = nullptr; + audioBuffer_ = nullptr; } double IOSAudioPlayer::getBaseLatency() const diff --git a/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.cpp b/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.cpp index 3ee4ca386..0eb639e99 100644 --- a/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.cpp +++ b/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.cpp @@ -1,6 +1,5 @@ #include "NodeAudioPlayer.h" -#include #include #include @@ -8,7 +7,14 @@ namespace audioapi { -NodeAudioPlayer::NodeAudioPlayer(const AudioPlayerBuilder &builder) : CommonPlayer(builder) {} +NodeAudioPlayer::NodeAudioPlayer( + const std::function &renderAudio, + float sampleRate, + int channelCount) + : renderAudio_(renderAudio), + buffer_(std::make_shared(RENDER_QUANTUM_SIZE, channelCount, sampleRate)), + sampleRate_(sampleRate), + channelCount_(channelCount) {} NodeAudioPlayer::~NodeAudioPlayer() { // Always join here — do not gate on isInitialized_. After cleanup()/stop() diff --git a/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.h b/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.h index 130fb91e5..a0556292c 100644 --- a/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.h +++ b/packages/react-native-audio-api/wpt_tests/src/NodeAudioPlayer.h @@ -4,15 +4,18 @@ #include #include +#include +#include #include namespace audioapi { -class AudioPlayerBuilder; - class NodeAudioPlayer final : public CommonPlayer { public: - explicit NodeAudioPlayer(const AudioPlayerBuilder &builder); + NodeAudioPlayer( + const std::function &renderAudio, + float sampleRate, + int channelCount); ~NodeAudioPlayer() override; bool start() override; @@ -30,7 +33,12 @@ class NodeAudioPlayer final : public CommonPlayer { /// Signal the worker to exit and join it. Safe to call repeatedly. void terminateWorker(); + std::function renderAudio_; + std::shared_ptr buffer_; + float sampleRate_; + int channelCount_; std::atomic isInitialized_{false}; + std::atomic isRunning_{false}; std::atomic isPaused_{true}; std::atomic shouldStop_{false}; std::thread worker_;