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 45eaebeca..44637d98a 100644
--- a/packages/audiodocs/docs/core/audio-context.mdx
+++ b/packages/audiodocs/docs/core/audio-context.mdx
@@ -21,6 +21,25 @@ 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). 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 |
+| :---: | :---- | :---- | :---- |
+| `'interactive'` | `LowLatency` | 128 frames | forwarded |
+| `'balanced'` | `None` | 1024 frames | forwarded |
+| `'playback'` | `PowerSaving` | 4096 frames | forwarded |
+
+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 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 38371018f..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
@@ -13,20 +13,38 @@
namespace audioapi {
+namespace {
+
+PerformanceMode performanceModeFor(AudioContextLatencyHint latencyHint) {
+ switch (latencyHint) {
+ case AudioContextLatencyHint::INTERACTIVE:
+ return PerformanceMode::LowLatency;
+ case AudioContextLatencyHint::BALANCED:
+ return PerformanceMode::None;
+ case AudioContextLatencyHint::PLAYBACK:
+ return PerformanceMode::PowerSaving;
+ }
+ 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),
+ performanceMode_(performanceModeFor(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(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 2f9a4ac01..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
@@ -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);
~AudioPlayer() override {
cleanup();
@@ -67,6 +69,7 @@ class AudioPlayer : public CommonPlayer,
std::atomic lastCallbackFrameCount_{0};
std::mutex *driverMutex_;
std::weak_ptr context_;
+ 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 ba15c738b..21d9df37d 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
@@ -69,7 +70,7 @@ class AudioAPIModuleInstaller {
return jsi::Function::createFromHostFunction(
*jsiRuntime,
jsi::PropNameID::forAscii(*jsiRuntime, "createAudioContext"),
- 1,
+ 2,
[jsCallInvoker, audioEventHandlerRegistry](
jsi::Runtime &runtime,
const jsi::Value &thisValue,
@@ -77,8 +78,14 @@ class AudioAPIModuleInstaller {
size_t count) -> jsi::Value {
auto sampleRate = static_cast(args[0].getNumber());
+ auto latencyHint = AudioContextLatencyHint::INTERACTIVE;
+ if (count > 1 && args[1].isString()) {
+ latencyHint =
+ js_enum_parser::latencyHintFromString(args[1].getString(runtime).utf8(runtime));
+ }
+
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..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
@@ -1,9 +1,11 @@
#pragma once
#include
+#include
#include
#include
+
#include
namespace audioapi {
@@ -17,7 +19,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);
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 a53c60242..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,6 +49,15 @@ std::string filterTypeToString(BiquadFilterType type) {
}
}
+AudioContextLatencyHint latencyHintFromString(const std::string &hint) {
+ if (hint == "balanced")
+ return AudioContextLatencyHint::BALANCED;
+ if (hint == "playback")
+ return AudioContextLatencyHint::PLAYBACK;
+
+ return AudioContextLatencyHint::INTERACTIVE;
+}
+
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 8af08d9cd..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
@@ -1,6 +1,7 @@
#pragma once
#include
+#include
#include
#include
#include
@@ -29,4 +30,6 @@ std::string distanceModelToString(DistanceModelType model);
DistanceModelType distanceModelFromString(const std::string &model);
ChannelInterpretation channelInterpretationFromString(const std::string &interpretation);
std::string contextStateToString(ContextState state);
+/// 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 6721bb9bf..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
@@ -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 the channels itself until start()/resume() hands draining to the
// audio callback (same pattern as OfflineAudioContext before rendering).
@@ -45,13 +48,15 @@ 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); },
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 0cad163a2..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
@@ -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);
~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..9fe640d71
--- /dev/null
+++ b/packages/react-native-audio-api/common/cpp/audioapi/core/types/AudioContextLatencyHint.h
@@ -0,0 +1,9 @@
+#pragma once
+
+#include
+
+namespace audioapi {
+
+enum class AudioContextLatencyHint : std::uint8_t { INTERACTIVE, BALANCED, PLAYBACK };
+
+} // namespace audioapi
diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.h
index d5203af6b..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
@@ -8,6 +8,7 @@ typedef struct objc_object AudioBufferList;
#endif // __OBJC__
#include
+#include
#include
#include
@@ -24,7 +25,8 @@ class IOSAudioPlayer : public CommonPlayer {
const std::function &renderAudio,
float sampleRate,
int channelCount,
- std::atomic ¤tRenders);
+ std::atomic ¤tRenders,
+ 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 cfd860e3e..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,15 +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::atomic ¤tRenders,
+ AudioContextLatencyHint latencyHint)
: audioBuffer_(nullptr),
audioPlayer_(nullptr),
- renderAudio_(renderAudio),
sampleRate_(sampleRate),
+ renderAudio_(renderAudio),
currentRenders_(currentRenders),
channelCount_(channelCount),
isRunning_(false),
@@ -31,9 +50,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..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
@@ -2,6 +2,12 @@
#import
#import
+@interface NativeAudioPlayer () {
+ int _preferredIOBufferFrames;
+ NSString *_ioBufferClientId;
+}
+@end
+
@implementation NativeAudioPlayer
- (void)detachSourceNodeIfAttached:(AudioEngine *)audioEngine
@@ -32,12 +38,20 @@ - (bool)startPlaybackGraph:(AudioEngine *)audioEngine
return [audioEngine startIfNecessary];
}
+- (void)releasePreferredIOBuffer
+{
+ [[AudioSessionManager sharedInstance] releaseIOBufferFramesForClient:_ioBufferClientId];
+}
+
- (instancetype)initWithRenderAudio:(RenderAudioBlock)renderAudio
sampleRate:(float)sampleRate
channelCount:(int)channelCount
+ preferredIOBufferFrames:(int)preferredIOBufferFrames
{
if (self = [super init]) {
self.sampleRate = sampleRate;
+ _preferredIOBufferFrames = preferredIOBufferFrames;
+ _ioBufferClientId = [[NSUUID UUID] UUIDString];
self.channelCount = channelCount;
self.renderAudio = [renderAudio copy];
@@ -67,9 +81,13 @@ - (bool)start
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;
}
@@ -81,11 +99,18 @@ - (bool)start
//
// 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];
+ if (![self startPlaybackGraph:audioEngine]) {
+ [self releasePreferredIOBuffer];
+ return false;
+ }
+
+ return true;
}
- (void)stop
{
+ [self releasePreferredIOBuffer];
+
AudioEngine *audioEngine = [AudioEngine sharedInstance];
if (audioEngine != nil) {
[self detachSourceNodeIfAttached:audioEngine];
@@ -95,21 +120,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 start];
}
- (void)suspend
{
+ [self releasePreferredIOBuffer];
+
AudioEngine *audioEngine = [AudioEngine sharedInstance];
assert(audioEngine != nil);
@@ -119,6 +136,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..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,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 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;
+
- (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..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,8 +2,12 @@
#import
#import
+#include
+
@interface AudioSessionManager ()
+@property (nonatomic, strong) NSMutableDictionary *ioBufferFramesRequests;
+
- (id)microphoneUsageDescriptionValue;
- (bool)usesAudioApplicationRecordPermissionAPI;
- (void)requestSystemRecordPermission:(void (^)(BOOL granted))completion;
@@ -21,6 +25,8 @@ - (NSString *)formatPorts:(NSArray *)ports;
RNAudioSessionCategoryOptionBluetoothHighQualityRecordingMask = 1 << 19;
static const AVAudioSessionCategoryOptions RNAudioSessionCategoryOptionFarFieldInputMask = 1 << 18;
+static const int kDefaultIOBufferFrames = audioapi::RENDER_QUANTUM_SIZE;
+
@implementation AudioSessionManager
static AudioSessionManager *_sharedInstance = nil;
@@ -38,6 +44,8 @@ - (instancetype)init
self.desiredOptions = 0;
self.allowHapticsAndSounds = false;
self.notifyOthersOnDeactivation = true;
+
+ self.ioBufferFramesRequests = [[NSMutableDictionary alloc] init];
}
_sharedInstance = self;
@@ -51,6 +59,14 @@ + (instancetype)sharedInstance
- (void)cleanup
{
+ // The preference outlives this manager.
+ @synchronized(self) {
+ if (self.ioBufferFramesRequests.count > 0) {
+ [self.ioBufferFramesRequests removeAllObjects];
+ [self applyPreferredIOBufferDuration];
+ }
+ }
+
self.audioSession = nil;
}
@@ -63,6 +79,46 @@ - (bool)areDesiredOptionsSet
self.audioSession.allowHapticsAndSystemSoundsDuringRecording == self.allowHapticsAndSounds);
}
+/// Caller holds the lock on self.
+- (void)applyPreferredIOBufferDuration
+{
+ if (!self.shouldManageSession) {
+ return;
+ }
+
+ NSNumber *shortestRequest = [self.ioBufferFramesRequests.allValues valueForKeyPath:@"@min.self"];
+ int frames = shortestRequest != nil ? shortestRequest.intValue : kDefaultIOBufferFrames;
+ double duration = frames / self.audioSession.sampleRate;
+
+ NSError *error = nil;
+ if (![self.audioSession setPreferredIOBufferDuration:duration error:&error]) {
+ NSLog(
+ @"[AudioSessionManager] Error while requesting IO buffer duration %f s: %@",
+ duration,
+ [error debugDescription]);
+ }
+}
+
+- (void)requestIOBufferFrames:(int)frames forClient:(NSString *)clientId
+{
+ @synchronized(self) {
+ self.ioBufferFramesRequests[clientId] = @(frames);
+ [self applyPreferredIOBufferDuration];
+ }
+}
+
+- (void)releaseIOBufferFramesForClient:(NSString *)clientId
+{
+ @synchronized(self) {
+ if (self.ioBufferFramesRequests[clientId] == nil) {
+ return;
+ }
+
+ [self.ioBufferFramesRequests removeObjectForKey:clientId];
+ [self applyPreferredIOBufferDuration];
+ }
+}
+
- (bool)configureAudioSession:(NSError **)outError
{
if (!self.shouldManageSession || [self areDesiredOptionsSet]) {
diff --git a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts
index a3f23c4d4..7a630effd 100644
--- a/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts
+++ b/packages/react-native-audio-api/src/AudioAPIModule/globals.d.ts
@@ -7,11 +7,18 @@ import type {
IAudioBuffer,
IOfflineAudioContext,
} from '../jsi-interfaces';
-import type { AudioRecorderOptions, FileInfo } from '../types';
+import type {
+ AudioContextLatencyCategory,
+ AudioRecorderOptions,
+ FileInfo,
+} 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 f0b075bda..fd6472593 100644
--- a/packages/react-native-audio-api/src/core/AudioContext.ts
+++ b/packages/react-native-audio-api/src/core/AudioContext.ts
@@ -15,7 +15,8 @@ export default class AudioContext extends BaseAudioContext {
super(
globalThis.createAudioContext(
- options?.sampleRate || AudioManager.getDevicePreferredSampleRate()
+ options?.sampleRate || AudioManager.getDevicePreferredSampleRate(),
+ options?.latencyHint
)
);
}
diff --git a/packages/react-native-audio-api/src/types.ts b/packages/react-native-audio-api/src/types.ts
index 56d4c8aab..cce583707 100644
--- a/packages/react-native-audio-api/src/types.ts
+++ b/packages/react-native-audio-api/src/types.ts
@@ -45,8 +45,19 @@ 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'`. See the platform table in the docs. 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 05ae20788..8f1c80b63 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
@@ -34,7 +34,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);
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..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
@@ -8,6 +8,8 @@
#include
#include
#include
+#include
+#include
#include
#include
#include
@@ -23,6 +25,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 +204,19 @@ void installAudioContextBinding(
}
const auto sampleRate = static_cast(args[0].getNumber());
+
+ auto latencyHint = AudioContextLatencyHint::INTERACTIVE;
+ 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);
});