Skip to content

Commit a7387b4

Browse files
committed
[RNE Rewrite] feat: add voice activity detection pipeline (#1249)
Port the VAD feature to the rewrite as a pure-TypeScript pipeline on top of the core model.execute primitive (no new C++): - src/extensions/speech/tasks/vad.ts: createVAD runner replicating the native FSMN-VAD algorithm (framing + Hann window + pre-emphasis, chunked inference, thresholding / min-duration / padding / merge). Segments are returned in seconds. Relies on the get_dynamic_dims relaxed input validation for the dynamic frame dimension; the fsmn-vad model is re-exported with it. - src/extensions/speech/vadStreamer.ts: pure streaming state machine driving onSpeechBegin / onSpeechEnd over an accumulating buffer. - src/hooks/useVAD.ts: hook wrapping createVAD + streamer lifecycle. - Register models.vad.FSMN_VAD and export the speech extension. - apps/speech: expo-router demo (mirrors apps/nlp) with a real-time mic VAD screen via react-native-audio-api.
1 parent ce78515 commit a7387b4

27 files changed

Lines changed: 1340 additions & 0 deletions

.cspell-wordlist.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,3 +278,6 @@ binarization
278278
bugprone
279279
NOLINTNEXTLINE
280280

281+
hann
282+
preemphasis
283+
coeff

apps/speech/app.json

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"expo": {
3+
"name": "speech",
4+
"slug": "speech",
5+
"version": "1.0.0",
6+
"orientation": "portrait",
7+
"icon": "./assets/icons/icon.png",
8+
"userInterfaceStyle": "light",
9+
"newArchEnabled": true,
10+
"scheme": "rne-speech",
11+
"splash": {
12+
"image": "./assets/icons/splash.png",
13+
"resizeMode": "contain",
14+
"backgroundColor": "#ffffff"
15+
},
16+
"ios": {
17+
"supportsTablet": true,
18+
"bundleIdentifier": "com.anonymous.speech",
19+
"infoPlist": {
20+
"NSMicrophoneUsageDescription": "This app uses the microphone to detect voice activity."
21+
}
22+
},
23+
"android": {
24+
"adaptiveIcon": {
25+
"foregroundImage": "./assets/icons/adaptive-icon.png",
26+
"backgroundColor": "#ffffff"
27+
},
28+
"package": "com.anonymous.speech",
29+
"permissions": [
30+
"android.permission.RECORD_AUDIO"
31+
]
32+
},
33+
"web": {
34+
"favicon": "./assets/icons/favicon.png"
35+
},
36+
"plugins": [
37+
"expo-router",
38+
[
39+
"expo-build-properties",
40+
{
41+
"android": {
42+
"minSdkVersion": 26
43+
},
44+
"ios": {
45+
"deploymentTarget": "17.0"
46+
}
47+
}
48+
]
49+
]
50+
}
51+
}

apps/speech/app/_layout.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Drawer } from 'expo-router/drawer';
2+
import { ColorPalette } from '../theme';
3+
import React from 'react';
4+
5+
export default function Layout() {
6+
return (
7+
<Drawer
8+
screenOptions={{
9+
drawerActiveTintColor: ColorPalette.primary,
10+
drawerInactiveTintColor: '#888',
11+
headerTintColor: ColorPalette.primary,
12+
headerTitleStyle: { color: ColorPalette.primary },
13+
}}
14+
>
15+
<Drawer.Screen
16+
name="index"
17+
options={{
18+
drawerLabel: () => null,
19+
title: 'Main Menu',
20+
drawerItemStyle: { display: 'none' },
21+
}}
22+
/>
23+
<Drawer.Screen
24+
name="vad/index"
25+
options={{
26+
drawerLabel: 'Voice Activity Detection',
27+
title: 'Voice Activity Detection',
28+
}}
29+
/>
30+
</Drawer>
31+
);
32+
}

apps/speech/app/index.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useRouter } from 'expo-router';
2+
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
3+
import { ColorPalette } from '../theme';
4+
import ExecutorchLogo from '../assets/icons/executorch.svg';
5+
6+
export default function Home() {
7+
const router = useRouter();
8+
9+
return (
10+
<View style={styles.container}>
11+
<ExecutorchLogo width={64} height={64} />
12+
<Text style={styles.headerText}>Select a demo</Text>
13+
<View style={styles.buttonContainer}>
14+
<TouchableOpacity style={styles.button} onPress={() => router.navigate('vad/')}>
15+
<Text style={styles.buttonText}>Voice Activity Detection</Text>
16+
</TouchableOpacity>
17+
</View>
18+
</View>
19+
);
20+
}
21+
22+
const styles = StyleSheet.create({
23+
container: {
24+
flex: 1,
25+
justifyContent: 'center',
26+
alignItems: 'center',
27+
backgroundColor: '#fff',
28+
},
29+
headerText: {
30+
fontSize: 18,
31+
color: ColorPalette.strongPrimary,
32+
margin: 20,
33+
},
34+
buttonContainer: {
35+
width: '80%',
36+
justifyContent: 'space-evenly',
37+
marginBottom: 20,
38+
},
39+
button: {
40+
backgroundColor: ColorPalette.strongPrimary,
41+
borderRadius: 8,
42+
padding: 14,
43+
alignItems: 'center',
44+
marginBottom: 12,
45+
},
46+
buttonText: {
47+
color: 'white',
48+
fontSize: 16,
49+
fontWeight: '600',
50+
},
51+
});

