Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
98f8631
feat: initial implementation of audio latencies
mdydek Jun 12, 2026
e060df0
feat: initial implementation of audio latencies
mdydek Jun 12, 2026
a495e92
chore: merge
closetcaiman Jun 22, 2026
79f6b32
chore: merge
closetcaiman Jun 29, 2026
6c1b65f
fix: resolve minors
closetcaiman Jun 29, 2026
3624e2f
feat: add test screen for latencies
closetcaiman Jun 29, 2026
ae3e54b
docs: update desc, cov table
closetcaiman Jun 29, 2026
4b3279a
fix: add missing import
closetcaiman Jun 29, 2026
5905313
fix: better desc
closetcaiman Jun 29, 2026
e9e6e4e
fix: better desc for inputLatency
closetcaiman Jun 29, 2026
8efe4ab
chore: merge
closetcaiman Jul 8, 2026
88829dd
fix: latencies interface
closetcaiman Jul 8, 2026
9df3f01
fix: out-of-line impl in BaseAudioContext
closetcaiman Jul 8, 2026
2469eaa
fix: remove OfflineAudioContext test
closetcaiman Jul 8, 2026
651ad20
chore: merge
closetcaiman Jul 9, 2026
3ef96ae
chore(ios): update pods
closetcaiman Jul 9, 2026
8e0225e
fix: host objects
closetcaiman Jul 9, 2026
4075912
fix: review changes
closetcaiman Jul 10, 2026
68e07fa
chore: merge
closetcaiman Jul 10, 2026
d7e5d6b
cMerge branch 'main' into feat/audio-latency
mdydek Jul 13, 2026
19adc0d
fix(ios): baseLatency
closetcaiman Jul 13, 2026
bc05cfb
fix(android): oboe latency calculation flow
closetcaiman Jul 14, 2026
8172944
chore: remove unused code
closetcaiman Jul 14, 2026
ed9e230
chore: remove unused header
closetcaiman Jul 14, 2026
313ebac
fix!: mStream_ race condidtion
closetcaiman Jul 14, 2026
3442244
chore: merge
closetcaiman Jul 14, 2026
4e837c4
docs: update desc
closetcaiman Jul 14, 2026
41e5fe4
test: latency screen
mdydek Jul 14, 2026
2a3629d
Merge branch 'main' into feat/audio-latency
closetcaiman Jul 14, 2026
643f66b
fix: wsola test
closetcaiman Jul 14, 2026
0c78cbf
docs: remove MobileOnly badge
closetcaiman Jul 14, 2026
6c7fd72
fix: add boilerplate thread offloading
closetcaiman Jul 15, 2026
a7aa28d
refactor: thread management
closetcaiman Jul 16, 2026
f487d00
fix: revert comment removal
closetcaiman Jul 16, 2026
b6cfe5d
fix: review changes
closetcaiman Jul 16, 2026
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;
LatencyValidation: undefined;
};

