Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions apps/common-app/src/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
1 change: 1 addition & 0 deletions apps/common-app/src/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type NavigationParamList = {
ConvolverIR: undefined;
AudioParamPipeline: undefined;
TestScreen: undefined;
LatencyMeter: undefined;
};

export type ExampleKey = keyof NavigationParamList;
Expand Down
125 changes: 125 additions & 0 deletions apps/common-app/src/other/LatencyMeter/LatencyMeter.tsx
Original file line number Diff line number Diff line change
@@ -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<Latencies | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | 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 (
<Container centered>
<Button
title={isRunning ? 'Stop' : 'Start'}
onPress={isRunning ? stop : start}
/>
{latencies && (
<View style={styles.table}>
{(
[
['Base latency', latencies.base],
['Output latency', latencies.output],
['Input latency', latencies.input],
] as [string, number][]
).map(([label, value]) => (
<View key={label} style={styles.row}>
<Text style={styles.label}>{label}</Text>
<Text style={styles.value}>{fmt(value)}</Text>
</View>
))}
</View>
)}
</Container>
);
};

const styles = StyleSheet.create({
table: {
marginTop: 32,
width: '100%',
paddingHorizontal: 24,
},
row: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
},
label: {
fontSize: 16,
color: colors.white,
},
value: {
fontSize: 16,
fontVariant: ['tabular-nums'],
color: colors.main,
},
});

export default LatencyMeter;
7 changes: 7 additions & 0 deletions apps/common-app/src/other/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { TestScreen } from '../../../../packages/test-app-screen';

import AudioParamPipeline from './AudioParamPipeline';
import AudioPipelineStress from './AudioPipelineStress';
import LatencyMeter from './LatencyMeter/LatencyMeter';

/** Screens shown under the 'Other' bottom tab (stress tests, internal tooling). */
export const otherScreens: Example[] = [
Expand All @@ -26,4 +27,10 @@ export const otherScreens: Example[] = [
Icon: icons.TestTube,
screen: TestScreen,
},
{
key: 'LatencyMeter',
title: 'Latency Meter',
Icon: icons.Timer,
screen: LatencyMeter,
},
];
6 changes: 3 additions & 3 deletions apps/fabric-example/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2476,7 +2476,7 @@ EXTERNAL SOURCES:

SPEC CHECKSUMS:
FBLazyVector: c00c20551d40126351a6783c47ce75f5b374851b
hermes-engine: 91023181d4bc5948b457de5314623fbfe4f8604e
hermes-engine: 146211e12d60a1951d9eb0287be07211e86cf5d5
RCTDeprecation: 3bb167081b134461cfeb875ff7ae1945f8635257
RCTRequired: 74839f55d5058a133a0bc4569b0afec750957f64
RCTSwiftUI: 87a316382f3eab4dd13d2a0d0fd2adcce917361a
Expand All @@ -2485,7 +2485,7 @@ SPEC CHECKSUMS:
React: 1b1536b9099195944034e65b1830f463caaa8390
React-callinvoker: 6dff6d17d1d6cc8fdf85468a649bafed473c65f5
React-Core: 00faa4d038298089a1d5a5b21dde8660c4f0820d
React-Core-prebuilt: a6d614de037caff7898424dfc22915ec792de921
React-Core-prebuilt: ef40616103ee11f8c2517697c3aa4f48ce790549
React-CoreModules: a17807f849bfd86045b0b9a75ec8c19373b482f6
React-cxxreact: c7b53ace5827be54048288bce5c55f337c41e95f
React-debug: e1f00fcd2cef58a2897471a6d76a4ef5f5f90c74
Expand Down Expand Up @@ -2549,7 +2549,7 @@ SPEC CHECKSUMS:
ReactAppDependencyProvider: 5787b37b8e2e51dfeab697ec031cc7c4080dcea2
ReactCodegen: d07ee3c8db75b43d1cbe479ae6affebf9925c733
ReactCommon: fe2a3af8975e63efa60f95fca8c34dc85deee360
ReactNativeDependencies: 4d5ce2683b6d74f7c686bf90a88c7d381295cf3c
ReactNativeDependencies: 54189f1570b1308686cb21564e755e1daa77ea03
RNAudioAPI: 2a3201bc5b9df56656b5061164ed9ffc885eb595
RNGestureHandler: 187c5c7936abf427bc4d22d6c3b1ac80ad1f63c0
RNReanimated: 64f4b3b33b48b19e0ba76a352571b52b1e931981
Expand Down
10 changes: 8 additions & 2 deletions packages/audiodocs/docs/core/audio-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
sidebar_position: 2
---

import { ReadOnly } from '@site/src/components/Badges';

# AudioContext

