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); });