apps/speech/app/vad/index.tsx

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import React, { useEffect, useRef, useState } from 'react';
2+
import { View, Text, StyleSheet, ScrollView, Platform } from 'react-native';
3+
import { useVAD, models } from 'react-native-executorch';
4+
import { AudioManager, AudioRecorder } from 'react-native-audio-api';
5+
import DeviceInfo from 'react-native-device-info';
6+
7+
import ScreenWrapper from '../../components/ScreenWrapper';
8+
import { ModelStatus } from '../../components/ModelStatus';
9+
import { Button } from '../../components/Button';
10+
import { theme } from '../../theme';
11+
12+
const SAMPLE_RATE = 16000;
13+
const isSimulator = DeviceInfo.isEmulatorSync();
14+
15+
function VADContent() {
16+
const { isReady, downloadProgress, error, stream, streamInsert, streamStop } = useVAD(
17+
models.vad.FSMN_VAD
18+
);
19+
20+
const [isStreaming, setIsStreaming] = useState(false);
21+
const [isSpeaking, setIsSpeaking] = useState(false);
22+
const [hasMicPermission, setHasMicPermission] = useState(false);
23+
const [runError, setRunError] = useState<string | null>(null);
24+
const [logs, setLogs] = useState<string[]>([]);
25+
26+
const recorder = useRef(new AudioRecorder());
27+
const logScrollRef = useRef<ScrollView>(null);
28+
29+
const addLog = (message: string) => {
30+
setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()}: ${message}`]);
31+
};
32+
33+
useEffect(() => {
34+
AudioManager.setAudioSessionOptions({
35+
iosCategory: 'playAndRecord',
36+
iosMode: 'spokenAudio',
37+
iosOptions: ['allowBluetoothHFP', 'defaultToSpeaker'],
38+
});
39+
AudioManager.requestRecordingPermissions().then((status) =>
40+
setHasMicPermission(status === 'Granted')
41+
);
42+
}, []);
43+
44+
const handleStart = async () => {
45+
if (isStreaming || !isReady) return;
46+
47+
if (!hasMicPermission) {
48+
setRunError('Microphone permission denied. Please enable it in Settings.');
49+
return;
50+
}
51+
52+
setRunError(null);
53+
setLogs([]);
54+
setIsStreaming(true);
55+
addLog('Starting VAD stream…');
56+
57+
recorder.current.onAudioReady(
58+
{ sampleRate: SAMPLE_RATE, bufferLength: 1600, channelCount: 1 },
59+
({ buffer }) => streamInsert(buffer.getChannelData(0))
60+
);
61+
62+
try {
63+
await AudioManager.setAudioSessionActivity(true);
64+
const started = await recorder.current.start();
65+
if (started.status === 'error') {
66+
throw new Error(started.message);
67+
}
68+
69+
await stream({
70+
onSpeechBegin: () => {
71+
setIsSpeaking(true);
72+
addLog('Speech detected (begin)');
73+
},
74+
onSpeechEnd: () => {
75+
setIsSpeaking(false);
76+
addLog('Silence detected (end)');
77+
},
78+
options: { timeout: 100, detectionMargin: 300 },
79+
});
80+
} catch (e) {
81+
setRunError(e instanceof Error ? e.message : String(e));
82+
setIsStreaming(false);
83+
}
84+
};
85+
86+
const handleStop = async () => {
87+
await recorder.current.stop();
88+
streamStop();
89+
setIsStreaming(false);
90+
setIsSpeaking(false);
91+
addLog('VAD stream stopped');
92+
};
93+
94+
const streamDisabled = isSimulator || !isReady;
95+
96+
return (
97+
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
98+
<View style={styles.card}>
99+
<Text style={styles.cardTitle}>Voice Activity Detection</Text>
100+
<Text style={styles.cardDescription}>
101+
Streams microphone audio through the FSMN-VAD model and reports when speech begins and
102+
ends in real time.
103+
</Text>
104+
<ModelStatus
105+
isReady={isReady}
106+
downloadProgress={downloadProgress}
107+
error={error ? error.message : null}
108+
modelTypeLabel="VAD model"
109+
/>
110+
</View>
111+
112+
{runError && (
113+
<View style={styles.errorContainer}>
114+
<Text style={styles.errorText}>{runError}</Text>
115+
</View>
116+
)}
117+
118+
<View style={styles.card}>
119+
<View style={styles.visualizer}>
120+
<View style={[styles.indicator, isSpeaking ? styles.speaking : styles.silent]} />
121+
<Text
122+
style={[styles.visualizerText, isSpeaking ? styles.speakingText : styles.silentText]}
123+
>
124+
{isSpeaking ? 'SPEAKING' : 'SILENT'}
125+
</Text>
126+
</View>
127+
128+
{isStreaming ? (
129+
<Button title="Stop VAD stream" variant="accent" onPress={handleStop} />
130+
) : (
131+
<Button
132+
title={isSimulator ? 'Recording not available on simulator' : 'Start VAD stream'}
133+
onPress={handleStart}
134+
disabled={streamDisabled}
135+
/>
136+
)}
137+
</View>
138+
139+
<View style={styles.card}>
140+
<Text style={styles.sectionTitle}>VAD events</Text>
141+
<ScrollView
142+
ref={logScrollRef}
143+
style={styles.logScroll}
144+
onContentSizeChange={() => logScrollRef.current?.scrollToEnd({ animated: true })}
145+
>
146+
{logs.length > 0 ? (
147+
logs.map((log, i) => (
148+
<Text key={i} style={styles.logText}>
149+
{log}
150+
</Text>
151+
))
152+
) : (
153+
<Text style={styles.emptyText}>No events logged yet…</Text>
154+
)}
155+
</ScrollView>
156+
</View>
157+
</ScrollView>
158+
);
159+
}
160+
161+
export default function VADScreen() {
162+
return (
163+
<ScreenWrapper>
164+
<VADContent />
165+
</ScreenWrapper>
166+
);
167+
}
168+
169+
const styles = StyleSheet.create({
170+
container: { flex: 1, backgroundColor: theme.colors.background },
171+
content: { padding: theme.spacing.large, paddingBottom: 40 },
172+
card: {
173+
backgroundColor: theme.colors.cardBackground,
174+
borderRadius: theme.radius.large,
175+
padding: 20,
176+
marginBottom: 20,
177+
borderWidth: 1,
178+
borderColor: theme.colors.lightBorder,
179+
},
180+
cardTitle: {
181+
fontSize: theme.typography.title.fontSize,
182+
fontWeight: theme.typography.title.fontWeight,
183+
color: theme.colors.strongPrimary,
184+
marginBottom: 8,
185+
},
186+
cardDescription: {
187+
fontSize: 14,
188+
color: theme.colors.textMuted,
189+
lineHeight: 20,
190+
marginBottom: 16,
191+
},
192+
visualizer: { alignItems: 'center', marginBottom: 20 },
193+
indicator: {
194+
width: 96,
195+
height: 96,
196+
borderRadius: 48,
197+
marginBottom: 16,
198+
},
199+
speaking: { backgroundColor: '#22c55e' },
200+
silent: { backgroundColor: '#e9ecef' },
201+
visualizerText: { fontSize: 22, fontWeight: '800', letterSpacing: 2 },
202+
speakingText: { color: '#22c55e' },
203+
silentText: { color: theme.colors.textPlaceholder },
204+
sectionTitle: { fontSize: 16, fontWeight: '700', color: '#212529', marginBottom: 10 },
205+
logScroll: {
206+
maxHeight: 180,
207+
backgroundColor: '#f8fafc',
208+
borderRadius: theme.radius.small,
209+
borderWidth: 1,
210+
borderColor: theme.colors.lightBorder,
211+
padding: 12,
212+
},
213+
logText: {
214+
fontSize: 12,
215+
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
216+
color: '#334155',
217+
marginBottom: 2,
218+
},
219+
emptyText: { color: theme.colors.textPlaceholder, fontStyle: 'italic' },
220+
errorContainer: {
221+
backgroundColor: theme.colors.errorBackground,
222+
padding: 12,
223+
borderRadius: theme.radius.small,
224+
marginBottom: 20,
225+
},
226+
errorText: { color: theme.colors.errorText, fontSize: 14, textAlign: 'center' },
227+
});
17.1 KB
Loading

apps/speech/assets/icons/executorch.svg

Lines changed: 9 additions & 0 deletions
Loading
1.43 KB
Loading

apps/speech/assets/icons/icon.png

21.9 KB
Loading
46.2 KB
Loading

0 commit comments

Comments
 (0)