The `AudioContext` interface inherits from [`BaseAudioContext`](/docs/core/base-audio-context).
Expand All @@ -25,8 +27,12 @@ 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 | |
| :----: | :----: | :-------- | :-: |
| `baseLatency` | `number` | Processing latency in seconds incurred by the `AudioContext` passing audio from the `AudioDestinationNode` to the audio subsystem. Does not include hardware output latency or audio graph processing time. Equals the render callback duration (`callbackFrames / sampleRate`). Before the first callback fires, falls back to `AVAudioSession.IOBufferDuration` (iOS) or Oboe `framesPerBurst / sampleRate` (Android). Returns `0` when the context is not running. | <ReadOnly /> |
| `outputLatency` | `number` | Estimated output latency in seconds — the interval between when the audio system is given a buffer and when the first sample is played by the output device. Can change when the audio route changes; re-query before sync-sensitive work. Returns `0` when the context is not running. On iOS backed by `AVAudioSession.outputLatency`; on Android sampled via Oboe timestamps. | <ReadOnly /> |

`AudioContext` inherits all properties from [`BaseAudioContext`](/docs/core/base-audio-context#properties).


## Methods
Expand Down
20 changes: 20 additions & 0 deletions packages/audiodocs/docs/inputs/audio-recorder.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,26 @@ export default MyRecorder;
```
</td>
</tr>
<!-- -->
<tr>
<td><div class="text-content">
##### inputLatency <MobileOnly />
</div></td>
<td>
<div class="text-content">
**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 iOS: `callbackFrames / sampleRate` (or `AVAudioSession.IOBufferDuration` before the first callback) plus `AVAudioSession.inputLatency`. On Android: `max(Oboe timestamp measurement, callbackFrames / sampleRate)` when Oboe timestamps are available; falls back to `callbackFrames / sampleRate + framesPerBurst / sampleRate`. 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);
```
</div>
```tsx
const inputLatency = recorder.inputLatency;
```
</td>
</tr>

</tbody>
</table>
Expand Down
2 changes: 1 addition & 1 deletion packages/audiodocs/docs/other/web-audio-api-coverage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ sidebar_position: 2
| PeriodicWave | ✅ |
| StereoPannerNode | ✅ |
| WaveShaperNode | ✅ |
| AudioContext | 🚧 | Available props and methods: `close`, `suspend`, `resume` |
| AudioContext | 🚧 | Available props and methods: `baseLatency`, `outputLatency`, `close`, `suspend`, `resume` |
| BaseAudioContext | 🚧 | Available props and methods: `currentTime`, `destination`, `sampleRate`, `state`, `decodeAudioData`, all create methods for available or partially implemented nodes |
| AudioListener | ❌ |
| AudioSinkInfo | ❌ |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#include <audioapi/utils/CircularArray.hpp>
#include <audioapi/utils/CircularOverflowableAudioArray.h>

#include <algorithm>
#include <memory>
#include <string>
#include <unordered_map>
Expand Down Expand Up @@ -185,6 +186,8 @@ AudioRecorder::StopResult 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
Expand Down Expand Up @@ -421,6 +424,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_;
Expand Down Expand Up @@ -522,4 +535,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<double>(callbackFrames) / static_cast<double>(streamSampleRate_);
} else {
const int32_t framesPerBurst = mStream_->getFramesPerBurst();
if (framesPerBurst > 0 && streamSampleRate_ > 0.0f) {
baseLatency = static_cast<double>(framesPerBurst) / static_cast<double>(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<double>(framesPerBurst) / static_cast<double>(streamSampleRate_);
}

return baseLatency;
}

} // namespace audioapi
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
#include <audioapi/utils/AudioBuffer.hpp>
#include <audioapi/utils/Result.hpp>
#include <oboe/Oboe.h>
#include <atomic>
#include <cstdint>
#include <memory>
#include <string>
#include <vector>
Expand Down Expand Up @@ -45,6 +47,8 @@ class AndroidAudioRecorder : public oboe::AudioStreamCallback,

void connect(const std::shared_ptr<RecorderAdapterNode> &node) 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;
Expand All @@ -55,14 +59,18 @@ class AndroidAudioRecorder : public oboe::AudioStreamCallback,
private:
std::shared_ptr<AudioBuffer> deinterleavingBuffer_;

float streamSampleRate_;
std::atomic<float> streamSampleRate_;
int32_t streamChannelCount_;
int32_t streamMaxBufferSizeInFrames_;

facebook::jni::global_ref<NativeAudioRecorder> nativeAudioRecorder_;

std::shared_ptr<oboe::AudioStream> mStream_;
std::vector<std::string> recordingSegmentPaths_;
/// Updated on the audio thread from each input callback `numFrames`.
std::atomic<int32_t> lastCallbackFrameCount_{0};
/// Full input latency (seconds) sampled at callback start via Oboe timestamps.
std::atomic<double> lastInputLatencySeconds_{0.0};
Result<NoneType, std::string> openAudioStream();
std::shared_ptr<AudioFileWriter> createFileWriter(
const std::shared_ptr<AudioFileProperties> &props);
Expand Down
Loading
Loading