From 77eb6179d294f1462727284cf5c5bc0e69668134 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 16 Aug 2026 13:22:52 -0500 Subject: [PATCH 1/3] feat: respect AudioContextOptions.latencyHint when opening the Android output stream AudioContext now accepts the Web Audio latencyHint category and the Android backend maps it to Oboe performance modes: interactive -> LowLatency (unchanged default), balanced -> None, playback -> PowerSaving. On web the hint is forwarded to the browser AudioContext; on iOS it is accepted but does not change the stream yet. Numeric hints are not supported yet. Motivation: the hard-coded LowLatency stream is granted a very small buffer (192 frames / 4 ms on an entry-level Samsung) and misses ~220 render deadlines per second when a multi-source graph renders, heard as continuous crackle. The same graph on a PerformanceMode::None stream plays clean. Measured via AudioStream::getXRunCount on device. --- .../audiodocs/docs/core/audio-context.mdx | 1 + .../cpp/audioapi/android/core/AudioPlayer.cpp | 24 ++++++++++++++++--- .../cpp/audioapi/android/core/AudioPlayer.h | 5 +++- .../cpp/audioapi/AudioAPIModuleInstaller.h | 16 +++++++++++-- .../HostObjects/AudioContextHostObject.cpp | 5 ++-- .../HostObjects/AudioContextHostObject.h | 4 +++- .../common/cpp/audioapi/core/AudioContext.cpp | 10 +++++--- .../common/cpp/audioapi/core/AudioContext.h | 5 +++- .../core/types/AudioContextLatencyHint.h | 12 ++++++++++ .../src/AudioAPIModule/globals.d.ts | 6 ++++- .../src/core/AudioContext.ts | 3 ++- packages/react-native-audio-api/src/types.ts | 13 ++++++++++ .../src/web-core/AudioContext.web.ts | 5 +++- 13 files changed, 93 insertions(+), 16 deletions(-) create mode 100644 packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioContextLatencyHint.h diff --git a/packages/audiodocs/docs/core/audio-context.mdx b/packages/audiodocs/docs/core/audio-context.mdx index 6e775e96b..2d8ef128f 100644 --- a/packages/audiodocs/docs/core/audio-context.mdx +++ b/packages/audiodocs/docs/core/audio-context.mdx @@ -21,6 +21,7 @@ constructor(options?: AudioContextOptions) | Parameter | Type | Default | | | :---: | :---: | :----: | :---- | | `sampleRate` | `number` | - | The preferred sample rate for the context. | +| `latencyHint` | `'interactive' \| 'balanced' \| 'playback'` | `'interactive'` | What the context should optimize its output stream for. `interactive` requests the lowest latency the platform offers; `balanced` and `playback` trade output latency for a deeper buffer, which favours glitch-free sustained playback (many simultaneous sources, low-end devices). On Android these map to Oboe's `LowLatency`, `None` and `PowerSaving` performance modes; on iOS the hint is currently accepted but does not change the stream; on web it is passed to the browser's `AudioContext`. Numeric hints are not supported yet. | #### 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..a80537e75 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 @@ -13,20 +13,38 @@ namespace audioapi { +namespace { + +PerformanceMode performanceModeFor(AudioContextLatencyHint latencyHint) { + switch (latencyHint) { + case AudioContextLatencyHint::BALANCED: + return PerformanceMode::None; + case AudioContextLatencyHint::PLAYBACK: + return PerformanceMode::PowerSaving; + case AudioContextLatencyHint::INTERACTIVE: + return PerformanceMode::LowLatency; + } + return PerformanceMode::LowLatency; +} + +} // namespace + AudioPlayer::AudioPlayer( const std::function &renderAudio, float sampleRate, int channelCount, std::mutex *driverMutex, const std::shared_ptr &context, - std::atomic ¤tRenders) + std::atomic ¤tRenders, + AudioContextLatencyHint latencyHint) : renderAudio_(renderAudio), currentRenders_(currentRenders), sampleRate_(sampleRate), channelCount_(channelCount), isRunning_(false), driverMutex_(driverMutex), - context_(context) {} + context_(context), + latencyHint_(latencyHint) {} bool AudioPlayer::openAudioStream() { std::scoped_lock lock(streamMutex_); @@ -35,7 +53,7 @@ bool AudioPlayer::openAudioStream() { builder.setSharingMode(SharingMode::Exclusive) ->setFormat(AudioFormat::Float) ->setFormatConversionAllowed(true) - ->setPerformanceMode(PerformanceMode::LowLatency) + ->setPerformanceMode(performanceModeFor(latencyHint_)) ->setChannelCount(channelCount_) ->setSampleRateConversionQuality(SampleRateConversionQuality::Medium) ->setFramesPerDataCallback(RENDER_QUANTUM_SIZE) 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..fe43d1cbb 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 @@ -10,6 +10,7 @@ #include #include +#include #include namespace audioapi { @@ -29,7 +30,8 @@ class AudioPlayer : public CommonPlayer, int channelCount, std::mutex *driverMutex, const std::shared_ptr &context, - std::atomic ¤tRenders); + std::atomic ¤tRenders, + AudioContextLatencyHint latencyHint = AudioContextLatencyHint::INTERACTIVE); ~AudioPlayer() override { cleanup(); @@ -67,6 +69,7 @@ class AudioPlayer : public CommonPlayer, std::atomic lastCallbackFrameCount_{0}; std::mutex *driverMutex_; std::weak_ptr context_; + AudioContextLatencyHint latencyHint_; 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 e4dc7cbce..5b6a68587 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h @@ -60,7 +60,7 @@ class AudioAPIModuleInstaller { return jsi::Function::createFromHostFunction( *jsiRuntime, jsi::PropNameID::forAscii(*jsiRuntime, "createAudioContext"), - 1, + 2, [jsCallInvoker, audioEventHandlerRegistry]( jsi::Runtime &runtime, const jsi::Value &thisValue, @@ -68,8 +68,20 @@ class AudioAPIModuleInstaller { size_t count) -> jsi::Value { auto sampleRate = static_cast(args[0].getNumber()); + // Unknown strings fall back to INTERACTIVE, matching how browsers + // treat an unrecognised latencyHint. + auto latencyHint = AudioContextLatencyHint::INTERACTIVE; + if (count > 1 && args[1].isString()) { + auto hint = args[1].getString(runtime).utf8(runtime); + if (hint == "balanced") { + latencyHint = AudioContextLatencyHint::BALANCED; + } else if (hint == "playback") { + latencyHint = AudioContextLatencyHint::PLAYBACK; + } + } + auto audioContextHostObject = std::make_shared( - sampleRate, audioEventHandlerRegistry, &runtime, jsCallInvoker); + sampleRate, audioEventHandlerRegistry, &runtime, jsCallInvoker, latencyHint); 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..73c682332 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 @@ -13,9 +13,10 @@ AudioContextHostObject::AudioContextHostObject( float sampleRate, const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, - const std::shared_ptr &callInvoker) + const std::shared_ptr &callInvoker, + AudioContextLatencyHint latencyHint) : BaseAudioContextHostObject( - std::make_shared(sampleRate, audioEventHandlerRegistry), + std::make_shared(sampleRate, audioEventHandlerRegistry, latencyHint), 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..4934ddf5c 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 @@ -17,7 +18,8 @@ class AudioContextHostObject : public BaseAudioContextHostObject { float sampleRate, const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, - const std::shared_ptr &callInvoker); + const std::shared_ptr &callInvoker, + AudioContextLatencyHint latencyHint = AudioContextLatencyHint::INTERACTIVE); JSI_HOST_FUNCTION_DECL(close); JSI_HOST_FUNCTION_DECL(resume); 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 fed66eb8a..8d88a8f39 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, - const std::shared_ptr &audioEventHandlerRegistry) - : BaseAudioContext(sampleRate, audioEventHandlerRegistry), isInitialized_(false) { + const std::shared_ptr &audioEventHandlerRegistry, + AudioContextLatencyHint latencyHint) + : BaseAudioContext(sampleRate, audioEventHandlerRegistry), + latencyHint_(latencyHint), + isInitialized_(false) { // Context starts SUSPENDED with no audio-thread consumer. Let the producer // drain Channel A itself until start()/resume() hands draining to the // audio callback (same pattern as OfflineAudioContext before rendering). @@ -44,7 +47,8 @@ void AudioContext::initialize(const AudioDestinationNode *destination) { destination_->getChannelCount(), &driverMutex_, std::static_pointer_cast(shared_from_this()), - currentRenders_); + currentRenders_, + latencyHint_); #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..1cf77e813 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 @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -15,7 +16,8 @@ class AudioContext : public BaseAudioContext { public: explicit AudioContext( float sampleRate, - const std::shared_ptr &audioEventHandlerRegistry); + const std::shared_ptr &audioEventHandlerRegistry, + AudioContextLatencyHint latencyHint = AudioContextLatencyHint::INTERACTIVE); ~AudioContext() override; DELETE_COPY_AND_MOVE(AudioContext); @@ -39,6 +41,7 @@ class AudioContext : public BaseAudioContext { private: std::shared_ptr audioPlayer_; + AudioContextLatencyHint latencyHint_; 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/types/AudioContextLatencyHint.h b/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioContextLatencyHint.h new file mode 100644 index 000000000..ecfc036e5 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioContextLatencyHint.h @@ -0,0 +1,12 @@ +#pragma once + +#include + +namespace audioapi { + +/// Web Audio's AudioContextOptions.latencyHint categories: what the context should +/// optimize its output stream for. Platform backends map these to their own stream +/// configuration; INTERACTIVE preserves the pre-hint behaviour and is the default. +enum class AudioContextLatencyHint : std::uint8_t { INTERACTIVE, BALANCED, PLAYBACK }; + +} // 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 655cde36b..6e762cc1c 100644 --- a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts +++ b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts @@ -7,10 +7,14 @@ import type { IAudioBuffer, IOfflineAudioContext, } from '../jsi-interfaces'; +import type { AudioContextLatencyCategory } from '../types'; /* eslint-disable no-var */ declare global { - var createAudioContext: (sampleRate: number) => IAudioContext; + var createAudioContext: ( + sampleRate: number, + latencyHint?: AudioContextLatencyCategory + ) => 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 8bf61ba42..6a01ad4f0 100644 --- a/packages/react-native-audio-api/src/core/AudioContext.ts +++ b/packages/react-native-audio-api/src/core/AudioContext.ts @@ -14,7 +14,8 @@ export default class AudioContext extends BaseAudioContext { super( globalThis.createAudioContext( - options?.sampleRate || AudioManager.getDevicePreferredSampleRate() + options?.sampleRate || AudioManager.getDevicePreferredSampleRate(), + options?.latencyHint ) ); } diff --git a/packages/react-native-audio-api/src/types.ts b/packages/react-native-audio-api/src/types.ts index 7afdae22c..c173f8e5c 100644 --- a/packages/react-native-audio-api/src/types.ts +++ b/packages/react-native-audio-api/src/types.ts @@ -45,8 +45,21 @@ export type OscillatorType = | 'triangle' | 'custom'; +export type AudioContextLatencyCategory = + | 'balanced' + | 'interactive' + | 'playback'; + export interface AudioContextOptions { sampleRate?: number; + /** + * What the context should optimize its output stream for. Defaults to + * `interactive` (the lowest latency the platform offers). `balanced` and + * `playback` trade output latency for a deeper buffer, which on Android moves + * multi-source playback off the underrun-prone low-latency path. Numeric + * hints are not supported yet. + */ + latencyHint?: AudioContextLatencyCategory; } export interface OfflineAudioContextOptions { 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 220b4a85d..fb7e62ed6 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 @@ -33,7 +33,10 @@ export default class AudioContext implements BaseAudioContext { assertSupportedSampleRate(options.sampleRate); } - this.context = new window.AudioContext({ sampleRate: options?.sampleRate }); + this.context = new window.AudioContext({ + sampleRate: options?.sampleRate, + latencyHint: options?.latencyHint, + }); this.sampleRate = this.context.sampleRate; this.destination = new AudioDestinationNode(this, this.context.destination); From 9790b7e9f59db67e2a3f2a3c4d55a24aed0c8737 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 17 Sep 2026 21:59:12 -0500 Subject: [PATCH 2/3] feat: map latencyHint to the iOS output buffer duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Android half of this PR had nothing to pair with on iOS because the backend never requested a buffer duration at all: playback ran at whatever AVAudioSession defaulted to for the category. iOS is the mirror image of the Android bug — there was no way to ask for LOW latency, so 'interactive' was unimplementable. An absent hint now stays absent rather than becoming INTERACTIVE, so each backend keeps the stream it opened before the hint existed. Android reads absence as LowLatency exactly as before; iOS leaves the session untouched, because dropping every existing playback app to a 2.7 ms buffer is how issue #1231 would arrive on iOS. An unrecognised string is treated the same way, where a browser throws a TypeError. 'interactive' asks for one render quantum and 'balanced' for four, in frames of the SESSION's rate rather than the context's — asking in seconds made 'interactive' request 256 frames on a 44.1 kHz context driving a 48 kHz output, and at low context rates asked for a buffer deeper than the default it was meant to shorten. 'playback' asks for nothing, the session default being deep already. The duration belongs to the process-wide session rather than to one stream, so live requests are reconciled by taking the shortest, and releasing the last one asks for what the session ran at beforehand. That baseline is snapped to the nearest power-of-two frame count because the session grants those and rounds a raw request down: asking for a 23 ms baseline as-is was granted 11 ms, and rounding up alone asked for 43 ms. Verified on an iPhone 17 simulator by reading back context.outputLatency: omitted 10.10 ms with no request made, 'interactive' 2.77 ms (128 frames granted) at both 48 kHz and 44.1 kHz, 'balanced' 10.77 ms (512 frames), and the deep buffer restored once the interactive context closed. Co-Authored-By: Claude Opus 5 --- .../audiodocs/docs/core/audio-context.mdx | 24 +++- .../cpp/audioapi/android/core/AudioPlayer.cpp | 10 +- .../cpp/audioapi/android/core/AudioPlayer.h | 5 +- .../cpp/audioapi/AudioAPIModuleInstaller.h | 15 +-- .../HostObjects/AudioContextHostObject.cpp | 2 +- .../HostObjects/AudioContextHostObject.h | 4 +- .../HostObjects/utils/JsEnumParser.cpp | 11 ++ .../audioapi/HostObjects/utils/JsEnumParser.h | 4 + .../common/cpp/audioapi/core/AudioContext.cpp | 5 +- .../common/cpp/audioapi/core/AudioContext.h | 5 +- .../core/types/AudioContextLatencyHint.h | 25 +++- .../src/core/AudioContextLatencyHintTest.cpp | 34 +++++ .../ios/audioapi/ios/core/IOSAudioPlayer.h | 5 +- .../ios/audioapi/ios/core/IOSAudioPlayer.mm | 11 +- .../ios/audioapi/ios/core/NativeAudioPlayer.h | 3 +- .../ios/audioapi/ios/core/NativeAudioPlayer.m | 95 ++++++++----- .../audioapi/ios/system/AudioSessionManager.h | 5 + .../ios/system/AudioSessionManager.mm | 127 +++++++++++++++++- packages/react-native-audio-api/src/types.ts | 7 +- .../tests/audio-context-latency-hint.test.ts | 107 +++++++++++++++ .../wpt_tests/src/jsi_install.cpp | 14 +- 21 files changed, 448 insertions(+), 70 deletions(-) create mode 100644 packages/react-native-audio-api/common/cpp/test/src/core/AudioContextLatencyHintTest.cpp create mode 100644 packages/react-native-audio-api/tests/audio-context-latency-hint.test.ts diff --git a/packages/audiodocs/docs/core/audio-context.mdx b/packages/audiodocs/docs/core/audio-context.mdx index 702080260..cfacffa28 100644 --- a/packages/audiodocs/docs/core/audio-context.mdx +++ b/packages/audiodocs/docs/core/audio-context.mdx @@ -21,7 +21,29 @@ constructor(options?: AudioContextOptions) | Parameter | Type | Default | | | :---: | :---: | :----: | :---- | | `sampleRate` | `number` | - | The preferred sample rate for the context. | -| `latencyHint` | `'interactive' \| 'balanced' \| 'playback'` | `'interactive'` | What the context should optimize its output stream for. `interactive` requests the lowest latency the platform offers; `balanced` and `playback` trade output latency for a deeper buffer, which favours glitch-free sustained playback (many simultaneous sources, low-end devices). On Android these map to Oboe's `LowLatency`, `None` and `PowerSaving` performance modes; on iOS the hint is currently accepted but does not change the stream; on web it is passed to the browser's `AudioContext`. Numeric hints are not supported yet. | +| `latencyHint` | `'interactive' \| 'balanced' \| 'playback'` | - | What the context should optimize its output stream for. `interactive` requests the lowest latency the platform offers; `balanced` and `playback` trade output latency for a deeper buffer, which favours glitch-free sustained playback (many simultaneous sources, low-end devices). See the table below for what each platform does with it. Numeric hints are not supported yet. | + +#### `latencyHint` per platform + +| `latencyHint` | Android (Oboe `PerformanceMode`) | iOS (`AVAudioSession.preferredIOBufferDuration`) | Web | +| :---: | :---- | :---- | :---- | +| omitted | `LowLatency` | not requested — the session default, a deep buffer | browser default | +| `'interactive'` | `LowLatency` | 128 frames | forwarded | +| `'balanced'` | `None` | 512 frames | forwarded | +| `'playback'` | `PowerSaving` | not requested — the session default is already deep | forwarded | + +Omitting the hint is deliberately not the same as passing `'interactive'`: each platform keeps the +stream it opened before the hint existed, so adding the option cannot change how an existing app +sounds. An unrecognized string is likewise treated as no hint on Android and iOS, where a browser +would throw a `TypeError`. + +On iOS the buffer duration belongs to the process-wide `AVAudioSession` rather than to one stream, +so concurrent contexts cannot each get their own. The shortest duration any live context asked for +wins, which makes `'balanced'` and `'playback'` best-effort: either loses to a concurrent +`'interactive'`. Releasing the last request asks for the duration the session ran at beforehand, +approximately — the session grants power-of-two frame counts, and a preference can only be +overwritten, never unset. Every value is a request the route may refuse; `outputLatency` reports +what the session grants, which a running engine may not have adopted yet. #### 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 a80537e75..0a2bb18a7 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 @@ -15,14 +15,14 @@ namespace audioapi { namespace { -PerformanceMode performanceModeFor(AudioContextLatencyHint latencyHint) { - switch (latencyHint) { +PerformanceMode performanceModeFor(std::optional latencyHint) { + switch (latencyHint.value_or(AudioContextLatencyHint::INTERACTIVE)) { + case AudioContextLatencyHint::INTERACTIVE: + return PerformanceMode::LowLatency; case AudioContextLatencyHint::BALANCED: return PerformanceMode::None; case AudioContextLatencyHint::PLAYBACK: return PerformanceMode::PowerSaving; - case AudioContextLatencyHint::INTERACTIVE: - return PerformanceMode::LowLatency; } return PerformanceMode::LowLatency; } @@ -36,7 +36,7 @@ AudioPlayer::AudioPlayer( std::mutex *driverMutex, const std::shared_ptr &context, std::atomic ¤tRenders, - AudioContextLatencyHint latencyHint) + std::optional latencyHint) : renderAudio_(renderAudio), currentRenders_(currentRenders), sampleRate_(sampleRate), 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 fe43d1cbb..7cb946fdf 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 @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -31,7 +32,7 @@ class AudioPlayer : public CommonPlayer, std::mutex *driverMutex, const std::shared_ptr &context, std::atomic ¤tRenders, - AudioContextLatencyHint latencyHint = AudioContextLatencyHint::INTERACTIVE); + std::optional latencyHint = std::nullopt); ~AudioPlayer() override { cleanup(); @@ -69,7 +70,7 @@ class AudioPlayer : public CommonPlayer, std::atomic lastCallbackFrameCount_{0}; std::mutex *driverMutex_; std::weak_ptr context_; - AudioContextLatencyHint latencyHint_; + std::optional latencyHint_; 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 1d0bd0e89..b98c0cbcd 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -17,6 +18,8 @@ #include #include +#include +#include #include namespace audioapi { @@ -71,16 +74,10 @@ class AudioAPIModuleInstaller { size_t count) -> jsi::Value { auto sampleRate = static_cast(args[0].getNumber()); - // Unknown strings fall back to INTERACTIVE, matching how browsers - // treat an unrecognised latencyHint. - auto latencyHint = AudioContextLatencyHint::INTERACTIVE; + std::optional latencyHint; if (count > 1 && args[1].isString()) { - auto hint = args[1].getString(runtime).utf8(runtime); - if (hint == "balanced") { - latencyHint = AudioContextLatencyHint::BALANCED; - } else if (hint == "playback") { - latencyHint = AudioContextLatencyHint::PLAYBACK; - } + latencyHint = + js_enum_parser::latencyHintFromString(args[1].getString(runtime).utf8(runtime)); } auto audioContextHostObject = std::make_shared( 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 73c682332..94a7a1848 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 @@ -14,7 +14,7 @@ AudioContextHostObject::AudioContextHostObject( const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, const std::shared_ptr &callInvoker, - AudioContextLatencyHint latencyHint) + std::optional latencyHint) : BaseAudioContextHostObject( std::make_shared(sampleRate, audioEventHandlerRegistry, latencyHint), runtime, 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 4934ddf5c..376011e70 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 @@ -5,7 +5,9 @@ #include #include + #include +#include namespace audioapi { using namespace facebook; @@ -19,7 +21,7 @@ class AudioContextHostObject : public BaseAudioContextHostObject { const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, const std::shared_ptr &callInvoker, - AudioContextLatencyHint latencyHint = AudioContextLatencyHint::INTERACTIVE); + std::optional latencyHint = std::nullopt); JSI_HOST_FUNCTION_DECL(close); JSI_HOST_FUNCTION_DECL(resume); 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 0e481a80b..b7dcb7e6b 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 @@ -49,6 +49,17 @@ std::string filterTypeToString(BiquadFilterType type) { } } +std::optional latencyHintFromString(const std::string &hint) { + if (hint == "interactive") + return AudioContextLatencyHint::INTERACTIVE; + if (hint == "balanced") + return AudioContextLatencyHint::BALANCED; + if (hint == "playback") + return AudioContextLatencyHint::PLAYBACK; + + return std::nullopt; +} + OverSampleType overSampleTypeFromString(const std::string &type) { if (type == "2x") return OverSampleType::OVERSAMPLE_2X; 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 9d35821ce..002067af9 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 @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -8,6 +9,7 @@ #include #include #include +#include #include namespace audioapi::js_enum_parser { @@ -24,4 +26,6 @@ ChannelCountMode channelCountModeFromString(const std::string &mode); std::string channelInterpretationToString(ChannelInterpretation interpretation); ChannelInterpretation channelInterpretationFromString(const std::string &interpretation); std::string contextStateToString(ContextState state); +/// Empty for an unrecognised string, where a browser would throw a TypeError. +std::optional latencyHintFromString(const std::string &hint); } // namespace audioapi::js_enum_parser 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 7b6d910f9..8763f12d3 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 @@ -16,7 +16,7 @@ namespace audioapi { AudioContext::AudioContext( float sampleRate, const std::shared_ptr &audioEventHandlerRegistry, - AudioContextLatencyHint latencyHint) + std::optional latencyHint) : BaseAudioContext(sampleRate, audioEventHandlerRegistry), latencyHint_(latencyHint), isInitialized_(false) { @@ -55,7 +55,8 @@ void AudioContext::initialize(const AudioDestinationNode *destination) { [this](DSPAudioBuffer *buf, int n) { processGraph(buf, n); }, getSampleRate(), destination_->getChannelCount(), - currentRenders_); + currentRenders_, + latencyHint_); #endif } 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 1cf77e813..f102ba5a8 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 @@ -9,6 +9,7 @@ #include #include +#include namespace audioapi { @@ -17,7 +18,7 @@ class AudioContext : public BaseAudioContext { explicit AudioContext( float sampleRate, const std::shared_ptr &audioEventHandlerRegistry, - AudioContextLatencyHint latencyHint = AudioContextLatencyHint::INTERACTIVE); + std::optional latencyHint = std::nullopt); ~AudioContext() override; DELETE_COPY_AND_MOVE(AudioContext); @@ -41,7 +42,7 @@ class AudioContext : public BaseAudioContext { private: std::shared_ptr audioPlayer_; - AudioContextLatencyHint latencyHint_; + std::optional latencyHint_; 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/types/AudioContextLatencyHint.h b/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioContextLatencyHint.h index ecfc036e5..d09c1b988 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioContextLatencyHint.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioContextLatencyHint.h @@ -1,12 +1,31 @@ #pragma once +#include + #include +#include namespace audioapi { -/// Web Audio's AudioContextOptions.latencyHint categories: what the context should -/// optimize its output stream for. Platform backends map these to their own stream -/// configuration; INTERACTIVE preserves the pre-hint behaviour and is the default. enum class AudioContextLatencyHint : std::uint8_t { INTERACTIVE, BALANCED, PLAYBACK }; +/// Output buffer this hint asks for, in frames of the output stream's own rate — not the +/// context's, which may differ. 0 requests nothing. +inline int preferredIOBufferFramesFor(std::optional latencyHint) { + if (!latencyHint.has_value()) { + return 0; + } + + switch (*latencyHint) { + case AudioContextLatencyHint::INTERACTIVE: + return RENDER_QUANTUM_SIZE; + case AudioContextLatencyHint::BALANCED: + return 4 * RENDER_QUANTUM_SIZE; + case AudioContextLatencyHint::PLAYBACK: + return 0; + } + + return 0; +} + } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/AudioContextLatencyHintTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/AudioContextLatencyHintTest.cpp new file mode 100644 index 000000000..9151094b5 --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/core/AudioContextLatencyHintTest.cpp @@ -0,0 +1,34 @@ +#include +#include +#include +#include + +using namespace audioapi; + +// NOLINTBEGIN + +TEST(AudioContextLatencyHintTest, ParsesTheThreeCategories) { + EXPECT_EQ( + js_enum_parser::latencyHintFromString("interactive"), AudioContextLatencyHint::INTERACTIVE); + EXPECT_EQ(js_enum_parser::latencyHintFromString("balanced"), AudioContextLatencyHint::BALANCED); + EXPECT_EQ(js_enum_parser::latencyHintFromString("playback"), AudioContextLatencyHint::PLAYBACK); +} + +TEST(AudioContextLatencyHintTest, TreatsAnUnrecognisedStringAsNoHint) { + EXPECT_EQ(js_enum_parser::latencyHintFromString(""), std::nullopt); + EXPECT_EQ(js_enum_parser::latencyHintFromString("foo"), std::nullopt); + EXPECT_EQ(js_enum_parser::latencyHintFromString("INTERACTIVE"), std::nullopt); + EXPECT_EQ(js_enum_parser::latencyHintFromString("0.01"), std::nullopt); +} + +TEST(AudioContextLatencyHintTest, RequestsNothingWithoutAHintOrForPlayback) { + EXPECT_EQ(preferredIOBufferFramesFor(std::nullopt), 0); + EXPECT_EQ(preferredIOBufferFramesFor(AudioContextLatencyHint::PLAYBACK), 0); +} + +TEST(AudioContextLatencyHintTest, RequestsOneRenderQuantumForInteractiveAndFourForBalanced) { + EXPECT_EQ(preferredIOBufferFramesFor(AudioContextLatencyHint::INTERACTIVE), 128); + EXPECT_EQ(preferredIOBufferFramesFor(AudioContextLatencyHint::BALANCED), 512); +} + +// NOLINTEND 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..e4ef1ea55 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 @@ -8,12 +8,14 @@ typedef struct objc_object AudioBufferList; #endif // __OBJC__ #include +#include #include #include #include #include #include +#include namespace audioapi { class AudioContext; @@ -24,7 +26,8 @@ class IOSAudioPlayer : public CommonPlayer { const std::function &renderAudio, float sampleRate, int channelCount, - std::atomic ¤tRenders); + std::atomic ¤tRenders, + std::optional latencyHint = std::nullopt); ~IOSAudioPlayer() override; DELETE_COPY_AND_MOVE(IOSAudioPlayer); 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..25b543c9f 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 @@ -17,7 +17,8 @@ const std::function &renderAudio, float sampleRate, int channelCount, - std::atomic ¤tRenders) + std::atomic ¤tRenders, + std::optional latencyHint) : audioBuffer_(nullptr), audioPlayer_(nullptr), renderAudio_(renderAudio), @@ -31,9 +32,11 @@ deliverOutputBuffers(outputData, numFrames); }; - audioPlayer_ = [[NativeAudioPlayer alloc] initWithRenderAudio:renderAudioBlock - sampleRate:sampleRate - channelCount:channelCount_]; + audioPlayer_ = + [[NativeAudioPlayer alloc] initWithRenderAudio:renderAudioBlock + sampleRate:sampleRate + channelCount:channelCount_ + preferredIOBufferFrames:preferredIOBufferFramesFor(latencyHint)]; audioBuffer_ = std::make_shared(RENDER_QUANTUM_SIZE, channelCount_, sampleRate); } diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.h b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.h index 31b283a6d..4796e5826 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.h @@ -15,7 +15,8 @@ typedef void (^RenderAudioBlock)(AudioBufferList *outputBuffer, int numFrames); - (instancetype)initWithRenderAudio:(RenderAudioBlock)renderAudio sampleRate:(float)sampleRate - channelCount:(int)channelCount; + channelCount:(int)channelCount + preferredIOBufferFrames:(int)preferredIOBufferFrames; - (bool)start; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m index d5292e417..cd7184d96 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m @@ -2,6 +2,13 @@ #import #import +@interface NativeAudioPlayer () { + int _preferredIOBufferFrames; + /// nil unless this player asked for a buffer duration. + NSString *_ioBufferClientId; +} +@end + @implementation NativeAudioPlayer - (void)detachSourceNodeIfAttached:(AudioEngine *)audioEngine @@ -32,12 +39,62 @@ - (bool)startPlaybackGraph:(AudioEngine *)audioEngine return [audioEngine startIfNecessary]; } +- (void)requestPreferredIOBuffer +{ + [[AudioSessionManager sharedInstance] requestIOBufferFrames:_preferredIOBufferFrames + forClient:_ioBufferClientId]; +} + +- (void)releasePreferredIOBuffer +{ + [[AudioSessionManager sharedInstance] releaseIOBufferFramesForClient:_ioBufferClientId]; +} + +- (bool)activateSessionAndStart:(NSString *)activationAction +{ + AudioEngine *audioEngine = [AudioEngine sharedInstance]; + AudioSessionManager *sessionManager = [AudioSessionManager sharedInstance]; + assert(audioEngine != nil); + + // Before activation and the engine start below: AVAudioEngine only adopts a new buffer size + // when it starts. + [self requestPreferredIOBuffer]; + + NSError *error = nil; + if (![sessionManager ensureActive:false error:&error]) { + NSLog( + @"Error while %@ audio session for playback: %@", + activationAction, + [error debugDescription]); + [self releasePreferredIOBuffer]; + return false; + } + + // AudioEngine allows us to attach and connect nodes at runtime but with few + // limitations in this case if it is the first player and recorder started the + // engine we need to restart. It can be optimized by tracking if we haven't + // break rules of at runtime modifications from docs + // https://developer.apple.com/documentation/avfaudio/avaudioengine?language=objc + // + // Currently we are restarting because we do not see any significant performance issue and case when + // you will need to start and stop player very frequently + if (![self startPlaybackGraph:audioEngine]) { + [self releasePreferredIOBuffer]; + return false; + } + + return true; +} + - (instancetype)initWithRenderAudio:(RenderAudioBlock)renderAudio sampleRate:(float)sampleRate channelCount:(int)channelCount + preferredIOBufferFrames:(int)preferredIOBufferFrames { if (self = [super init]) { self.sampleRate = sampleRate; + _preferredIOBufferFrames = preferredIOBufferFrames; + _ioBufferClientId = preferredIOBufferFrames > 0 ? [[NSUUID UUID] UUIDString] : nil; self.channelCount = channelCount; self.renderAudio = [renderAudio copy]; @@ -63,29 +120,13 @@ - (instancetype)initWithRenderAudio:(RenderAudioBlock)renderAudio - (bool)start { - AudioEngine *audioEngine = [AudioEngine sharedInstance]; - AudioSessionManager *sessionManager = [AudioSessionManager sharedInstance]; - assert(audioEngine != nil); - - NSError *error = nil; - if (![sessionManager ensureActive:false error:&error]) { - NSLog(@"Error while activating audio session for playback: %@", [error debugDescription]); - return false; - } - - // AudioEngine allows us to attach and connect nodes at runtime but with few - // limitations in this case if it is the first player and recorder started the - // engine we need to restart. It can be optimized by tracking if we haven't - // break rules of at runtime modifications from docs - // https://developer.apple.com/documentation/avfaudio/avaudioengine?language=objc - // - // Currently we are restarting because we do not see any significant performance issue and case when - // you will need to start and stop player very frequently - return [self startPlaybackGraph:audioEngine]; + return [self activateSessionAndStart:@"activating"]; } - (void)stop { + [self releasePreferredIOBuffer]; + AudioEngine *audioEngine = [AudioEngine sharedInstance]; if (audioEngine != nil) { [self detachSourceNodeIfAttached:audioEngine]; @@ -95,21 +136,13 @@ - (void)stop - (bool)resume { - AudioEngine *audioEngine = [AudioEngine sharedInstance]; - AudioSessionManager *sessionManager = [AudioSessionManager sharedInstance]; - assert(audioEngine != nil); - - NSError *error = nil; - if (![sessionManager ensureActive:false error:&error]) { - NSLog(@"Error while re-activating audio session for playback: %@", [error debugDescription]); - return false; - } - - return [self startPlaybackGraph:audioEngine]; + return [self activateSessionAndStart:@"re-activating"]; } - (void)suspend { + [self releasePreferredIOBuffer]; + AudioEngine *audioEngine = [AudioEngine sharedInstance]; assert(audioEngine != nil); @@ -119,6 +152,8 @@ - (void)suspend - (void)cleanup { + [self releasePreferredIOBuffer]; + self.renderAudio = nil; self.renderBlock = nil; } diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.h b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.h index 77af2b55c..84056507c 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.h @@ -39,6 +39,11 @@ /// Switches the manager into external owner mode and stops all session mutations. - (void)disableSessionManagement; +/// One IO buffer duration serves the whole process, so live requests are reconciled by taking +/// the shortest; releasing the last asks for the duration the session ran at beforehand. +- (void)requestIOBufferFrames:(int)frames forClient:(NSString *)clientId; +- (void)releaseIOBufferFramesForClient:(NSString *)clientId; + - (NSNumber *)getDevicePreferredSampleRate; - (NSNumber *)getSystemVolume; - (NSString *)inputDiagnosticsSnapshot; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.mm index f825a06da..8898345fd 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.mm @@ -4,6 +4,10 @@ @interface AudioSessionManager () +@property (nonatomic, strong) NSMutableDictionary *ioBufferFramesRequests; +/// What the session ran at before the first request, to ask for after the last release. +@property (nonatomic, assign) double baselineIOBufferDuration; + - (id)microphoneUsageDescriptionValue; - (bool)usesAudioApplicationRecordPermissionAPI; - (void)requestSystemRecordPermission:(void (^)(BOOL granted))completion; @@ -21,6 +25,20 @@ - (NSString *)formatPorts:(NSArray *)ports; RNAudioSessionCategoryOptionBluetoothHighQualityRecordingMask = 1 << 19; static const AVAudioSessionCategoryOptions RNAudioSessionCategoryOptionFarFieldInputMask = 1 << 18; +static const uint32_t kMaxIOBufferFrames = 1u << 16; + +static uint32_t nearestPowerOfTwoFrames(double frames) +{ + uint32_t above = 1; + while (above < frames && above < kMaxIOBufferFrames) { + above <<= 1; + } + + uint32_t below = above > 1 ? above >> 1 : 1; + + return (frames - below <= above - frames) ? below : above; +} + @implementation AudioSessionManager static AudioSessionManager *_sharedInstance = nil; @@ -38,6 +56,9 @@ - (instancetype)init self.desiredOptions = 0; self.allowHapticsAndSounds = false; self.notifyOthersOnDeactivation = true; + + self.ioBufferFramesRequests = [[NSMutableDictionary alloc] init]; + self.baselineIOBufferDuration = 0.0; } _sharedInstance = self; @@ -51,6 +72,14 @@ + (instancetype)sharedInstance - (void)cleanup { + @synchronized(self) { + [self.ioBufferFramesRequests removeAllObjects]; + } + + // The preference outlives this manager, and the next one installed would read a shortened + // value as its own baseline. + [self applyPreferredIOBufferDuration]; + self.audioSession = nil; } @@ -63,12 +92,88 @@ - (bool)areDesiredOptionsSet self.audioSession.allowHapticsAndSystemSoundsDuringRecording == self.allowHapticsAndSounds); } -- (bool)configureAudioSession:(NSError **)outError +- (void)applyPreferredIOBufferDuration { - if (!self.shouldManageSession || [self areDesiredOptionsSet]) { - return true; + if (!self.shouldManageSession) { + return; + } + + double requested = 0.0; + double granted = 0.0; + + @synchronized(self) { + int frames = 0; + for (NSNumber *clientFrames in self.ioBufferFramesRequests.objectEnumerator) { + int clientRequest = [clientFrames intValue]; + if (frames == 0 || clientRequest < frames) { + frames = clientRequest; + } + } + + if (frames == 0 && self.baselineIOBufferDuration <= 0.0) { + return; + } + + double sampleRate = self.audioSession.sampleRate; + if (sampleRate <= 0.0) { + return; + } + + // A preference can only be overwritten, never unset. + if (self.baselineIOBufferDuration <= 0.0) { + self.baselineIOBufferDuration = self.audioSession.IOBufferDuration; + } + + // The session grants a power-of-two frame count, so the baseline cannot be asked for exactly. + requested = frames > 0 + ? frames / sampleRate + : nearestPowerOfTwoFrames(self.baselineIOBufferDuration * sampleRate) / sampleRate; + + NSError *error = nil; + if (![self.audioSession setPreferredIOBufferDuration:requested error:&error]) { + // A refused duration still leaves playback running at whatever the session grants. + NSLog( + @"[AudioSessionManager] Error while requesting IO buffer duration %f s: %@", + requested, + [error debugDescription]); + return; + } + + granted = self.audioSession.IOBufferDuration; + } + + NSLog( + @"[AudioSessionManager] Requested IO buffer duration: %f s, session reports %f s", + requested, + granted); +} + +- (void)requestIOBufferFrames:(int)frames forClient:(NSString *)clientId +{ + if (clientId == nil || frames <= 0) { + return; + } + + @synchronized(self) { + self.ioBufferFramesRequests[clientId] = @(frames); + [self applyPreferredIOBufferDuration]; + } +} + +- (void)releaseIOBufferFramesForClient:(NSString *)clientId +{ + @synchronized(self) { + if (clientId == nil || self.ioBufferFramesRequests[clientId] == nil) { + return; + } + + [self.ioBufferFramesRequests removeObjectForKey:clientId]; + [self applyPreferredIOBufferDuration]; } +} +- (bool)configureCategory:(NSError **)outError +{ NSError *categoryError = nil; [self.audioSession setCategory:self.desiredCategory mode:self.desiredMode @@ -110,6 +215,22 @@ - (bool)configureAudioSession:(NSError **)outError return true; } +- (bool)configureAudioSession:(NSError **)outError +{ + if (!self.shouldManageSession) { + return true; + } + + if (![self areDesiredOptionsSet] && ![self configureCategory:outError]) { + return false; + } + + // After the category, which decides the duration the session defaults to. + [self applyPreferredIOBufferDuration]; + + return true; +} + - (void)setAudioSessionOptions:(NSString *)categoryStr mode:(NSString *)modeStr options:(NSArray *)optionsArray diff --git a/packages/react-native-audio-api/src/types.ts b/packages/react-native-audio-api/src/types.ts index 2f4016b6a..e1f7c6915 100644 --- a/packages/react-native-audio-api/src/types.ts +++ b/packages/react-native-audio-api/src/types.ts @@ -53,10 +53,9 @@ export type AudioContextLatencyCategory = export interface AudioContextOptions { sampleRate?: number; /** - * What the context should optimize its output stream for. Defaults to - * `interactive` (the lowest latency the platform offers). `balanced` and - * `playback` trade output latency for a deeper buffer, which on Android moves - * multi-source playback off the underrun-prone low-latency path. Numeric + * What the context should optimize its output stream for. Omitting it is not + * the same as `'interactive'`: each platform keeps the stream it opened + * before this option existed. See the platform table in the docs. Numeric * hints are not supported yet. */ latencyHint?: AudioContextLatencyCategory; diff --git a/packages/react-native-audio-api/tests/audio-context-latency-hint.test.ts b/packages/react-native-audio-api/tests/audio-context-latency-hint.test.ts new file mode 100644 index 000000000..b7f3662f8 --- /dev/null +++ b/packages/react-native-audio-api/tests/audio-context-latency-hint.test.ts @@ -0,0 +1,107 @@ +import AudioContext from '../src/core/AudioContext'; +import WebAudioContext from '../src/web-core/AudioContext.web'; +import type { IAudioEventEmitter } from '../src/jsi-interfaces'; +import type { AudioContextLatencyCategory } from '../src/types'; + +jest.mock('react-native', () => ({ + Image: { resolveAssetSource: jest.fn() }, + Platform: { OS: 'ios' }, + TurboModuleRegistry: { get: jest.fn(() => ({ install: jest.fn() })) }, +})); + +jest.mock('../src/system', () => ({ + __esModule: true, + default: { getDevicePreferredSampleRate: () => 48000 }, +})); + +jest.mock('../src/core/AudioListener', () => ({ + __esModule: true, + default: class AudioListenerStub {}, +})); + +jest.mock('../src/web-core/AudioListener.web', () => ({ + __esModule: true, + default: class WebAudioListenerStub {}, +})); + +jest.mock('../src/web-core/AudioDestinationNode.web', () => ({ + __esModule: true, + default: class WebAudioDestinationNodeStub {}, +})); + +describe('AudioContext latencyHint on web', () => { + const windowAudioContext = jest.fn(); + + beforeEach(() => { + windowAudioContext.mockReset(); + (globalThis as { window?: unknown }).window = { + AudioContext: windowAudioContext, + }; + }); + + const optionsPassedToBrowser = (options?: { + latencyHint?: AudioContextLatencyCategory; + }) => { + new WebAudioContext(options); + return windowAudioContext.mock.calls[0][0]; + }; + + it('forwards the category to the browser', () => { + expect(optionsPassedToBrowser({ latencyHint: 'playback' }).latencyHint).toBe( + 'playback' + ); + }); + + it('forwards no category when the caller gave none', () => { + expect(optionsPassedToBrowser().latencyHint).toBeUndefined(); + }); +}); + +describe('AudioContext latencyHint', () => { + const createAudioContext = jest.fn(); + + beforeEach(() => { + createAudioContext.mockReset(); + createAudioContext.mockReturnValue({ + destination: {}, + listener: {}, + sampleRate: 48000, + }); + + globalThis.createAudioContext = + createAudioContext as unknown as typeof globalThis.createAudioContext; + globalThis.AudioEventEmitter = { + addAudioEventListener: () => 'subscription', + removeAudioEventListener: () => undefined, + } as unknown as IAudioEventEmitter; + }); + + const argsPassedToNative = (options?: { + sampleRate?: number; + latencyHint?: AudioContextLatencyCategory; + }) => { + new AudioContext(options); + return createAudioContext.mock.calls[0]; + }; + + it.each(['interactive', 'balanced', 'playback'])( + 'forwards the %s category', + (latencyHint) => { + expect(argsPassedToNative({ latencyHint })[1]).toBe(latencyHint); + } + ); + + it('forwards no hint when the caller gave none', () => { + expect(argsPassedToNative()[1]).toBeUndefined(); + }); + + it('keeps the sample rate as the first argument alongside a hint', () => { + const [sampleRate, latencyHint] = argsPassedToNative({ + sampleRate: 44100, + latencyHint: 'balanced', + }); + + expect(sampleRate).toBe(44100); + expect(latencyHint).toBe('balanced'); + }); +}); 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..408ae86a0 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 @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include #include #include @@ -15,6 +17,7 @@ #include #include +#include #include #include @@ -23,6 +26,7 @@ namespace { using audioapi::AudioBuffer; using audioapi::AudioBufferHostObject; using audioapi::AudioContextHostObject; +using audioapi::AudioContextLatencyHint; using audioapi::AudioEventHandlerRegistry; using audioapi::AudioEventHandlerRegistryHostObject; using audioapi::IAudioEventHandlerRegistry; @@ -201,11 +205,19 @@ void installAudioContextBinding( } const auto sampleRate = static_cast(args[0].getNumber()); + + std::optional latencyHint; + if (count > 1 && args[1].isString()) { + latencyHint = audioapi::js_enum_parser::latencyHintFromString( + args[1].getString(rt).utf8(rt)); + } + auto hostObject = std::make_shared( sampleRate, eventRegistry, &rt, - callInvoker); + callInvoker, + latencyHint); return makeContextObject(rt, hostObject); }); From a064095e7c64908af175b4a014723ccf52d9bd09 Mon Sep 17 00:00:00 2001 From: michal Date: Mon, 28 Sep 2026 18:37:41 +0200 Subject: [PATCH 3/3] feat: changes after review --- apps/common-app/src/singletons/index.ts | 2 +- .../audiodocs/docs/core/audio-context.mdx | 20 ++-- .../cpp/audioapi/android/core/AudioPlayer.cpp | 10 +- .../cpp/audioapi/android/core/AudioPlayer.h | 5 +- .../cpp/audioapi/AudioAPIModuleInstaller.h | 4 +- .../HostObjects/AudioContextHostObject.cpp | 2 +- .../HostObjects/AudioContextHostObject.h | 3 +- .../HostObjects/utils/JsEnumParser.cpp | 6 +- .../audioapi/HostObjects/utils/JsEnumParser.h | 5 +- .../common/cpp/audioapi/core/AudioContext.cpp | 2 +- .../common/cpp/audioapi/core/AudioContext.h | 5 +- .../core/types/AudioContextLatencyHint.h | 22 ---- .../src/core/AudioContextLatencyHintTest.cpp | 34 ------ .../ios/audioapi/ios/core/IOSAudioPlayer.h | 3 +- .../ios/audioapi/ios/core/IOSAudioPlayer.mm | 22 +++- .../ios/audioapi/ios/core/NativeAudioPlayer.m | 76 +++++------- .../audioapi/ios/system/AudioSessionManager.h | 4 +- .../ios/system/AudioSessionManager.mm | 113 ++++-------------- packages/react-native-audio-api/src/types.ts | 7 +- .../wpt_tests/src/jsi_install.cpp | 3 +- 20 files changed, 107 insertions(+), 241 deletions(-) delete mode 100644 packages/react-native-audio-api/common/cpp/test/src/core/AudioContextLatencyHintTest.cpp diff --git a/apps/common-app/src/singletons/index.ts b/apps/common-app/src/singletons/index.ts index 191a5d20a..727cc909b 100644 --- a/apps/common-app/src/singletons/index.ts +++ b/apps/common-app/src/singletons/index.ts @@ -3,5 +3,5 @@ import { AudioContext, AudioRecorder } from 'react-native-audio-api'; export const audioContext = new AudioContext(); export const audioRecorder = new AudioRecorder({ androidInputPreset: 'voiceCommunication', - iosVoiceProcessing: true, + iosVoiceProcessing: false, }); diff --git a/packages/audiodocs/docs/core/audio-context.mdx b/packages/audiodocs/docs/core/audio-context.mdx index cfacffa28..44637d98a 100644 --- a/packages/audiodocs/docs/core/audio-context.mdx +++ b/packages/audiodocs/docs/core/audio-context.mdx @@ -21,29 +21,25 @@ constructor(options?: AudioContextOptions) | Parameter | Type | Default | | | :---: | :---: | :----: | :---- | | `sampleRate` | `number` | - | The preferred sample rate for the context. | -| `latencyHint` | `'interactive' \| 'balanced' \| 'playback'` | - | What the context should optimize its output stream for. `interactive` requests the lowest latency the platform offers; `balanced` and `playback` trade output latency for a deeper buffer, which favours glitch-free sustained playback (many simultaneous sources, low-end devices). See the table below for what each platform does with it. Numeric hints are not supported yet. | +| `latencyHint` | `'interactive' \| 'balanced' \| 'playback'` | `'interactive'` | What the context should optimize its output stream for. `interactive` requests the lowest latency the platform offers; `balanced` and `playback` trade output latency for a deeper buffer, which favours glitch-free sustained playback (many simultaneous sources, low-end devices). See the table below for what each platform does with it. Numeric hints are not supported yet. | #### `latencyHint` per platform | `latencyHint` | Android (Oboe `PerformanceMode`) | iOS (`AVAudioSession.preferredIOBufferDuration`) | Web | | :---: | :---- | :---- | :---- | -| omitted | `LowLatency` | not requested — the session default, a deep buffer | browser default | | `'interactive'` | `LowLatency` | 128 frames | forwarded | -| `'balanced'` | `None` | 512 frames | forwarded | -| `'playback'` | `PowerSaving` | not requested — the session default is already deep | forwarded | +| `'balanced'` | `None` | 1024 frames | forwarded | +| `'playback'` | `PowerSaving` | 4096 frames | forwarded | -Omitting the hint is deliberately not the same as passing `'interactive'`: each platform keeps the -stream it opened before the hint existed, so adding the option cannot change how an existing app -sounds. An unrecognized string is likewise treated as no hint on Android and iOS, where a browser -would throw a `TypeError`. +As in the Web Audio API, omitting the hint means `'interactive'`. An unrecognized string is +treated as `'interactive'` too on Android and iOS, where a browser would throw a `TypeError`. On iOS the buffer duration belongs to the process-wide `AVAudioSession` rather than to one stream, so concurrent contexts cannot each get their own. The shortest duration any live context asked for wins, which makes `'balanced'` and `'playback'` best-effort: either loses to a concurrent -`'interactive'`. Releasing the last request asks for the duration the session ran at beforehand, -approximately — the session grants power-of-two frame counts, and a preference can only be -overwritten, never unset. Every value is a request the route may refuse; `outputLatency` reports -what the session grants, which a running engine may not have adopted yet. +`'interactive'`. Releasing the last request asks for 128 frames again. Every +value is a request the route may refuse; `outputLatency` reports what the session grants, which a +running engine may not have adopted yet. #### 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 0a2bb18a7..5db78efa9 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 @@ -15,8 +15,8 @@ namespace audioapi { namespace { -PerformanceMode performanceModeFor(std::optional latencyHint) { - switch (latencyHint.value_or(AudioContextLatencyHint::INTERACTIVE)) { +PerformanceMode performanceModeFor(AudioContextLatencyHint latencyHint) { + switch (latencyHint) { case AudioContextLatencyHint::INTERACTIVE: return PerformanceMode::LowLatency; case AudioContextLatencyHint::BALANCED: @@ -36,7 +36,7 @@ AudioPlayer::AudioPlayer( std::mutex *driverMutex, const std::shared_ptr &context, std::atomic ¤tRenders, - std::optional latencyHint) + AudioContextLatencyHint latencyHint) : renderAudio_(renderAudio), currentRenders_(currentRenders), sampleRate_(sampleRate), @@ -44,7 +44,7 @@ AudioPlayer::AudioPlayer( isRunning_(false), driverMutex_(driverMutex), context_(context), - latencyHint_(latencyHint) {} + performanceMode_(performanceModeFor(latencyHint)) {} bool AudioPlayer::openAudioStream() { std::scoped_lock lock(streamMutex_); @@ -53,7 +53,7 @@ bool AudioPlayer::openAudioStream() { builder.setSharingMode(SharingMode::Exclusive) ->setFormat(AudioFormat::Float) ->setFormatConversionAllowed(true) - ->setPerformanceMode(performanceModeFor(latencyHint_)) + ->setPerformanceMode(performanceMode_) ->setChannelCount(channelCount_) ->setSampleRateConversionQuality(SampleRateConversionQuality::Medium) ->setFramesPerDataCallback(RENDER_QUANTUM_SIZE) 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 7cb946fdf..7fe6dc7f4 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 @@ -8,7 +8,6 @@ #include #include #include -#include #include #include @@ -32,7 +31,7 @@ class AudioPlayer : public CommonPlayer, std::mutex *driverMutex, const std::shared_ptr &context, std::atomic ¤tRenders, - std::optional latencyHint = std::nullopt); + AudioContextLatencyHint latencyHint); ~AudioPlayer() override { cleanup(); @@ -70,7 +69,7 @@ class AudioPlayer : public CommonPlayer, std::atomic lastCallbackFrameCount_{0}; std::mutex *driverMutex_; std::weak_ptr context_; - std::optional latencyHint_; + const PerformanceMode performanceMode_; 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 658b50026..21d9df37d 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/AudioAPIModuleInstaller.h @@ -19,8 +19,6 @@ #include #include -#include -#include #include namespace audioapi { @@ -80,7 +78,7 @@ class AudioAPIModuleInstaller { size_t count) -> jsi::Value { auto sampleRate = static_cast(args[0].getNumber()); - std::optional latencyHint; + auto latencyHint = AudioContextLatencyHint::INTERACTIVE; if (count > 1 && args[1].isString()) { latencyHint = js_enum_parser::latencyHintFromString(args[1].getString(runtime).utf8(runtime)); 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 94a7a1848..73c682332 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 @@ -14,7 +14,7 @@ AudioContextHostObject::AudioContextHostObject( const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, const std::shared_ptr &callInvoker, - std::optional latencyHint) + AudioContextLatencyHint latencyHint) : BaseAudioContextHostObject( std::make_shared(sampleRate, audioEventHandlerRegistry, latencyHint), runtime, 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 376011e70..194a7be33 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 @@ -7,7 +7,6 @@ #include #include -#include namespace audioapi { using namespace facebook; @@ -21,7 +20,7 @@ class AudioContextHostObject : public BaseAudioContextHostObject { const std::shared_ptr &audioEventHandlerRegistry, jsi::Runtime *runtime, const std::shared_ptr &callInvoker, - std::optional latencyHint = std::nullopt); + AudioContextLatencyHint latencyHint); JSI_HOST_FUNCTION_DECL(close); JSI_HOST_FUNCTION_DECL(resume); 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 f136063d3..f603599ea 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 @@ -49,15 +49,13 @@ std::string filterTypeToString(BiquadFilterType type) { } } -std::optional latencyHintFromString(const std::string &hint) { - if (hint == "interactive") - return AudioContextLatencyHint::INTERACTIVE; +AudioContextLatencyHint latencyHintFromString(const std::string &hint) { if (hint == "balanced") return AudioContextLatencyHint::BALANCED; if (hint == "playback") return AudioContextLatencyHint::PLAYBACK; - return std::nullopt; + return AudioContextLatencyHint::INTERACTIVE; } OverSampleType overSampleTypeFromString(const std::string &type) { 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 53e77a2b8..90e9fdd57 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 @@ -10,7 +10,6 @@ #include #include #include -#include #include namespace audioapi::js_enum_parser { @@ -31,6 +30,6 @@ std::string distanceModelToString(DistanceModelType model); DistanceModelType distanceModelFromString(const std::string &model); ChannelInterpretation channelInterpretationFromString(const std::string &interpretation); std::string contextStateToString(ContextState state); -/// Empty for an unrecognised string, where a browser would throw a TypeError. -std::optional latencyHintFromString(const std::string &hint); +/// Interactive, the spec default, for an unrecognised string; a browser would throw a TypeError. +AudioContextLatencyHint latencyHintFromString(const std::string &hint); } // namespace audioapi::js_enum_parser 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 8763f12d3..550897b91 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 @@ -16,7 +16,7 @@ namespace audioapi { AudioContext::AudioContext( float sampleRate, const std::shared_ptr &audioEventHandlerRegistry, - std::optional latencyHint) + AudioContextLatencyHint latencyHint) : BaseAudioContext(sampleRate, audioEventHandlerRegistry), latencyHint_(latencyHint), isInitialized_(false) { 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 f102ba5a8..580bc7254 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 @@ -9,7 +9,6 @@ #include #include -#include namespace audioapi { @@ -18,7 +17,7 @@ class AudioContext : public BaseAudioContext { explicit AudioContext( float sampleRate, const std::shared_ptr &audioEventHandlerRegistry, - std::optional latencyHint = std::nullopt); + AudioContextLatencyHint latencyHint); ~AudioContext() override; DELETE_COPY_AND_MOVE(AudioContext); @@ -42,7 +41,7 @@ class AudioContext : public BaseAudioContext { private: std::shared_ptr audioPlayer_; - std::optional latencyHint_; + AudioContextLatencyHint latencyHint_; 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/types/AudioContextLatencyHint.h b/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioContextLatencyHint.h index d09c1b988..9fe640d71 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioContextLatencyHint.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioContextLatencyHint.h @@ -1,31 +1,9 @@ #pragma once -#include - #include -#include namespace audioapi { enum class AudioContextLatencyHint : std::uint8_t { INTERACTIVE, BALANCED, PLAYBACK }; -/// Output buffer this hint asks for, in frames of the output stream's own rate — not the -/// context's, which may differ. 0 requests nothing. -inline int preferredIOBufferFramesFor(std::optional latencyHint) { - if (!latencyHint.has_value()) { - return 0; - } - - switch (*latencyHint) { - case AudioContextLatencyHint::INTERACTIVE: - return RENDER_QUANTUM_SIZE; - case AudioContextLatencyHint::BALANCED: - return 4 * RENDER_QUANTUM_SIZE; - case AudioContextLatencyHint::PLAYBACK: - return 0; - } - - return 0; -} - } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/AudioContextLatencyHintTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/AudioContextLatencyHintTest.cpp deleted file mode 100644 index 9151094b5..000000000 --- a/packages/react-native-audio-api/common/cpp/test/src/core/AudioContextLatencyHintTest.cpp +++ /dev/null @@ -1,34 +0,0 @@ -#include -#include -#include -#include - -using namespace audioapi; - -// NOLINTBEGIN - -TEST(AudioContextLatencyHintTest, ParsesTheThreeCategories) { - EXPECT_EQ( - js_enum_parser::latencyHintFromString("interactive"), AudioContextLatencyHint::INTERACTIVE); - EXPECT_EQ(js_enum_parser::latencyHintFromString("balanced"), AudioContextLatencyHint::BALANCED); - EXPECT_EQ(js_enum_parser::latencyHintFromString("playback"), AudioContextLatencyHint::PLAYBACK); -} - -TEST(AudioContextLatencyHintTest, TreatsAnUnrecognisedStringAsNoHint) { - EXPECT_EQ(js_enum_parser::latencyHintFromString(""), std::nullopt); - EXPECT_EQ(js_enum_parser::latencyHintFromString("foo"), std::nullopt); - EXPECT_EQ(js_enum_parser::latencyHintFromString("INTERACTIVE"), std::nullopt); - EXPECT_EQ(js_enum_parser::latencyHintFromString("0.01"), std::nullopt); -} - -TEST(AudioContextLatencyHintTest, RequestsNothingWithoutAHintOrForPlayback) { - EXPECT_EQ(preferredIOBufferFramesFor(std::nullopt), 0); - EXPECT_EQ(preferredIOBufferFramesFor(AudioContextLatencyHint::PLAYBACK), 0); -} - -TEST(AudioContextLatencyHintTest, RequestsOneRenderQuantumForInteractiveAndFourForBalanced) { - EXPECT_EQ(preferredIOBufferFramesFor(AudioContextLatencyHint::INTERACTIVE), 128); - EXPECT_EQ(preferredIOBufferFramesFor(AudioContextLatencyHint::BALANCED), 512); -} - -// NOLINTEND 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 e4ef1ea55..cfa8e5322 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 @@ -15,7 +15,6 @@ typedef struct objc_object AudioBufferList; #include #include #include -#include namespace audioapi { class AudioContext; @@ -27,7 +26,7 @@ class IOSAudioPlayer : public CommonPlayer { float sampleRate, int channelCount, std::atomic ¤tRenders, - std::optional latencyHint = std::nullopt); + AudioContextLatencyHint latencyHint); ~IOSAudioPlayer() override; DELETE_COPY_AND_MOVE(IOSAudioPlayer); 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 25b543c9f..75c9ab5d3 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 @@ -13,16 +13,34 @@ namespace audioapi { +namespace { + +/// In frames of the session's own rate, which may differ from the context's. +int preferredIOBufferFramesFor(AudioContextLatencyHint latencyHint) +{ + switch (latencyHint) { + case AudioContextLatencyHint::INTERACTIVE: + return RENDER_QUANTUM_SIZE; + case AudioContextLatencyHint::BALANCED: + return 8 * RENDER_QUANTUM_SIZE; + case AudioContextLatencyHint::PLAYBACK: + return 32 * RENDER_QUANTUM_SIZE; + } + return RENDER_QUANTUM_SIZE; +} + +} // namespace + IOSAudioPlayer::IOSAudioPlayer( const std::function &renderAudio, float sampleRate, int channelCount, std::atomic ¤tRenders, - std::optional latencyHint) + AudioContextLatencyHint latencyHint) : audioBuffer_(nullptr), audioPlayer_(nullptr), - renderAudio_(renderAudio), sampleRate_(sampleRate), + renderAudio_(renderAudio), currentRenders_(currentRenders), channelCount_(channelCount), isRunning_(false), diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m index cd7184d96..223ff78d4 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/NativeAudioPlayer.m @@ -4,7 +4,6 @@ @interface NativeAudioPlayer () { int _preferredIOBufferFrames; - /// nil unless this player asked for a buffer duration. NSString *_ioBufferClientId; } @end @@ -39,53 +38,11 @@ - (bool)startPlaybackGraph:(AudioEngine *)audioEngine return [audioEngine startIfNecessary]; } -- (void)requestPreferredIOBuffer -{ - [[AudioSessionManager sharedInstance] requestIOBufferFrames:_preferredIOBufferFrames - forClient:_ioBufferClientId]; -} - - (void)releasePreferredIOBuffer { [[AudioSessionManager sharedInstance] releaseIOBufferFramesForClient:_ioBufferClientId]; } -- (bool)activateSessionAndStart:(NSString *)activationAction -{ - AudioEngine *audioEngine = [AudioEngine sharedInstance]; - AudioSessionManager *sessionManager = [AudioSessionManager sharedInstance]; - assert(audioEngine != nil); - - // Before activation and the engine start below: AVAudioEngine only adopts a new buffer size - // when it starts. - [self requestPreferredIOBuffer]; - - NSError *error = nil; - if (![sessionManager ensureActive:false error:&error]) { - NSLog( - @"Error while %@ audio session for playback: %@", - activationAction, - [error debugDescription]); - [self releasePreferredIOBuffer]; - return false; - } - - // AudioEngine allows us to attach and connect nodes at runtime but with few - // limitations in this case if it is the first player and recorder started the - // engine we need to restart. It can be optimized by tracking if we haven't - // break rules of at runtime modifications from docs - // https://developer.apple.com/documentation/avfaudio/avaudioengine?language=objc - // - // Currently we are restarting because we do not see any significant performance issue and case when - // you will need to start and stop player very frequently - if (![self startPlaybackGraph:audioEngine]) { - [self releasePreferredIOBuffer]; - return false; - } - - return true; -} - - (instancetype)initWithRenderAudio:(RenderAudioBlock)renderAudio sampleRate:(float)sampleRate channelCount:(int)channelCount @@ -94,7 +51,7 @@ - (instancetype)initWithRenderAudio:(RenderAudioBlock)renderAudio if (self = [super init]) { self.sampleRate = sampleRate; _preferredIOBufferFrames = preferredIOBufferFrames; - _ioBufferClientId = preferredIOBufferFrames > 0 ? [[NSUUID UUID] UUIDString] : nil; + _ioBufferClientId = [[NSUUID UUID] UUIDString]; self.channelCount = channelCount; self.renderAudio = [renderAudio copy]; @@ -120,7 +77,34 @@ - (instancetype)initWithRenderAudio:(RenderAudioBlock)renderAudio - (bool)start { - return [self activateSessionAndStart:@"activating"]; + AudioEngine *audioEngine = [AudioEngine sharedInstance]; + AudioSessionManager *sessionManager = [AudioSessionManager sharedInstance]; + assert(audioEngine != nil); + + // AVAudioEngine adopts a new buffer duration only when it starts, so it is requested first. + [sessionManager requestIOBufferFrames:_preferredIOBufferFrames forClient:_ioBufferClientId]; + + NSError *error = nil; + if (![sessionManager ensureActive:false error:&error]) { + NSLog(@"Error while activating audio session for playback: %@", [error debugDescription]); + [self releasePreferredIOBuffer]; + return false; + } + + // AudioEngine allows us to attach and connect nodes at runtime but with few + // limitations in this case if it is the first player and recorder started the + // engine we need to restart. It can be optimized by tracking if we haven't + // break rules of at runtime modifications from docs + // https://developer.apple.com/documentation/avfaudio/avaudioengine?language=objc + // + // Currently we are restarting because we do not see any significant performance issue and case when + // you will need to start and stop player very frequently + if (![self startPlaybackGraph:audioEngine]) { + [self releasePreferredIOBuffer]; + return false; + } + + return true; } - (void)stop @@ -136,7 +120,7 @@ - (void)stop - (bool)resume { - return [self activateSessionAndStart:@"re-activating"]; + return [self start]; } - (void)suspend diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.h b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.h index 84056507c..c874dfefd 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.h @@ -39,8 +39,8 @@ /// Switches the manager into external owner mode and stops all session mutations. - (void)disableSessionManagement; -/// One IO buffer duration serves the whole process, so live requests are reconciled by taking -/// the shortest; releasing the last asks for the duration the session ran at beforehand. +/// One IO buffer duration serves the whole process, so the shortest live request wins; +/// releasing the last asks for one render quantum, the default of an interactive context. - (void)requestIOBufferFrames:(int)frames forClient:(NSString *)clientId; - (void)releaseIOBufferFramesForClient:(NSString *)clientId; diff --git a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.mm b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.mm index 8898345fd..1055eae30 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/system/AudioSessionManager.mm @@ -2,11 +2,11 @@ #import #import +#include + @interface AudioSessionManager () @property (nonatomic, strong) NSMutableDictionary *ioBufferFramesRequests; -/// What the session ran at before the first request, to ask for after the last release. -@property (nonatomic, assign) double baselineIOBufferDuration; - (id)microphoneUsageDescriptionValue; - (bool)usesAudioApplicationRecordPermissionAPI; @@ -25,19 +25,7 @@ - (NSString *)formatPorts:(NSArray *)ports; RNAudioSessionCategoryOptionBluetoothHighQualityRecordingMask = 1 << 19; static const AVAudioSessionCategoryOptions RNAudioSessionCategoryOptionFarFieldInputMask = 1 << 18; -static const uint32_t kMaxIOBufferFrames = 1u << 16; - -static uint32_t nearestPowerOfTwoFrames(double frames) -{ - uint32_t above = 1; - while (above < frames && above < kMaxIOBufferFrames) { - above <<= 1; - } - - uint32_t below = above > 1 ? above >> 1 : 1; - - return (frames - below <= above - frames) ? below : above; -} +static const int kDefaultIOBufferFrames = audioapi::RENDER_QUANTUM_SIZE; @implementation AudioSessionManager @@ -58,7 +46,6 @@ - (instancetype)init self.notifyOthersOnDeactivation = true; self.ioBufferFramesRequests = [[NSMutableDictionary alloc] init]; - self.baselineIOBufferDuration = 0.0; } _sharedInstance = self; @@ -72,14 +59,14 @@ + (instancetype)sharedInstance - (void)cleanup { + // The preference outlives this manager. @synchronized(self) { - [self.ioBufferFramesRequests removeAllObjects]; + if (self.ioBufferFramesRequests.count > 0) { + [self.ioBufferFramesRequests removeAllObjects]; + [self applyPreferredIOBufferDuration]; + } } - // The preference outlives this manager, and the next one installed would read a shortened - // value as its own baseline. - [self applyPreferredIOBufferDuration]; - self.audioSession = nil; } @@ -92,68 +79,28 @@ - (bool)areDesiredOptionsSet self.audioSession.allowHapticsAndSystemSoundsDuringRecording == self.allowHapticsAndSounds); } +/// Caller holds the lock on self. - (void)applyPreferredIOBufferDuration { if (!self.shouldManageSession) { return; } - double requested = 0.0; - double granted = 0.0; - - @synchronized(self) { - int frames = 0; - for (NSNumber *clientFrames in self.ioBufferFramesRequests.objectEnumerator) { - int clientRequest = [clientFrames intValue]; - if (frames == 0 || clientRequest < frames) { - frames = clientRequest; - } - } - - if (frames == 0 && self.baselineIOBufferDuration <= 0.0) { - return; - } - - double sampleRate = self.audioSession.sampleRate; - if (sampleRate <= 0.0) { - return; - } - - // A preference can only be overwritten, never unset. - if (self.baselineIOBufferDuration <= 0.0) { - self.baselineIOBufferDuration = self.audioSession.IOBufferDuration; - } - - // The session grants a power-of-two frame count, so the baseline cannot be asked for exactly. - requested = frames > 0 - ? frames / sampleRate - : nearestPowerOfTwoFrames(self.baselineIOBufferDuration * sampleRate) / sampleRate; - - NSError *error = nil; - if (![self.audioSession setPreferredIOBufferDuration:requested error:&error]) { - // A refused duration still leaves playback running at whatever the session grants. - NSLog( - @"[AudioSessionManager] Error while requesting IO buffer duration %f s: %@", - requested, - [error debugDescription]); - return; - } + NSNumber *shortestRequest = [self.ioBufferFramesRequests.allValues valueForKeyPath:@"@min.self"]; + int frames = shortestRequest != nil ? shortestRequest.intValue : kDefaultIOBufferFrames; + double duration = frames / self.audioSession.sampleRate; - granted = self.audioSession.IOBufferDuration; + NSError *error = nil; + if (![self.audioSession setPreferredIOBufferDuration:duration error:&error]) { + NSLog( + @"[AudioSessionManager] Error while requesting IO buffer duration %f s: %@", + duration, + [error debugDescription]); } - - NSLog( - @"[AudioSessionManager] Requested IO buffer duration: %f s, session reports %f s", - requested, - granted); } - (void)requestIOBufferFrames:(int)frames forClient:(NSString *)clientId { - if (clientId == nil || frames <= 0) { - return; - } - @synchronized(self) { self.ioBufferFramesRequests[clientId] = @(frames); [self applyPreferredIOBufferDuration]; @@ -163,7 +110,7 @@ - (void)requestIOBufferFrames:(int)frames forClient:(NSString *)clientId - (void)releaseIOBufferFramesForClient:(NSString *)clientId { @synchronized(self) { - if (clientId == nil || self.ioBufferFramesRequests[clientId] == nil) { + if (self.ioBufferFramesRequests[clientId] == nil) { return; } @@ -172,8 +119,12 @@ - (void)releaseIOBufferFramesForClient:(NSString *)clientId } } -- (bool)configureCategory:(NSError **)outError +- (bool)configureAudioSession:(NSError **)outError { + if (!self.shouldManageSession || [self areDesiredOptionsSet]) { + return true; + } + NSError *categoryError = nil; [self.audioSession setCategory:self.desiredCategory mode:self.desiredMode @@ -215,22 +166,6 @@ - (bool)configureCategory:(NSError **)outError return true; } -- (bool)configureAudioSession:(NSError **)outError -{ - if (!self.shouldManageSession) { - return true; - } - - if (![self areDesiredOptionsSet] && ![self configureCategory:outError]) { - return false; - } - - // After the category, which decides the duration the session defaults to. - [self applyPreferredIOBufferDuration]; - - return true; -} - - (void)setAudioSessionOptions:(NSString *)categoryStr mode:(NSString *)modeStr options:(NSArray *)optionsArray diff --git a/packages/react-native-audio-api/src/types.ts b/packages/react-native-audio-api/src/types.ts index 15b48b06f..cce583707 100644 --- a/packages/react-native-audio-api/src/types.ts +++ b/packages/react-native-audio-api/src/types.ts @@ -53,10 +53,9 @@ export type AudioContextLatencyCategory = export interface AudioContextOptions { sampleRate?: number; /** - * What the context should optimize its output stream for. Omitting it is not - * the same as `'interactive'`: each platform keeps the stream it opened - * before this option existed. See the platform table in the docs. Numeric - * hints are not supported yet. + * What the context should optimize its output stream for; defaults to + * `'interactive'`. See the platform table in the docs. Numeric hints are not + * supported yet. */ latencyHint?: AudioContextLatencyCategory; } 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 408ae86a0..06c1fe776 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 @@ -17,7 +17,6 @@ #include #include -#include #include #include @@ -206,7 +205,7 @@ void installAudioContextBinding( const auto sampleRate = static_cast(args[0].getNumber()); - std::optional latencyHint; + auto latencyHint = AudioContextLatencyHint::INTERACTIVE; if (count > 1 && args[1].isString()) { latencyHint = audioapi::js_enum_parser::latencyHintFromString( args[1].getString(rt).utf8(rt));