Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
25ff14e
feat(speech): wire VAD to Whisper STT and rewrite example app screen
barhanc Jul 16, 2026
01ca3d2
fix(stt): remove unused sttProgress and sttError variables
barhanc Jul 16, 2026
4360283
docs: add JSDoc to Whisper STT task pipeline and hook
barhanc Jul 16, 2026
e1ce0f8
fix: add missing @category and localPath to useSpeechToText
barhanc Jul 16, 2026
ddaa1fd
fix(stt): adapt to VAD API changes from rne-rewrite rebase
barhanc Jul 17, 2026
3eef975
feat(speech-example): add scrollable ModelPicker to STT screen
barhanc Jul 17, 2026
c82828f
feat(stt): add all Whisper models, disable CoreML on non-iOS platforms
barhanc Jul 20, 2026
0754ca1
docs(stt): replace BCP-47 references with Whisper-specific language c…
barhanc Jul 20, 2026
c7b5d51
fix(whisper): correct STRIDE_SIZE comment to reflect stream-only usage
barhanc Jul 20, 2026
0ffd335
fix(whisper): validate language against supportedLanguages before dec…
barhanc Jul 20, 2026
081dd80
fix(whisper): validate language token against tokenizer, not declared…
barhanc Jul 20, 2026
3ace9e9
fix(speech-app): render download progress status on STT screen
barhanc Jul 20, 2026
f01fab9
small fix
barhanc Jul 20, 2026
ee16926
refactor(speech): rename fsmnVadModel to generic vadModel in WhisperS…
barhanc Jul 20, 2026
307ab59
feat(example-stt): add separate Transcribe Audio File screen
barhanc Jul 20, 2026
f7b423b
feat(speech-app): wrap STT screens in ScreenWrapper and add error log…
barhanc Jul 20, 2026
9aad0da
Rename STT example app directories to be more descriptive
barhanc Jul 21, 2026
e65ed1c
Point Whisper model URLs to HuggingFace repos and add MLX backend sup…
Jul 21, 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
14 changes: 14 additions & 0 deletions apps/speech/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@ export default function Layout() {
title: 'Voice Activity Detection',
}}
/>
<Drawer.Screen
name="microphone-transcription/index"
options={{
drawerLabel: 'Live Transcription (Mic)',
title: 'Live Transcription (Mic)',
}}
/>
<Drawer.Screen
name="audio-file-transcription/index"
options={{
drawerLabel: 'Transcribe Audio File',
title: 'Transcribe Audio File',
}}
/>
</Drawer>
);
}
300 changes: 300 additions & 0 deletions apps/speech/app/audio-file-transcription/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
import React, { useEffect, useState } from 'react';
import {
Platform,
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
TextInput,
} from 'react-native';
import { useSpeechToText, models, WHISPER_SAMPLE_RATE_HZ } from 'react-native-executorch';
import { decodeAudioData } from 'react-native-audio-api';

import ScreenWrapper from '../../components/ScreenWrapper';
import { ModelPicker } from '../../components/ModelPicker';
import { ModelStatus } from '../../components/ModelStatus';
import { theme } from '../../theme';