export type ExampleKey = keyof NavigationParamList;
Expand Down
149 changes: 149 additions & 0 deletions apps/common-app/src/other/LatencyValidation/CorrelationPlot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import React, { FC, useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import Svg, { Circle, Line, Path, Rect } from 'react-native-svg';

import { colors } from '../../styles';
import type { CorrelationPoint } from './types';

interface CorrelationPlotProps {
title: string;
caption?: string;
width: number;
height?: number;
points: CorrelationPoint[];
bestLagMs: number | null;
expectedLagMs: number;
}

const CorrelationPlot: FC<CorrelationPlotProps> = ({
title,
caption,
width,
height = 120,
points,
bestLagMs,
expectedLagMs,
}) => {
const { path, bestX, expectedX, maxScore } = useMemo(() => {
if (points.length === 0) {
return { path: '', bestX: null, expectedX: null, maxScore: 1 };
}

const minLag = points[0].lagMs;
const maxLag = points[points.length - 1].lagMs;
const lagSpan = Math.max(maxLag - minLag, 1);
const maxScoreValue = Math.max(...points.map((point) => point.score), 0.001);

const builtPath = points
.map((point, index) => {
const x = ((point.lagMs - minLag) / lagSpan) * width;
const y = height - (point.score / maxScoreValue) * (height * 0.82) - 8;
return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(' ');

return {
path: builtPath,
bestX:
bestLagMs !== null
? ((bestLagMs - minLag) / lagSpan) * width
: null,
expectedX: ((expectedLagMs - minLag) / lagSpan) * width,
maxScore: maxScoreValue,
};
}, [bestLagMs, expectedLagMs, height, points, width]);

return (
<View style={styles.container}>
<Text style={styles.title}>{title}</Text>
{caption ? <Text style={styles.caption}>{caption}</Text> : null}

<View style={[styles.plotFrame, { width, height }]}>
<Svg width={width} height={height}>
<Rect
x={0}
y={0}
width={width}
height={height}
fill={colors.backgroundDark}
rx={4}
/>

{expectedX !== null ? (
<Line
x1={expectedX}
y1={0}
x2={expectedX}
y2={height}
stroke={colors.yellow}
strokeDasharray="4 4"
strokeWidth={1}
/>
) : null}

{bestX !== null ? (
<Line
x1={bestX}
y1={0}
x2={bestX}
y2={height}
stroke={colors.main}
strokeWidth={1.5}
/>
) : null}

<Path d={path} stroke={colors.main} strokeWidth={2} fill="none" />

{bestX !== null ? (
<Circle cx={bestX} cy={12} r={4} fill={colors.main} />
) : null}
</Svg>
</View>

<View style={styles.legendRow}>
<Text style={styles.legendText}>Peak score max={maxScore.toFixed(3)}</Text>
<Text style={[styles.legendText, { color: colors.main }]}>
Best lag {bestLagMs !== null ? `${bestLagMs.toFixed(1)} ms` : '—'}
</Text>
<Text style={[styles.legendText, { color: colors.yellow }]}>
Expected {expectedLagMs.toFixed(1)} ms
</Text>
</View>
</View>
);
};

const styles = StyleSheet.create({
caption: {
color: colors.gray,
fontSize: 12,
lineHeight: 17,
marginBottom: 8,
},
container: {
backgroundColor: colors.backgroundLight,
borderRadius: 8,
gap: 8,
padding: 12,
},
legendRow: {
gap: 4,
},
legendText: {
color: colors.gray,
fontSize: 11,
},
plotFrame: {
borderColor: colors.separator,
borderRadius: 4,
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
title: {
color: colors.white,
fontSize: 15,
fontWeight: '700',
},
});

export default CorrelationPlot;
198 changes: 198 additions & 0 deletions apps/common-app/src/other/LatencyValidation/LatencyValidation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import React, { FC, useEffect, useRef, useState } from 'react';
import { Platform, ScrollView, StyleSheet } from 'react-native';
import { AudioManager } from 'react-native-audio-api';

import { Container } from '../../components';
import { audioContext, audioRecorder } from '../../singletons';
import { TestUI, type ControlAction } from '../../testComponents';
import { formatTimestamp } from './helpers';
import {
cleanupLatencyValidation,
runLatencyValidationSuite,
} from './latencyTests';
import LoopbackAnalysisPanel from './LoopbackAnalysisPanel';
import type { LoopbackAnalysis } from './types';

type RunnerState = 'idle' | 'running';

const LatencyValidation: FC = () => {
const isMountedRef = useRef(true);
const [runnerState, setRunnerState] = useState<RunnerState>('idle');
const [currentStep, setCurrentStep] = useState(
'Ready to run the speaker-to-mic loopback latency check.'
);
const [analysis, setAnalysis] = useState<LoopbackAnalysis | null>(null);
const [liveLog, setLiveLog] = useState<string[]>([]);

const appendLog = (message: string) => {
if (!isMountedRef.current) {
return;
}

setLiveLog((previous) => [
...previous,
`${formatTimestamp(Date.now())} ${message}`,
]);
};

useEffect(() => {
return () => {
isMountedRef.current = false;
cleanupLatencyValidation({ audioContext, audioRecorder }).catch((error) => {
console.warn('LatencyValidation cleanup failed', error);
});
};
}, []);

const runTest = async () => {
if (runnerState === 'running') {
return;
}

setRunnerState('running');
setAnalysis(null);
setLiveLog([]);
setCurrentStep('Preparing audio session...');
appendLog('Starting speaker-to-mic loopback check.');

try {
const permission = await AudioManager.requestRecordingPermissions();
if (permission !== 'Granted') {
throw new Error('Recording permission was not granted.');
}

if (Platform.OS !== 'web') {
AudioManager.setAudioSessionOptions({
iosCategory: 'playAndRecord',
// 'measurement' keeps the signal path raw (no AGC/EQ) so the loopback
// delay is accurate, while running the mic+speaker for the shortest
// possible window to keep the idle full-duplex buzz to a minimum.
iosMode: 'measurement',
iosOptions: ['defaultToSpeaker'],
});
await AudioManager.setAudioSessionActivity(true);
}

setCurrentStep('Playing beep pattern and analyzing microphone capture...');
appendLog('Reference pattern scheduled. Recording microphone input.');

const result = await runLatencyValidationSuite({
audioContext,
audioRecorder,
});

if (!isMountedRef.current) {
return;
}

setAnalysis(result);
appendLog(
`Captured ${result.recordedWindowPlot.points.length > 0 ? 'microphone' : 'no'} waveform points for visualization.`
);

if (result.scenario.status === 'pass') {
setCurrentStep(
'Analysis complete: aligned waveform matches the reported latencies.'
);
} else if (result.scenario.status === 'skipped') {
setCurrentStep(
'Analysis complete: microphone capture did not contain a matchable beep pattern.'
);
} else {
setCurrentStep(
'Analysis complete: waveform alignment exists but latency delta is outside tolerance.'
);
}

appendLog(`Test finished with status=${result.scenario.status}.`);
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown validation error';
setCurrentStep(`Loopback check failed: ${message}`);
appendLog(`Test failed: ${message}`);
} finally {
await cleanupLatencyValidation({ audioContext, audioRecorder });

if (isMountedRef.current) {
setRunnerState('idle');
}
}
};

const resetTest = async () => {
if (runnerState === 'running') {
return;
}

setAnalysis(null);
setLiveLog([]);
setCurrentStep('Ready to run the speaker-to-mic loopback latency check.');
await cleanupLatencyValidation({ audioContext, audioRecorder });
};

if (Platform.OS === 'web') {
return (
<Container centered>
<TestUI.UnsupportedNotice
title="Latency Validation"
message="The speaker-to-mic loopback check requires native audio I/O and is not available on web."
/>
</Container>
);
}

const controlActions: ControlAction[] = [
{
title: 'Run Test',
onPress: () => {
runTest();
},
disabled: runnerState === 'running',
width: 120,
},
{
title: 'Reset',
onPress: () => {
resetTest();
},
disabled: runnerState === 'running',
width: 90,
},
];

return (
<Container disablePadding>
<TestUI.Header
title="Latency Validation"
subtitle="Visual loopback analysis: generated beep pattern, microphone capture, aligned overlay, and correlation search."
/>
<TestUI.CurrentStepCard message={currentStep} />
<TestUI.ControlBar actions={controlActions} />

<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
<LoopbackAnalysisPanel analysis={analysis} />
<TestUI.LiveLog
entries={liveLog}
emptyMessage="Live log will appear here while the test runs."
/>
</ScrollView>
</Container>
);
};

const styles = StyleSheet.create({
scrollContent: {
gap: 12,
paddingBottom: 32,
paddingHorizontal: 18,
paddingTop: 18,
},
scrollView: {
flex: 1,
},
});

export default LatencyValidation;
Loading
Loading