From 77eb6179d294f1462727284cf5c5bc0e69668134 Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 16 Aug 2026 13:22:52 -0500 Subject: [PATCH] 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);