const MODELS = [
{
name: 'Tiny English (CPU)',
config: models.speechToText.WHISPER.EN.TINY.XNNPACK_FP32,
},
{
name: 'Tiny English (CoreML)',
config: models.speechToText.WHISPER.EN.TINY.COREML_FP16,
disabled: Platform.OS !== 'ios',
},
{
name: 'Tiny English (MLX)',
config: models.speechToText.WHISPER.EN.TINY.MLX_BF16,
disabled: Platform.OS !== 'ios',
},
{
name: 'Base English (CPU)',
config: models.speechToText.WHISPER.EN.BASE.XNNPACK_FP32,
},
{
name: 'Base English (CoreML)',
config: models.speechToText.WHISPER.EN.BASE.COREML_FP16,
disabled: Platform.OS !== 'ios',
},
{
name: 'Base English (MLX)',
config: models.speechToText.WHISPER.EN.BASE.MLX_BF16,
disabled: Platform.OS !== 'ios',
},
{
name: 'Small English (CPU)',
config: models.speechToText.WHISPER.EN.SMALL.XNNPACK_FP32,
},
{
name: 'Small English (CoreML)',
config: models.speechToText.WHISPER.EN.SMALL.COREML_FP16,
disabled: Platform.OS !== 'ios',
},
{
name: 'Small English (MLX)',
config: models.speechToText.WHISPER.EN.SMALL.MLX_BF16,
disabled: Platform.OS !== 'ios',
},
{
name: 'Tiny Multilingual (CPU)',
config: models.speechToText.WHISPER.TINY.XNNPACK_FP32,
},
{
name: 'Tiny Multilingual (CoreML)',
config: models.speechToText.WHISPER.TINY.COREML_FP16,
disabled: Platform.OS !== 'ios',
},
{
name: 'Tiny Multilingual (MLX)',
config: models.speechToText.WHISPER.TINY.MLX_BF16,
disabled: Platform.OS !== 'ios',
},
{
name: 'Base Multilingual (CPU)',
config: models.speechToText.WHISPER.BASE.XNNPACK_FP32,
},
{
name: 'Base Multilingual (CoreML)',
config: models.speechToText.WHISPER.BASE.COREML_FP16,
disabled: Platform.OS !== 'ios',
},
{
name: 'Base Multilingual (MLX)',
config: models.speechToText.WHISPER.BASE.MLX_BF16,
disabled: Platform.OS !== 'ios',
},
{
name: 'Small Multilingual (CPU)',
config: models.speechToText.WHISPER.SMALL.XNNPACK_FP32,
},
{
name: 'Small Multilingual (CoreML)',
config: models.speechToText.WHISPER.SMALL.COREML_FP16,
disabled: Platform.OS !== 'ios',
},
{
name: 'Small Multilingual (MLX)',
config: models.speechToText.WHISPER.SMALL.MLX_BF16,
disabled: Platform.OS !== 'ios',
},
];

function STTAudioContent() {
const [selectedModel, setSelectedModel] = useState(MODELS[0]!);
const [status, setStatus] = useState<string>('Idle');
const [url, setUrl] = useState('https://models.silero.ai/vad_models/en.wav');
const [isTranscribing, setIsTranscribing] = useState(false);
const [audioText, setAudioText] = useState('');
const [runError, setRunError] = useState<string | null>(null);

const {
isReady: isSttReady,
transcribe,
transcribeStop,
downloadProgress,
error: modelError,
} = useSpeechToText(selectedModel.config);

useEffect(() => {
return () => {
if (transcribeStop) transcribeStop();
};
}, [transcribeStop]);

const startTranscribing = async () => {
if (!isSttReady || !transcribe || isTranscribing || !url) return;
setRunError(null);
setAudioText('');
setStatus('Decoding URL...');
setIsTranscribing(true);

try {
const audioBuffer = await decodeAudioData(url, WHISPER_SAMPLE_RATE_HZ);
const samples = audioBuffer.getChannelData(0);

setStatus('Transcribing...');

let currentText = '';
const text = await transcribe(samples, { language: 'en' }, (token) => {
currentText += token;
setAudioText(currentText);
});
setAudioText(text);
setStatus('Done');
} catch (err) {
console.error('STT Audio transcription error:', err);
const errMsg = err instanceof Error ? err.message : String(err);
setRunError(errMsg);
setStatus('Error');
} finally {
setIsTranscribing(false);
}
};

const stopTranscribing = () => {
if (!isTranscribing) return;
if (transcribeStop) transcribeStop();
setIsTranscribing(false);
setStatus('Cancelled');
};

const isModelBusy = status.includes('...');

return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.card}>
<ModelPicker
label="Whisper Model"
options={MODELS.map((m) => ({
label: m.name,
value: m,
disabled: isModelBusy || !!m.disabled,
}))}
selectedValue={selectedModel}
onValueChange={setSelectedModel}
/>
<ModelStatus
isReady={isSttReady}
downloadProgress={downloadProgress}
error={modelError ? modelError.message : null}
modelTypeLabel="Whisper model"
/>
</View>

