diff --git a/.cspell-wordlist.txt b/.cspell-wordlist.txt index 61b9142ad5..6bc184a669 100644 --- a/.cspell-wordlist.txt +++ b/.cspell-wordlist.txt @@ -278,3 +278,8 @@ binarization bugprone NOLINTNEXTLINE nullopt + +hann +preemphasis +coeff +Silero diff --git a/apps/speech/app.json b/apps/speech/app.json new file mode 100644 index 0000000000..c53f558446 --- /dev/null +++ b/apps/speech/app.json @@ -0,0 +1,51 @@ +{ + "expo": { + "name": "speech", + "slug": "speech", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icons/icon.png", + "userInterfaceStyle": "light", + "newArchEnabled": true, + "scheme": "rne-speech", + "splash": { + "image": "./assets/icons/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.anonymous.speech", + "infoPlist": { + "NSMicrophoneUsageDescription": "This app uses the microphone to detect voice activity." + } + }, + "android": { + "adaptiveIcon": { + "foregroundImage": "./assets/icons/adaptive-icon.png", + "backgroundColor": "#ffffff" + }, + "package": "com.anonymous.speech", + "permissions": [ + "android.permission.RECORD_AUDIO" + ] + }, + "web": { + "favicon": "./assets/icons/favicon.png" + }, + "plugins": [ + "expo-router", + [ + "expo-build-properties", + { + "android": { + "minSdkVersion": 26 + }, + "ios": { + "deploymentTarget": "17.0" + } + } + ] + ] + } +} diff --git a/apps/speech/app/_layout.tsx b/apps/speech/app/_layout.tsx new file mode 100644 index 0000000000..bd4a75cc71 --- /dev/null +++ b/apps/speech/app/_layout.tsx @@ -0,0 +1,32 @@ +import { Drawer } from 'expo-router/drawer'; +import { ColorPalette } from '../theme'; +import React from 'react'; + +export default function Layout() { + return ( + + null, + title: 'Main Menu', + drawerItemStyle: { display: 'none' }, + }} + /> + + + ); +} diff --git a/apps/speech/app/index.tsx b/apps/speech/app/index.tsx new file mode 100644 index 0000000000..453b8e3863 --- /dev/null +++ b/apps/speech/app/index.tsx @@ -0,0 +1,51 @@ +import { useRouter } from 'expo-router'; +import { View, Text, StyleSheet, TouchableOpacity } from 'react-native'; +import { ColorPalette } from '../theme'; +import ExecutorchLogo from '../assets/icons/executorch.svg'; + +export default function Home() { + const router = useRouter(); + + return ( + + + Select a demo + + router.navigate('vad/')}> + Voice Activity Detection + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#fff', + }, + headerText: { + fontSize: 18, + color: ColorPalette.strongPrimary, + margin: 20, + }, + buttonContainer: { + width: '80%', + justifyContent: 'space-evenly', + marginBottom: 20, + }, + button: { + backgroundColor: ColorPalette.strongPrimary, + borderRadius: 8, + padding: 14, + alignItems: 'center', + marginBottom: 12, + }, + buttonText: { + color: 'white', + fontSize: 16, + fontWeight: '600', + }, +}); diff --git a/apps/speech/app/vad/index.tsx b/apps/speech/app/vad/index.tsx new file mode 100644 index 0000000000..734a681b85 --- /dev/null +++ b/apps/speech/app/vad/index.tsx @@ -0,0 +1,228 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { View, Text, StyleSheet, ScrollView, Platform } from 'react-native'; +import { useVAD, models } from 'react-native-executorch'; +import { AudioManager, AudioRecorder } from 'react-native-audio-api'; +import DeviceInfo from 'react-native-device-info'; + +import ScreenWrapper from '../../components/ScreenWrapper'; +import { ModelStatus } from '../../components/ModelStatus'; +import { Button } from '../../components/Button'; +import { theme } from '../../theme'; + +// Record at the model's expected sample rate rather than hardcoding it. +const SAMPLE_RATE = models.vad.FSMN_VAD.featureConfig.sampleRate; +const isSimulator = DeviceInfo.isEmulatorSync(); + +function VADContent() { + const { isReady, downloadProgress, error, stream, streamInsert, streamStop } = useVAD( + models.vad.FSMN_VAD + ); + + const [isStreaming, setIsStreaming] = useState(false); + const [isSpeaking, setIsSpeaking] = useState(false); + const [hasMicPermission, setHasMicPermission] = useState(false); + const [runError, setRunError] = useState(null); + const [logs, setLogs] = useState([]); + + const recorder = useRef(new AudioRecorder()); + const logScrollRef = useRef(null); + + const addLog = (message: string) => { + setLogs((prev) => [...prev, `${new Date().toLocaleTimeString()}: ${message}`]); + }; + + useEffect(() => { + AudioManager.setAudioSessionOptions({ + iosCategory: 'playAndRecord', + iosMode: 'spokenAudio', + iosOptions: ['allowBluetoothHFP', 'defaultToSpeaker'], + }); + AudioManager.requestRecordingPermissions().then((status) => + setHasMicPermission(status === 'Granted') + ); + }, []); + + const handleStart = async () => { + if (isStreaming || !isReady) return; + + if (!hasMicPermission) { + setRunError('Microphone permission denied. Please enable it in Settings.'); + return; + } + + setRunError(null); + setLogs([]); + setIsStreaming(true); + addLog('Starting VAD stream…'); + + recorder.current.onAudioReady( + { sampleRate: SAMPLE_RATE, bufferLength: 1600, channelCount: 1 }, + ({ buffer }) => streamInsert(buffer.getChannelData(0)) + ); + + try { + await AudioManager.setAudioSessionActivity(true); + const started = await recorder.current.start(); + if (started.status === 'error') { + throw new Error(started.message); + } + + await stream({ + onSpeechBegin: () => { + setIsSpeaking(true); + addLog('Speech detected (begin)'); + }, + onSpeechEnd: () => { + setIsSpeaking(false); + addLog('Silence detected (end)'); + }, + options: { timeout: 100, detectionMargin: 300 }, + }); + } catch (e) { + setRunError(e instanceof Error ? e.message : String(e)); + setIsStreaming(false); + } + }; + + const handleStop = async () => { + await recorder.current.stop(); + streamStop(); + setIsStreaming(false); + setIsSpeaking(false); + addLog('VAD stream stopped'); + }; + + const streamDisabled = isSimulator || !isReady; + + return ( + + + Voice Activity Detection + + Streams microphone audio through the FSMN-VAD model and reports when speech begins and + ends in real time. + + + + + {runError && ( + + {runError} + + )} + + + + + + {isSpeaking ? 'SPEAKING' : 'SILENT'} + + + + {isStreaming ? ( +