From 98f863161d9789e9360e9f7c28aa854616d26c4b Mon Sep 17 00:00:00 2001 From: michal Date: Fri, 12 Jun 2026 14:16:12 +0200 Subject: [PATCH 1/8] feat: initial implementation of audio latencies --- apps/fabric-example/ios/Podfile.lock | 6 +-- .../audiodocs/docs/core/audio-context.mdx | 7 ++- .../docs/core/base-audio-context.mdx | 1 + .../audiodocs/docs/inputs/audio-recorder.mdx | 20 +++++++++ .../docs/other/web-audio-api-coverage.mdx | 4 +- .../android/core/AndroidAudioRecorder.cpp | 43 +++++++++++++++++++ .../android/core/AndroidAudioRecorder.h | 8 ++++ .../cpp/audioapi/android/core/AudioPlayer.cpp | 37 ++++++++++++++++ .../cpp/audioapi/android/core/AudioPlayer.h | 9 ++++ .../HostObjects/AudioContextHostObject.cpp | 7 +++ .../HostObjects/AudioContextHostObject.h | 2 + .../BaseAudioContextHostObject.cpp | 7 ++- .../HostObjects/BaseAudioContextHostObject.h | 1 + .../inputs/AudioRecorderHostObject.cpp | 7 ++- .../inputs/AudioRecorderHostObject.h | 1 + .../common/cpp/audioapi/core/AudioContext.cpp | 16 +++++++ .../common/cpp/audioapi/core/AudioContext.h | 3 ++ .../cpp/audioapi/core/BaseAudioContext.cpp | 4 ++ .../cpp/audioapi/core/BaseAudioContext.h | 1 + .../cpp/audioapi/core/inputs/AudioRecorder.h | 2 + .../core/OfflineAudioContextLatencyTest.cpp | 15 +++++++ .../ios/audioapi/ios/core/IOSAudioPlayer.h | 6 +++ .../ios/audioapi/ios/core/IOSAudioPlayer.mm | 32 ++++++++++++++ .../ios/audioapi/ios/core/IOSAudioRecorder.h | 7 +++ .../ios/audioapi/ios/core/IOSAudioRecorder.mm | 27 ++++++++++++ .../audioapi/ios/system/AudioSessionManager.h | 4 ++ .../ios/system/AudioSessionManager.mm | 16 +++++++ .../src/core/AudioContext.ts | 8 ++++ .../src/core/AudioRecorder.ts | 4 ++ .../react-native-audio-api/src/interfaces.ts | 3 ++ .../react-native-audio-api/src/mock/index.ts | 12 ++++++ .../src/web-core/AudioContext.web.ts | 8 ++++ .../react-native-audio-api/tests/mock.test.ts | 4 ++ 33 files changed, 323 insertions(+), 9 deletions(-) create mode 100644 packages/react-native-audio-api/common/cpp/test/src/core/OfflineAudioContextLatencyTest.cpp diff --git a/apps/fabric-example/ios/Podfile.lock b/apps/fabric-example/ios/Podfile.lock index 45288eaca..74a47ec25 100644 --- a/apps/fabric-example/ios/Podfile.lock +++ b/apps/fabric-example/ios/Podfile.lock @@ -2476,7 +2476,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b - hermes-engine: 146211e12d60a1951d9eb0287be07211e86cf5d5 + hermes-engine: 91023181d4bc5948b457de5314623fbfe4f8604e RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257 RCTRequired: 74839f55d5058a133a0bc4569b0afec750957f64 RCTSwiftUI: 87a316382f3eab4dd13d2a0d0fd2adcce917361a @@ -2485,7 +2485,7 @@ SPEC CHECKSUMS: React: 1b1536b9099195944034e65b1830f463caaa8390 React-callinvoker: 6dff6d17d1d6cc8fdf85468a649bafed473c65f5 React-Core: 00faa4d038298089a1d5a5b21dde8660c4f0820d - React-Core-prebuilt: ef40616103ee11f8c2517697c3aa4f48ce790549 + React-Core-prebuilt: a6d614de037caff7898424dfc22915ec792de921 React-CoreModules: a17807f849bfd86045b0b9a75ec8c19373b482f6 React-cxxreact: c7b53ace5827be54048288bce5c55f337c41e95f React-debug: e1f00fcd2cef58a2897471a6d76a4ef5f5f90c74 @@ -2549,7 +2549,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 5787b37b8e2e51dfeab697ec031cc7c4080dcea2 ReactCodegen: d07ee3c8db75b43d1cbe479ae6affebf9925c733 ReactCommon: fe2a3af8975e63efa60f95fca8c34dc85deee360 - ReactNativeDependencies: 54189f1570b1308686cb21564e755e1daa77ea03 + ReactNativeDependencies: 4d5ce2683b6d74f7c686bf90a88c7d381295cf3c RNAudioAPI: 6668f71bdd9850005984acf39a3daef4935cec02 RNGestureHandler: 187c5c7936abf427bc4d22d6c3b1ac80ad1f63c0 RNReanimated: 64f4b3b33b48b19e0ba76a352571b52b1e931981 diff --git a/packages/audiodocs/docs/core/audio-context.mdx b/packages/audiodocs/docs/core/audio-context.mdx index 00d09361e..212281c84 100644 --- a/packages/audiodocs/docs/core/audio-context.mdx +++ b/packages/audiodocs/docs/core/audio-context.mdx @@ -25,8 +25,11 @@ interface AudioContextOptions { ## Properties -`AudioContext` does not define any additional properties. -It inherits all properties from [`BaseAudioContext`](/docs/core/base-audio-context#properties). +| Name | Type | Description | | +| :----: | :----: | :-------- | :-: | +| `outputLatency` | `number` | Estimated output latency in seconds — the full interval from when the engine requests a buffer to when the first sample reaches the output device. Always greater than or equal to [`baseLatency`](/docs/core/base-audio-context#properties). Re-query before sync-sensitive work; the value can change when the audio route changes. Returns `0` when the context is not running. On iOS this is `baseLatency` plus `AVAudioSession.outputLatency`. On Android it is `baseLatency` plus a hardware tail derived from Oboe timestamps (full-buffer Oboe readings are excluded). | | + +`AudioContext` inherits all properties from [`BaseAudioContext`](/docs/core/base-audio-context#properties), including `baseLatency`. ## Methods diff --git a/packages/audiodocs/docs/core/base-audio-context.mdx b/packages/audiodocs/docs/core/base-audio-context.mdx index f7b646381..739a643c5 100644 --- a/packages/audiodocs/docs/core/base-audio-context.mdx +++ b/packages/audiodocs/docs/core/base-audio-context.mdx @@ -40,6 +40,7 @@ Concept of system-level audio callback does not apply to [`OfflineAudioContext`] | Name | Type | Description | | | :----: | :----: | :-------- | :-: | | `currentTime` | `number` | Double value representing an ever-increasing hardware time in seconds, starting from 0. | | +| `baseLatency` | `number` | Processing latency in seconds from the audio destination to the platform audio subsystem. Returns `0` when the context is not running. On iOS and Android this uses the measured render callback frame count divided by `sampleRate` (falls back to platform defaults before the first callback). [`outputLatency`](/docs/core/audio-context#properties) is always greater than or equal to this value. | | | `destination` | [`AudioDestinationNode`](/docs/destinations/audio-destination-node) | Final output destination associated with the context. | | | `sampleRate` | `number` | Float value representing the sample rate (in samples per seconds) used by all nodes in this context. | | | `state` | [`ContextState`](/docs/core/base-audio-context#contextstate) | Enumerated value represents the current state of the context. | | diff --git a/packages/audiodocs/docs/inputs/audio-recorder.mdx b/packages/audiodocs/docs/inputs/audio-recorder.mdx index c930b3773..d464b8611 100644 --- a/packages/audiodocs/docs/inputs/audio-recorder.mdx +++ b/packages/audiodocs/docs/inputs/audio-recorder.mdx @@ -566,6 +566,26 @@ export default MyRecorder; ``` + + +
+ ##### inputLatency +
+ +
+ **React Native extension (not in the Web Audio spec).** Returns the estimated **full** input latency in seconds — from the analog input through the platform to when samples are delivered to the app. Mirrors [`AudioContext.outputLatency`](/docs/core/audio-context#properties) semantics for capture. On Android this is sampled at input callback start via Oboe timestamps; on iOS it is the measured input callback duration plus `AVAudioSession.inputLatency`. Re-query before sync-sensitive work; the value can change when the audio route changes. Returns `0` when the recorder is idle or when recording from a graph tap without an open input stream. + + Use together with [`AudioContext.outputLatency`](/docs/core/audio-context#properties) to compensate overdub placement: + + ```tsx + const shift = -(context.outputLatency + recorder.inputLatency); + ``` +
+ ```tsx + const inputLatency = recorder.inputLatency; + ``` + + diff --git a/packages/audiodocs/docs/other/web-audio-api-coverage.mdx b/packages/audiodocs/docs/other/web-audio-api-coverage.mdx index 31e35fb9a..282aef144 100644 --- a/packages/audiodocs/docs/other/web-audio-api-coverage.mdx +++ b/packages/audiodocs/docs/other/web-audio-api-coverage.mdx @@ -29,8 +29,8 @@ sidebar_position: 2 | PeriodicWave | ✅ | | StereoPannerNode | ✅ | | WaveShaperNode | ✅ | -| AudioContext | 🚧 | Available props and methods: `close`, `suspend`, `resume` | -| BaseAudioContext | 🚧 | Available props and methods: `currentTime`, `destination`, `sampleRate`, `state`, `decodeAudioData`, all create methods for available or partially implemented nodes | +| AudioContext | 🚧 | Available props and methods: `baseLatency`, `outputLatency`, `close`, `suspend`, `resume` | +| BaseAudioContext | 🚧 | Available props and methods: `baseLatency`, `currentTime`, `destination`, `sampleRate`, `state`, `decodeAudioData`, all create methods for available or partially implemented nodes | | AudioListener | ❌ | | AudioSinkInfo | ❌ | | AudioWorklet | ❌ | diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp index c0aafd69a..972352366 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp @@ -17,6 +17,7 @@ #include #include +#include #include #include #include @@ -183,6 +184,8 @@ AndroidAudioRecorder::stop() { } state_.store(RecorderState::Idle, std::memory_order_release); + lastCallbackFrameCount_.store(0, std::memory_order_release); + lastInputLatencySeconds_.store(0.0, std::memory_order_release); // Fully close the stream rather than just stopping it. A stopped-but-open // stream keeps the data/error callbacks registered, so a later device @@ -481,6 +484,16 @@ oboe::DataCallbackResult AndroidAudioRecorder::onAudioReady( return oboe::DataCallbackResult::Continue; } + if (numFrames > 0) { + lastCallbackFrameCount_.store(numFrames, std::memory_order_release); + + // Sample at callback start (maximum of the input latency sawtooth). + const auto latencyResult = oboeStream->calculateLatencyMillis(); + if (latencyResult) { + lastInputLatencySeconds_.store(latencyResult.value() / 1000.0, std::memory_order_release); + } + } + if (usesFileOutput()) { if (auto fileWriterLock = Locker::tryLock(fileWriterMutex_)) { auto fileWriter = fileWriter_; @@ -599,4 +612,34 @@ void AndroidAudioRecorder::onErrorAfterClose(oboe::AudioStream *stream, oboe::Re state_.store(RecorderState::Recording, std::memory_order_release); } +double AndroidAudioRecorder::getInputLatency() const { + if (mStream_ == nullptr || isIdle()) { + return 0.0; + } + + double baseLatency = 0.0; + const int32_t callbackFrames = lastCallbackFrameCount_.load(std::memory_order_acquire); + if (callbackFrames > 0 && streamSampleRate_ > 0.0f) { + baseLatency = static_cast(callbackFrames) / static_cast(streamSampleRate_); + } else { + const int32_t framesPerBurst = mStream_->getFramesPerBurst(); + if (framesPerBurst > 0) { + baseLatency = static_cast(framesPerBurst) / static_cast(streamSampleRate_); + } + } + + const double measuredTotal = lastInputLatencySeconds_.load(std::memory_order_acquire); + if (measuredTotal > 0.0) { + return std::max(measuredTotal, baseLatency); + } + + const int32_t framesPerBurst = mStream_->getFramesPerBurst(); + if (framesPerBurst > 0 && streamSampleRate_ > 0.0f) { + return baseLatency + + static_cast(framesPerBurst) / static_cast(streamSampleRate_); + } + + return baseLatency; +} + } // namespace audioapi diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.h b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.h index aaf79cbd5..44a71a717 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.h +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -49,6 +51,8 @@ class AndroidAudioRecorder : public oboe::AudioStreamCallback, void connect(const std::shared_ptr &node) override; void disconnect() override; + [[nodiscard]] double getInputLatency() const override; + oboe::DataCallbackResult onAudioReady(oboe::AudioStream *oboeStream, void *audioData, int32_t numFrames) override; void onErrorAfterClose(oboe::AudioStream *oboeStream, oboe::Result error) override; @@ -64,6 +68,10 @@ class AndroidAudioRecorder : public oboe::AudioStreamCallback, std::shared_ptr mStream_; std::vector recordingSegmentPaths_; + /// Updated on the audio thread from each input callback `numFrames`. + std::atomic lastCallbackFrameCount_{0}; + /// Full input latency (seconds) sampled at callback start via Oboe timestamps. + std::atomic lastInputLatencySeconds_{0.0}; Result openAudioStream(); std::shared_ptr createFileWriter( const std::shared_ptr &props); 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 4ae5ff0b9..afd19a2ff 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 @@ -59,6 +59,8 @@ bool AudioPlayer::start() { void AudioPlayer::stop() { if (mStream_ != nullptr) { isRunning_.store(false, std::memory_order_release); + lastCallbackFrameCount_.store(0, std::memory_order_release); + lastOutputLatencySeconds_.store(0.0, std::memory_order_release); mStream_->requestStop(); } } @@ -104,6 +106,16 @@ AudioPlayer::onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numF return DataCallbackResult::Continue; } + if (numFrames > 0) { + lastCallbackFrameCount_.store(numFrames, std::memory_order_release); + + // Sample at callback start (minimum of the output latency sawtooth). + const auto latencyResult = oboeStream->calculateLatencyMillis(); + if (latencyResult) { + lastOutputLatencySeconds_.store(latencyResult.value() / 1000.0, std::memory_order_release); + } + } + auto *buffer = static_cast(audioData); int processedFrames = 0; @@ -134,4 +146,29 @@ void AudioPlayer::onErrorAfterClose(oboe::AudioStream *stream, oboe::Result erro } } } + +double AudioPlayer::getBaseLatency() const { + if (mStream_ == nullptr || !isInitialized_ || !isRunning()) { + return 0.0; + } + + const int32_t callbackFrames = lastCallbackFrameCount_.load(std::memory_order_acquire); + if (callbackFrames > 0 && sampleRate_ > 0.0f) { + return static_cast(callbackFrames) / static_cast(sampleRate_); + } + + const int32_t framesPerBurst = mStream_->getFramesPerBurst(); + if (framesPerBurst > 0) { + return static_cast(framesPerBurst) / static_cast(sampleRate_); + } + + return static_cast(RENDER_QUANTUM_SIZE) / static_cast(sampleRate_); +} + +double AudioPlayer::getOutputLatency() const { + if (mStream_ == nullptr || !isInitialized_ || !isRunning()) { + return 0.0; + } + return lastOutputLatencySeconds_.load(std::memory_order_acquire); +} } // namespace audioapi 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 b7213bfee..bd863beb3 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 @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include #include @@ -37,6 +39,9 @@ class AudioPlayer : public AudioStreamDataCallback, [[nodiscard]] bool isRunning() const; + [[nodiscard]] double getBaseLatency() const; + [[nodiscard]] double getOutputLatency() const; + DataCallbackResult onAudioReady(AudioStream *oboeStream, void *audioData, int32_t numFrames) override; @@ -50,6 +55,10 @@ class AudioPlayer : public AudioStreamDataCallback, float sampleRate_; int channelCount_; std::atomic isRunning_; + /// Updated on the audio thread from each Oboe callback `numFrames`. + std::atomic lastCallbackFrameCount_{0}; + /// Full output latency (seconds) sampled at callback start via Oboe timestamps. + std::atomic lastOutputLatencySeconds_{0.0}; bool openAudioStream(); 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 fba2a3ba5..b68ecd577 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 @@ -18,6 +18,8 @@ AudioContextHostObject::AudioContextHostObject( std::make_shared(sampleRate, audioEventHandlerRegistry, runtimeRegistry), runtime, callInvoker) { + addGetters(JSI_EXPORT_PROPERTY_GETTER(AudioContextHostObject, outputLatency)); + addFunctions( JSI_EXPORT_FUNCTION(AudioContextHostObject, close), JSI_EXPORT_FUNCTION(AudioContextHostObject, resume), @@ -60,6 +62,11 @@ JSI_HOST_FUNCTION_IMPL(AudioContextHostObject, suspend) { return promise; } +JSI_PROPERTY_GETTER_IMPL(AudioContextHostObject, outputLatency) { + auto audioContext = std::static_pointer_cast(context_); + return {audioContext->getOutputLatency()}; +} + JSI_HOST_FUNCTION_IMPL(AudioContextHostObject, createMediaElementSource) { auto sourceObject = args[0].asObject(runtime); auto fileSourceHostObject = sourceObject.getHostObject(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 ce114618d..36b85cdc9 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 @@ -25,5 +25,7 @@ class AudioContextHostObject : public BaseAudioContextHostObject { JSI_HOST_FUNCTION_DECL(resume); JSI_HOST_FUNCTION_DECL(suspend); JSI_HOST_FUNCTION_DECL(createMediaElementSource); + + JSI_PROPERTY_GETTER_DECL(outputLatency); }; } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp index 7c7e528f8..d670b8e1c 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.cpp @@ -44,7 +44,8 @@ BaseAudioContextHostObject::BaseAudioContextHostObject( JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, destination), JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, state), JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, sampleRate), - JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, currentTime)); + JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, currentTime), + JSI_EXPORT_PROPERTY_GETTER(BaseAudioContextHostObject, baseLatency)); addFunctions( JSI_EXPORT_FUNCTION(BaseAudioContextHostObject, createWorkletSourceNode), @@ -91,6 +92,10 @@ JSI_PROPERTY_GETTER_IMPL(BaseAudioContextHostObject, currentTime) { return {context_->getCurrentTime()}; } +JSI_PROPERTY_GETTER_IMPL(BaseAudioContextHostObject, baseLatency) { + return {context_->getBaseLatency()}; +} + JSI_HOST_FUNCTION_IMPL(BaseAudioContextHostObject, createWorkletSourceNode) { #if RN_AUDIO_API_ENABLE_WORKLETS auto shareableWorklet = diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h index 4041f8f51..25054e843 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/BaseAudioContextHostObject.h @@ -26,6 +26,7 @@ class BaseAudioContextHostObject : public JsiHostObject { JSI_PROPERTY_GETTER_DECL(state); JSI_PROPERTY_GETTER_DECL(sampleRate); JSI_PROPERTY_GETTER_DECL(currentTime); + JSI_PROPERTY_GETTER_DECL(baseLatency); JSI_HOST_FUNCTION_DECL(createWorkletSourceNode); JSI_HOST_FUNCTION_DECL(createWorkletNode); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp index da4465e54..21e2c6a09 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.cpp @@ -39,7 +39,8 @@ AudioRecorderHostObject::AudioRecorderHostObject( JSI_EXPORT_FUNCTION(AudioRecorderHostObject, clearOnAudioReady), JSI_EXPORT_FUNCTION(AudioRecorderHostObject, setOnError), JSI_EXPORT_FUNCTION(AudioRecorderHostObject, clearOnError), - JSI_EXPORT_FUNCTION(AudioRecorderHostObject, getCurrentDuration)); + JSI_EXPORT_FUNCTION(AudioRecorderHostObject, getCurrentDuration), + JSI_EXPORT_FUNCTION(AudioRecorderHostObject, getInputLatency)); } JSI_HOST_FUNCTION_IMPL(AudioRecorderHostObject, start) { @@ -196,4 +197,8 @@ JSI_HOST_FUNCTION_IMPL(AudioRecorderHostObject, getCurrentDuration) { return jsi::Value(duration); } +JSI_HOST_FUNCTION_IMPL(AudioRecorderHostObject, getInputLatency) { + return jsi::Value(audioRecorder_->getInputLatency()); +} + } // namespace audioapi diff --git a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h index de79e819a..d26235bc6 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/HostObjects/inputs/AudioRecorderHostObject.h @@ -36,6 +36,7 @@ class AudioRecorderHostObject : public JsiHostObject { JSI_HOST_FUNCTION_DECL(clearOnError); JSI_HOST_FUNCTION_DECL(getCurrentDuration); + JSI_HOST_FUNCTION_DECL(getInputLatency); private: std::shared_ptr audioRecorder_; 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 710bb1a34..027793370 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 @@ -101,6 +101,22 @@ bool AudioContext::isDriverRunning() const { return audioPlayer_->isRunning(); } +double AudioContext::getBaseLatency() const { + if (audioPlayer_ == nullptr) { + return 0.0; + } + + return audioPlayer_->getBaseLatency(); +} + +double AudioContext::getOutputLatency() const { + if (audioPlayer_ == nullptr) { + return 0.0; + } + + return audioPlayer_->getOutputLatency(); +} + std::shared_ptr AudioContext::createMediaElementSource( const std::shared_ptr &fileSource) { 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 d163d9d30..03642332f 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 @@ -30,6 +30,9 @@ class AudioContext : public BaseAudioContext { bool start(); void initialize() override; + [[nodiscard]] double getBaseLatency() const override; + [[nodiscard]] double getOutputLatency() const; + std::shared_ptr createMediaElementSource( const std::shared_ptr &fileSource); diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp index cda938ac7..6f875af35 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.cpp @@ -72,6 +72,10 @@ double BaseAudioContext::getCurrentTime() const { return destination_->getCurrentTime(); } +double BaseAudioContext::getBaseLatency() const { + return 0.0; +} + std::shared_ptr BaseAudioContext::getDestination() const { return destination_; } diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h index 935e8d5b1..0d6429854 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/BaseAudioContext.h @@ -70,6 +70,7 @@ class BaseAudioContext : public std::enable_shared_from_this { ContextState getState(); [[nodiscard]] float getSampleRate() const; [[nodiscard]] double getCurrentTime() const; + [[nodiscard]] virtual double getBaseLatency() const; [[nodiscard]] std::size_t getCurrentSampleFrame() const; [[nodiscard]] std::shared_ptr getDestination() const; diff --git a/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/AudioRecorder.h b/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/AudioRecorder.h index 9e0c4248b..c878a6cf2 100644 --- a/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/AudioRecorder.h +++ b/packages/react-native-audio-api/common/cpp/audioapi/core/inputs/AudioRecorder.h @@ -61,6 +61,8 @@ class AudioRecorder { virtual bool isPaused() const = 0; virtual bool isIdle() const = 0; + [[nodiscard]] virtual double getInputLatency() const = 0; + protected: bool wantsCallback() const; bool wantsFileOutput() const; diff --git a/packages/react-native-audio-api/common/cpp/test/src/core/OfflineAudioContextLatencyTest.cpp b/packages/react-native-audio-api/common/cpp/test/src/core/OfflineAudioContextLatencyTest.cpp new file mode 100644 index 000000000..28c6751ba --- /dev/null +++ b/packages/react-native-audio-api/common/cpp/test/src/core/OfflineAudioContextLatencyTest.cpp @@ -0,0 +1,15 @@ +#include +#include +#include +#include +#include + +using namespace audioapi; + +TEST(OfflineAudioContextLatencyTest, BaseLatencyIsZero) { + auto eventRegistry = std::make_shared(); + auto context = + std::make_shared(2, 44100, 44100.0f, eventRegistry, RuntimeRegistry{}); + + EXPECT_DOUBLE_EQ(context->getBaseLatency(), 0.0); +} 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 ced8ce26a..ddab41f51 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 @@ -32,6 +32,9 @@ class IOSAudioPlayer { bool isRunning() const; + [[nodiscard]] double getBaseLatency() const; + [[nodiscard]] double getOutputLatency() const; + private: void clearPendingSaved(); /// Audio-thread only. Always pulls the graph in steps of RENDER_QUANTUM_SIZE; if the system @@ -42,8 +45,11 @@ class IOSAudioPlayer { std::shared_ptr audioBuffer_; NativeAudioPlayer *audioPlayer_; std::function, int)> renderAudio_; + float sampleRate_; int channelCount_; std::atomic isRunning_; + /// Updated on the audio thread from each render callback `numFrames` (e.g. 512 at 48 kHz). + std::atomic lastCallbackFrameCount_{0}; /// Set from main thread on start/resume; consumed on audio thread to drop stale pending audio. std::atomic flushOverflowNextPull_{false}; /// Frames valid at the front of each `pendingSaved_[ch]` (0 … RENDER_QUANTUM_SIZE). diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioPlayer.mm index 42e9aa2de..9a273f55f 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 @@ -6,6 +6,7 @@ #include #include #include +#include #include namespace audioapi { @@ -17,6 +18,7 @@ : audioBuffer_(nullptr), audioPlayer_(nullptr), renderAudio_(renderAudio), + sampleRate_(sampleRate), channelCount_(channelCount), isRunning_(false), pendingSaved_(RENDER_QUANTUM_SIZE, channelCount_, sampleRate) @@ -44,6 +46,10 @@ void IOSAudioPlayer::deliverOutputBuffers(AudioBufferList *outputData, int numFrames) { + if (numFrames > 0) { + lastCallbackFrameCount_.store(numFrames, std::memory_order_release); + } + // If requested, clear any saved overflow before continuing normal rendering. if (flushOverflowNextPull_.exchange(false, std::memory_order_acq_rel)) { clearPendingSaved(); @@ -127,6 +133,7 @@ void IOSAudioPlayer::stop() { isRunning_.store(false, std::memory_order_release); + lastCallbackFrameCount_.store(0, std::memory_order_release); [audioPlayer_ stop]; } @@ -165,4 +172,29 @@ audioBuffer_ = nullptr; } +double IOSAudioPlayer::getBaseLatency() const +{ + if (!isRunning()) { + return 0.0; + } + + const int32_t callbackFrames = lastCallbackFrameCount_.load(std::memory_order_acquire); + if (callbackFrames > 0 && sampleRate_ > 0.0f) { + return static_cast(callbackFrames) / static_cast(sampleRate_); + } + + AudioSessionManager *sessionManager = [AudioSessionManager sharedInstance]; + return [sessionManager ioBufferDurationSeconds]; +} + +double IOSAudioPlayer::getOutputLatency() const +{ + if (!isRunning()) { + return 0.0; + } + + AudioSessionManager *sessionManager = [AudioSessionManager sharedInstance]; + return [sessionManager outputLatencySeconds]; +} + } // namespace audioapi diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h index 57a11f8d3..b3734ed35 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h @@ -12,6 +12,8 @@ typedef struct objc_object NativeAudioRecorder; #include #include +#include +#include #include #include #include @@ -53,6 +55,8 @@ class IOSAudioRecorder : public AudioRecorder { uint64_t callbackId) override; void clearOnAudioReadyCallback() override; + [[nodiscard]] double getInputLatency() const override; + protected: NativeAudioRecorder *nativeRecorder_; @@ -64,6 +68,9 @@ class IOSAudioRecorder : public AudioRecorder { const std::string &fileNameOverride = ""); std::vector recordingSegmentPaths_; + float streamSampleRate_{0.0f}; + /// Updated on the audio thread from each input callback `numFrames`. + std::atomic lastCallbackFrameCount_{0}; }; } // namespace audioapi diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm index 7ca35bf89..38ed76790 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.mm @@ -107,6 +107,10 @@ static void cleanupStartedRecorder( : AudioRecorder(audioEventHandlerRegistry) { AudioReceiverBlock receiverBlock = ^(const AudioBufferList *inputBuffer, int numFrames) { + if (numFrames > 0) { + lastCallbackFrameCount_.store(numFrames, std::memory_order_release); + } + if (usesFileOutput()) { if (auto lock = Locker::tryLock(fileWriterMutex_)) { fileWriter_->writeAudioData(inputBuffer, numFrames); @@ -227,6 +231,8 @@ static void cleanupStartedRecorder( // Estimate the maximum input buffer lengths that can be expected from the sink node size_t maxInputBufferLength = [nativeRecorder_ getResolvedBufferSize]; + streamSampleRate_ = static_cast(recorderFormatSampleRate(inputFormat)); + lastCallbackFrameCount_.store(0, std::memory_order_release); bool fileWasOpened = false; if (wantsFileOutput()) { @@ -308,6 +314,8 @@ static void cleanupStartedRecorder( [nativeRecorder_ setInputArmed:false]; state_.store(RecorderState::Idle, std::memory_order_release); + lastCallbackFrameCount_.store(0, std::memory_order_release); + streamSampleRate_ = 0.0f; [nativeRecorder_ stop]; hadFileOutput = usesFileOutput(); @@ -618,4 +626,23 @@ static void cleanupStartedRecorder( dataCallback_ = nullptr; } +double IOSAudioRecorder::getInputLatency() const +{ + if (isIdle()) { + return 0.0; + } + + AudioSessionManager *sessionManager = [AudioSessionManager sharedInstance]; + + double baseLatency = 0.0; + const int32_t callbackFrames = lastCallbackFrameCount_.load(std::memory_order_acquire); + if (callbackFrames > 0 && streamSampleRate_ > 0.0f) { + baseLatency = static_cast(callbackFrames) / static_cast(streamSampleRate_); + } else { + baseLatency = [sessionManager ioBufferDurationSeconds]; + } + + return baseLatency + [sessionManager inputLatencySeconds]; +} + } // namespace audioapi 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 e001e7a48..6d26ab636 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 @@ -56,4 +56,8 @@ resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; +- (double)outputLatencySeconds; +- (double)inputLatencySeconds; +- (double)ioBufferDurationSeconds; + @end 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 941071838..b28134cb0 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 @@ -555,4 +555,20 @@ - (AVAudioSessionCategoryOptions)optionsFromArray:(NSArray *)optionsArray return options; } + +- (double)outputLatencySeconds +{ + return self.audioSession.outputLatency; +} + +- (double)inputLatencySeconds +{ + return self.audioSession.inputLatency; +} + +- (double)ioBufferDurationSeconds +{ + return self.audioSession.IOBufferDuration; +} + @end diff --git a/packages/react-native-audio-api/src/core/AudioContext.ts b/packages/react-native-audio-api/src/core/AudioContext.ts index c805e60fb..587b6c9b1 100644 --- a/packages/react-native-audio-api/src/core/AudioContext.ts +++ b/packages/react-native-audio-api/src/core/AudioContext.ts @@ -29,6 +29,14 @@ export default class AudioContext extends BaseAudioContext { ); } + public get baseLatency(): number { + return (this.context as IAudioContext).baseLatency; + } + + public get outputLatency(): number { + return (this.context as IAudioContext).outputLatency; + } + async close(): Promise { return (this.context as IAudioContext).close(); } diff --git a/packages/react-native-audio-api/src/core/AudioRecorder.ts b/packages/react-native-audio-api/src/core/AudioRecorder.ts index b9b84715c..1dfa157c6 100644 --- a/packages/react-native-audio-api/src/core/AudioRecorder.ts +++ b/packages/react-native-audio-api/src/core/AudioRecorder.ts @@ -198,6 +198,10 @@ export default class AudioRecorder { return this.recorder.getCurrentDuration(); } + get inputLatency(): number { + return this.recorder.getInputLatency(); + } + onError(callback: (error: OnRecorderErrorEventType) => void): void { if (this.onErrorSubscription) { this.recorder.clearOnError(); diff --git a/packages/react-native-audio-api/src/interfaces.ts b/packages/react-native-audio-api/src/interfaces.ts index fa8cd2576..4ae077445 100644 --- a/packages/react-native-audio-api/src/interfaces.ts +++ b/packages/react-native-audio-api/src/interfaces.ts @@ -110,6 +110,8 @@ export interface IBaseAudioContext { } export interface IAudioContext extends IBaseAudioContext { + readonly baseLatency: number; + readonly outputLatency: number; createMediaElementSource: ( mediaElement: IAudioFileSourceNode ) => IMediaElementAudioSourceNode; @@ -359,6 +361,7 @@ export interface IAudioRecorder { getCurrentDuration: () => number; getFilePath: () => string | null; + getInputLatency: () => number; } export interface IAudioDecoder { diff --git a/packages/react-native-audio-api/src/mock/index.ts b/packages/react-native-audio-api/src/mock/index.ts index 7fedef3f6..63141620b 100644 --- a/packages/react-native-audio-api/src/mock/index.ts +++ b/packages/react-native-audio-api/src/mock/index.ts @@ -562,6 +562,10 @@ class BaseAudioContextMock { return this._state; } + get baseLatency(): number { + return 0.005; + } + createBuffer( numberOfChannels: number, length: number, @@ -683,6 +687,10 @@ class AudioContextMock extends BaseAudioContextMock { super(options); } + get outputLatency(): number { + return 0.01; + } + close(): Promise { this._state = 'closed'; return Promise.resolve(); @@ -824,6 +832,10 @@ class AudioRecorderMock { return this._currentDuration; } + getInputLatency(): number { + return 0.01; + } + onError( callback: (error: Record | undefined) => void ): void { 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 235e857db..e8f5c8ccc 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 @@ -48,6 +48,14 @@ export default class AudioContext implements BaseAudioContext { return this.context.state as ContextState; } + public get baseLatency(): number { + return this.context.baseLatency; + } + + public get outputLatency(): number { + return this.context.outputLatency; + } + createOscillator(): OscillatorNode { return new OscillatorNode(this); } diff --git a/packages/react-native-audio-api/tests/mock.test.ts b/packages/react-native-audio-api/tests/mock.test.ts index 48e868c78..8aa8eea96 100644 --- a/packages/react-native-audio-api/tests/mock.test.ts +++ b/packages/react-native-audio-api/tests/mock.test.ts @@ -9,6 +9,8 @@ describe('React Native Audio API Mocks', () => { expect(context.sampleRate).toBe(44100); expect(context.currentTime).toBe(0); expect(context.state).toBe('running'); + expect(context.baseLatency).toBe(0.005); + expect(context.outputLatency).toBe(0.01); expect(context.destination).toBeInstanceOf(MockAPI.AudioDestinationNode); }); @@ -42,6 +44,7 @@ describe('React Native Audio API Mocks', () => { expect(context.sampleRate).toBe(44100); expect(context.length).toBe(44100); + expect(context.baseLatency).toBe(0.005); }); it('should start rendering and return an AudioBuffer', async () => { @@ -254,6 +257,7 @@ describe('React Native Audio API Mocks', () => { expect(recorder.isRecording()).toBe(false); expect(recorder.isPaused()).toBe(false); expect(recorder.getCurrentDuration()).toBe(0); + expect(recorder.getInputLatency()).toBe(0.01); expect(recorder.options).toBeNull(); }); From e060df0e07ff4eae806a3c649fbffcf6d3b691b3 Mon Sep 17 00:00:00 2001 From: michal Date: Fri, 12 Jun 2026 14:16:12 +0200 Subject: [PATCH 2/8] feat: initial implementation of audio latencies From 6c1b65f6d6f1456abfc5d6702db30c38bdeb8636 Mon Sep 17 00:00:00 2001 From: Marek Malek Date: Mon, 29 Jun 2026 13:27:13 +0200 Subject: [PATCH 3/8] fix: resolve minors --- .../main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp | 2 +- .../src/main/cpp/audioapi/android/core/AndroidAudioRecorder.h | 2 +- .../src/main/cpp/audioapi/android/core/AudioPlayer.cpp | 4 ++-- .../ios/audioapi/ios/core/IOSAudioRecorder.h | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp index 68e9c106d..faade0a7e 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.cpp @@ -546,7 +546,7 @@ double AndroidAudioRecorder::getInputLatency() const { baseLatency = static_cast(callbackFrames) / static_cast(streamSampleRate_); } else { const int32_t framesPerBurst = mStream_->getFramesPerBurst(); - if (framesPerBurst > 0) { + if (framesPerBurst > 0 && streamSampleRate_ > 0.0f) { baseLatency = static_cast(framesPerBurst) / static_cast(streamSampleRate_); } } diff --git a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.h b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.h index f7f92c365..899081c81 100644 --- a/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.h +++ b/packages/react-native-audio-api/android/src/main/cpp/audioapi/android/core/AndroidAudioRecorder.h @@ -59,7 +59,7 @@ class AndroidAudioRecorder : public oboe::AudioStreamCallback, private: std::shared_ptr deinterleavingBuffer_; - float streamSampleRate_; + std::atomic streamSampleRate_; int32_t streamChannelCount_; int32_t streamMaxBufferSizeInFrames_; 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 bb37d9cb9..866822015 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 @@ -172,7 +172,7 @@ void AudioPlayer::onErrorAfterClose(oboe::AudioStream *stream, oboe::Result erro } double AudioPlayer::getBaseLatency() const { - if (mStream_ == nullptr || !isInitialized_ || !isRunning()) { + if (mStream_ == nullptr || !isInitialized_.load(std::memory_order_acquire) || !isRunning()) { return 0.0; } @@ -190,7 +190,7 @@ double AudioPlayer::getBaseLatency() const { } double AudioPlayer::getOutputLatency() const { - if (mStream_ == nullptr || !isInitialized_ || !isRunning()) { + if (mStream_ == nullptr || !isInitialized_.load(std::memory_order_acquire) || !isRunning()) { return 0.0; } return lastOutputLatencySeconds_.load(std::memory_order_acquire); diff --git a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h index 65d0789c2..02eadd577 100644 --- a/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h +++ b/packages/react-native-audio-api/ios/audioapi/ios/core/IOSAudioRecorder.h @@ -65,7 +65,7 @@ class IOSAudioRecorder : public AudioRecorder { void rollbackFailedStart(); std::vector recordingSegmentPaths_; - float streamSampleRate_{0.0f}; + std::atomic streamSampleRate_{0.0f}; /// Updated on the audio thread from each input callback `numFrames`. std::atomic lastCallbackFrameCount_{0}; }; From 3624e2fc7312ebe1e520ee740fefa418351cbeb9 Mon Sep 17 00:00:00 2001 From: Marek Malek Date: Mon, 29 Jun 2026 13:40:43 +0200 Subject: [PATCH 4/8] feat: add test screen for latencies --- apps/common-app/src/demos/index.ts | 5 +- apps/common-app/src/examples/index.ts | 1 + .../src/other/LatencyMeter/LatencyMeter.tsx | 125 ++++++++++++++++++ apps/common-app/src/other/index.ts | 7 + apps/fabric-example/ios/Podfile.lock | 6 +- 5 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 apps/common-app/src/other/LatencyMeter/LatencyMeter.tsx diff --git a/apps/common-app/src/demos/index.ts b/apps/common-app/src/demos/index.ts index 8f1d989b8..c338d6f7d 100644 --- a/apps/common-app/src/demos/index.ts +++ b/apps/common-app/src/demos/index.ts @@ -37,9 +37,8 @@ export const demos: DemoScreen[] = [ { key: 'Crossfade', title: 'Crossfade', - subtitle: - 'Demonstrates crossfading between two audio files.', + subtitle: 'Demonstrates crossfading between two audio files.', icon: icons.ArrowLeftRight, screen: Crossfade, - } + }, ] as const; diff --git a/apps/common-app/src/examples/index.ts b/apps/common-app/src/examples/index.ts index d664f93b1..2572dd90d 100644 --- a/apps/common-app/src/examples/index.ts +++ b/apps/common-app/src/examples/index.ts @@ -31,6 +31,7 @@ type NavigationParamList = { ConvolverIR: undefined; AudioParamPipeline: undefined; TestScreen: undefined; + LatencyMeter: undefined; }; export type ExampleKey = keyof NavigationParamList; diff --git a/apps/common-app/src/other/LatencyMeter/LatencyMeter.tsx b/apps/common-app/src/other/LatencyMeter/LatencyMeter.tsx new file mode 100644 index 000000000..5b438e931 --- /dev/null +++ b/apps/common-app/src/other/LatencyMeter/LatencyMeter.tsx @@ -0,0 +1,125 @@ +import React, { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { Platform, StyleSheet, Text, View } from 'react-native'; +import { AudioManager } from 'react-native-audio-api'; + +import { Button, Container } from '../../components'; +import { audioContext, audioRecorder } from '../../singletons'; +import { colors } from '../../styles'; + +interface Latencies { + base: number; + output: number; + input: number; +} + +const fmt = (s: number) => `${(s * 1000).toFixed(2)} ms`; + +const LatencyMeter: FC = () => { + const [isRunning, setIsRunning] = useState(false); + const [latencies, setLatencies] = useState(null); + const intervalRef = useRef | null>(null); + + const readLatencies = useCallback(() => { + setLatencies({ + base: audioContext.baseLatency, + output: audioContext.outputLatency, + input: audioRecorder.inputLatency, + }); + }, []); + + const start = useCallback(async () => { + const status = await AudioManager.requestRecordingPermissions(); + if (status !== 'Granted') { + return; + } + + if (Platform.OS !== 'web') { + AudioManager.setAudioSessionOptions({ + iosCategory: 'playAndRecord', + iosMode: 'measurement', + iosOptions: ['allowBluetooth'], + }); + await AudioManager.setAudioSessionActivity(true); + } + + if (audioContext.state === 'suspended') { + await audioContext.resume(); + } + + await audioRecorder.start(); + setIsRunning(true); + readLatencies(); + intervalRef.current = setInterval(readLatencies, 500); + }, [readLatencies]); + + const stop = useCallback(async () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + await audioRecorder.stop(); + if (Platform.OS !== 'web') { + await AudioManager.setAudioSessionActivity(false); + } + setIsRunning(false); + }, []); + + useEffect(() => { + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, []); + + return ( + +