{runError && (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>{runError}</Text>
</View>
)}

{/* Audio Link Panel */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Transcribe Audio File</Text>
<TextInput
style={styles.input}
value={url}
onChangeText={setUrl}
placeholder="Audio URL (e.g. .wav)"
placeholderTextColor={theme.colors.textMuted}
editable={!isTranscribing}
/>
<View style={styles.buttonContainer}>
{!isTranscribing ? (
<TouchableOpacity
style={[styles.button, (!isSttReady || !url) && styles.buttonDisabled]}
onPress={startTranscribing}
disabled={!isSttReady || !url}
>
<Text style={styles.buttonText}>Transcribe Audio File</Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={[styles.button, styles.buttonStop]} onPress={stopTranscribing}>
<Text style={styles.buttonText}>Stop Transcription</Text>
</TouchableOpacity>
)}
</View>

<Text style={styles.resultHeader}>Transcription Output:</Text>
<View style={styles.textOutputContainer}>
<Text style={styles.committedText}>{audioText}</Text>
</View>
</View>
</ScrollView>
);
}

export default function STTAudioScreen() {
return (
<ScreenWrapper>
<STTAudioContent />
</ScreenWrapper>
);
}

const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: theme.colors.background },
content: { padding: 16 },
input: {
height: 40,
borderColor: theme.colors.border,
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 12,
color: theme.colors.textSecondary,
backgroundColor: theme.colors.background,
marginBottom: 12,
},
card: {
backgroundColor: theme.colors.cardBackground,
borderRadius: 12,
padding: 16,
marginBottom: 16,
borderWidth: 1,
borderColor: theme.colors.border,
},
cardTitle: {
fontSize: 16,
fontWeight: 'bold',
color: theme.colors.strongPrimary,
marginBottom: 12,
},
errorContainer: {
padding: 12,
backgroundColor: '#ffdddd',
borderRadius: 8,
marginBottom: 16,
},
errorText: { color: '#cc0000', fontSize: 13 },
buttonContainer: { alignItems: 'center', marginVertical: 12 },
button: {
backgroundColor: theme.colors.accent,
paddingVertical: 12,
paddingHorizontal: 24,
borderRadius: 8,
alignItems: 'center',
width: '100%',
},
buttonStop: { backgroundColor: '#ff3b30' },
buttonDisabled: { backgroundColor: '#d1d1d6' },
buttonText: { color: '#ffffff', fontWeight: 'bold', fontSize: 15 },
resultHeader: {
fontSize: 13,
fontWeight: 'bold',
color: theme.colors.textSecondary,
marginTop: 12,
marginBottom: 6,
},
textOutputContainer: {
minHeight: 100,
padding: 12,
backgroundColor: theme.colors.background,
borderRadius: 8,
borderWidth: 1,
borderColor: theme.colors.border,
},
committedText: { fontSize: 14, color: theme.colors.textSecondary, lineHeight: 20 },
});
12 changes: 12 additions & 0 deletions apps/speech/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ export default function Home() {
<TouchableOpacity style={styles.button} onPress={() => router.navigate('vad/')}>
<Text style={styles.buttonText}>Voice Activity Detection</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.button}
onPress={() => router.navigate('microphone-transcription/')}
>
<Text style={styles.buttonText}>Live Transcription (Mic)</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.button}
onPress={() => router.navigate('audio-file-transcription/')}
>
<Text style={styles.buttonText}>Transcribe Audio File</Text>
</TouchableOpacity>
</View>
</View>
);
Expand Down
Loading