Skip to content

Commit 6f7e0dc

Browse files
committed
Fully working Kokoro pipeline
1 parent 6e35ddf commit 6f7e0dc

22 files changed

Lines changed: 1413 additions & 11 deletions

File tree

.cspell-wordlist.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,3 +314,5 @@ Partitioner
314314
denoised
315315
ttfa
316316
TTFA
317+
phonemized
318+
subsentences

apps/speech/app/_layout.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ export default function Layout() {
4848
title: 'Text-to-Speech (SuperTonic)',
4949
}}
5050
/>
51+
<Drawer.Screen
52+
name="kokoro-text-to-speech/index"
53+
options={{
54+
drawerLabel: 'Text-to-Speech (Kokoro)',
55+
title: 'Text-to-Speech (Kokoro)',
56+
}}
57+
/>
5158
</Drawer>
5259
);
5360
}

apps/speech/app/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ export default function Home() {
2929
<TouchableOpacity style={styles.button} onPress={() => router.navigate('text-to-speech/')}>
3030
<Text style={styles.buttonText}>Text-to-Speech (SuperTonic)</Text>
3131
</TouchableOpacity>
32+
<TouchableOpacity
33+
style={styles.button}
34+
onPress={() => router.navigate('kokoro-text-to-speech/')}
35+
>
36+
<Text style={styles.buttonText}>Text-to-Speech (Kokoro)</Text>
37+
</TouchableOpacity>
3238
</View>
3339
</View>
3440
);
Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
import React, { useCallback, useEffect, useRef, useState } from 'react';
2+
import { View, Text, StyleSheet, ScrollView, TextInput } from 'react-native';
3+
import {
4+
useKokoroTextToSpeech,
5+
models,
6+
KOKORO_SAMPLE_RATE,
7+
type KokoroTtsModel,
8+
} from 'react-native-executorch';
9+
import { AudioContext, type AudioBufferQueueSourceNode } from 'react-native-audio-api';
10+
11+
import ScreenWrapper from '../../components/ScreenWrapper';
12+
import { ModelPicker } from '../../components/ModelPicker';
13+
import { ModelStatus } from '../../components/ModelStatus';
14+
import { Button } from '../../components/Button';
15+
import { theme } from '../../theme';
16+
17+
const LANGUAGE_OPTIONS = [
18+
{ label: 'English (US)', value: 'EN_US' as const },
19+
{ label: 'English (GB)', value: 'EN_GB' as const },
20+
{ label: 'Spanish', value: 'ES' as const },
21+
{ label: 'French', value: 'FR' as const },
22+
{ label: 'Italian', value: 'IT' as const },
23+
{ label: 'Portuguese', value: 'PT' as const },
24+
{ label: 'Hindi', value: 'HI' as const },
25+
{ label: 'Polish', value: 'PL' as const },
26+
{ label: 'German', value: 'DE' as const },
27+
];
28+
29+
type KokoroLanguage = (typeof LANGUAGE_OPTIONS)[number]['value'];
30+
31+
// cspell:disable
32+
const SAMPLE_TEXTS: Record<KokoroLanguage, string> = {
33+
EN_US:
34+
'Kokoro is a compact text-to-speech model that runs entirely on your device. ' +
35+
'It converts text into phonemes first, then predicts how long each sound should last, ' +
36+
'and finally synthesizes the waveform. No internet connection is required.',
37+
EN_GB:
38+
'Kokoro is a compact text-to-speech model that runs entirely on your device, ' +
39+
'converting text into phonemes before synthesising the waveform.',
40+
ES: 'Kokoro es un modelo de síntesis de voz que funciona completamente en tu dispositivo, sin conexión a internet.',
41+
FR: 'Kokoro est un modèle de synthèse vocale qui fonctionne entièrement sur votre appareil, sans connexion internet.',
42+
IT: 'Kokoro è un modello di sintesi vocale che funziona interamente sul tuo dispositivo, senza connessione a internet.',
43+
PT: 'Kokoro é um modelo de síntese de voz que funciona inteiramente no seu dispositivo, sem ligação à internet.',
44+
HI: 'कोकोरो एक छोटा टेक्स्ट-टू-स्पीच मॉडल है जो पूरी तरह से आपके डिवाइस पर चलता है।',
45+
PL: 'Kokoro to niewielki model syntezy mowy, który działa w całości na twoim urządzeniu, bez połączenia z internetem.',
46+
DE: 'Kokoro ist ein kompaktes Sprachsynthesemodell, das vollständig auf deinem Gerät läuft, ganz ohne Internetverbindung.',
47+
};
48+
// cspell:enable
49+
50+
const SPEED_OPTIONS = [
51+
{ label: '0.8x', value: 0.8 },
52+
{ label: '0.9x', value: 0.9 },
53+
{ label: '1.0x', value: 1.0 },
54+
{ label: '1.1x', value: 1.1 },
55+
{ label: '1.25x', value: 1.25 },
56+
];
57+
58+
function KokoroContent() {
59+
const [language, setLanguage] = useState<KokoroLanguage>('EN_US');
60+
const [text, setText] = useState(SAMPLE_TEXTS.EN_US);
61+
const [speed, setSpeed] = useState(1.0);
62+
const [isSynthesizing, setIsSynthesizing] = useState(false);
63+
const [isPlaying, setIsPlaying] = useState(false);
64+
const [chunkProgress, setChunkProgress] = useState<string | null>(null);
65+
const [runError, setRunError] = useState<string | null>(null);
66+
const [totalDuration, setTotalDuration] = useState<number | null>(null);
67+
68+
const model = models.textToSpeech.KOKORO[language] as KokoroTtsModel<string>;
69+
const voiceNames = Object.keys(model.voices);
70+
const [voice, setVoice] = useState(voiceNames[0]!);
71+
72+
const audioCtxRef = useRef<AudioContext | null>(null);
73+
const queueSourceRef = useRef<AudioBufferQueueSourceNode | null>(null);
74+
75+
const { isReady, downloadProgress, error, synthesize, synthesizeStop } =
76+
useKokoroTextToSpeech(model);
77+
78+
useEffect(() => {
79+
setVoice(Object.keys(models.textToSpeech.KOKORO[language].voices)[0]!);
80+
setText(SAMPLE_TEXTS[language]);
81+
}, [language]);
82+
83+
const getAudioContext = useCallback(async () => {
84+
if (!audioCtxRef.current || audioCtxRef.current.state === 'closed') {
85+
audioCtxRef.current = new AudioContext({ sampleRate: KOKORO_SAMPLE_RATE });
86+
}
87+
if (audioCtxRef.current.state === 'suspended') {
88+
await audioCtxRef.current.resume();
89+
}
90+
return audioCtxRef.current;
91+
}, []);
92+
93+
const stopAudioQueue = useCallback(() => {
94+
if (queueSourceRef.current) {
95+
queueSourceRef.current.clearBuffers();
96+
queueSourceRef.current.stop();
97+
queueSourceRef.current = null;
98+
}
99+
setIsPlaying(false);
100+
}, []);
101+
102+
useEffect(() => {
103+
return () => {
104+
synthesizeStop?.();
105+
stopAudioQueue();
106+
if (audioCtxRef.current) {
107+
audioCtxRef.current.close().catch(() => {});
108+
audioCtxRef.current = null;
109+
}
110+
};
111+
}, [stopAudioQueue, synthesizeStop]);
112+
113+
const preparePlaybackSource = useCallback(async () => {
114+
stopAudioQueue();
115+
const ctx = await getAudioContext();
116+
117+
const source = ctx.createBufferQueueSource();
118+
source.connect(ctx.destination);
119+
source.onBufferEnded = (event) => {
120+
if (event.isLastBufferInQueue) setIsPlaying(false);
121+
};
122+
queueSourceRef.current = source;
123+
return { ctx, source };
124+
}, [getAudioContext, stopAudioQueue]);
125+
126+
const handleSynthesize = async () => {
127+
if (!synthesize || isSynthesizing || !text.trim()) return;
128+
129+
setRunError(null);
130+
setChunkProgress(null);
131+
setTotalDuration(null);
132+
setIsSynthesizing(true);
133+
134+
try {
135+
const { ctx, source } = await preparePlaybackSource();
136+
let durationSum = 0;
137+
let started = false;
138+
139+
for await (const chunk of synthesize(text, { voice, speed })) {
140+
setChunkProgress(
141+
`Chunk ${chunk.chunkIndex + 1}/${chunk.totalChunks} (${chunk.duration.toFixed(1)}s)`
142+
);
143+
durationSum += chunk.duration;
144+
145+
const buffer = ctx.createBuffer(1, chunk.audio.length, KOKORO_SAMPLE_RATE);
146+
buffer.copyToChannel(chunk.audio as Float32Array<ArrayBuffer>, 0);
147+
source.enqueueBuffer(buffer);
148+
149+
if (!started) {
150+
started = true;
151+
setIsPlaying(true);
152+
source.start(0, 0);
153+
}
154+
}
155+
156+
setTotalDuration(durationSum);
157+
setChunkProgress(null);
158+
} catch (err) {
159+
setRunError(err instanceof Error ? err.message : String(err));
160+
} finally {
161+
setIsSynthesizing(false);
162+
}
163+
};
164+
165+
const handleStopPlayback = () => {
166+
synthesizeStop?.();
167+
stopAudioQueue();
168+
setIsSynthesizing(false);
169+
};
170+
171+
const isBusy = isSynthesizing || isPlaying;
172+
173+
return (
174+
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
175+
<View style={styles.card}>
176+
<Text style={styles.cardTitle}>Kokoro Text-to-Speech</Text>
177+
<Text style={styles.cardDescription}>
178+
Phoneme-driven on-device speech synthesis. Pick a language to load its model, phonemizer
179+
assets and voices.
180+
</Text>
181+
<ModelStatus
182+
isReady={isReady}
183+
downloadProgress={downloadProgress}
184+
error={error ? error.message : null}
185+
modelTypeLabel="Kokoro TTS models"
186+
/>
187+
</View>
188+
189+
{runError && (
190+
<View style={styles.errorContainer}>
191+
<Text style={styles.errorText}>{runError}</Text>
192+
</View>
193+
)}
194+
195+
<View style={styles.card}>
196+
<ModelPicker
197+
label="Language"
198+
options={LANGUAGE_OPTIONS.map((l) => ({ ...l, disabled: isBusy }))}
199+
selectedValue={language}
200+
onValueChange={setLanguage}
201+
/>
202+
<ModelPicker
203+
label="Voice"
204+
options={voiceNames.map((name) => ({ label: name, value: name, disabled: isBusy }))}
205+
selectedValue={voice}
206+
onValueChange={setVoice}
207+
/>
208+
<ModelPicker
209+
label="Speed"
210+
options={SPEED_OPTIONS.map((s) => ({ ...s, disabled: isBusy }))}
211+
selectedValue={speed}
212+
onValueChange={setSpeed}
213+
/>
214+
</View>
215+
216+
<View style={styles.card}>
217+
<Text style={styles.sectionTitle}>Input Text</Text>
218+
<TextInput
219+
style={styles.textInput}
220+
value={text}
221+
onChangeText={setText}
222+
placeholder="Enter text to synthesize..."
223+
placeholderTextColor={theme.colors.textPlaceholder}
224+
multiline
225+
numberOfLines={4}
226+
editable={!isBusy}
227+
/>
228+
</View>
229+
230+
<View style={styles.card}>
231+
<View style={styles.buttonRow}>
232+
{!isPlaying ? (
233+
<Button
234+
title={isSynthesizing ? 'Synthesizing...' : 'Synthesize & Play'}
235+
onPress={handleSynthesize}
236+
disabled={!isReady || !text.trim() || isBusy}
237+
loading={isSynthesizing}
238+
/>
239+
) : (
240+
<Button title="Stop Playback" variant="accent" onPress={handleStopPlayback} />
241+
)}
242+
</View>
243+
244+
{chunkProgress && (
245+
<View style={styles.progressContainer}>
246+
<Text style={styles.progressText}>{chunkProgress}</Text>
247+
</View>
248+
)}
249+
250+
{totalDuration !== null && !isSynthesizing && (
251+
<View style={styles.resultContainer}>
252+
<Text style={styles.resultText}>
253+
Generated {totalDuration.toFixed(1)}s of audio at {KOKORO_SAMPLE_RATE} Hz
254+
</Text>
255+
</View>
256+
)}
257+
</View>
258+
</ScrollView>
259+
);
260+
}
261+
262+
export default function KokoroScreen() {
263+
return (
264+
<ScreenWrapper>
265+
<KokoroContent />
266+
</ScreenWrapper>
267+
);
268+
}
269+
270+
const styles = StyleSheet.create({
271+
container: { flex: 1, backgroundColor: theme.colors.background },
272+
content: { padding: theme.spacing.large, paddingBottom: 40 },
273+
card: {
274+
backgroundColor: theme.colors.cardBackground,
275+
borderRadius: theme.radius.large,
276+
padding: 20,
277+
marginBottom: 20,
278+
borderWidth: 1,
279+
borderColor: theme.colors.lightBorder,
280+
},
281+
cardTitle: {
282+
fontSize: theme.typography.title.fontSize,
283+
fontWeight: theme.typography.title.fontWeight,
284+
color: theme.colors.strongPrimary,
285+
marginBottom: 8,
286+
},
287+
cardDescription: {
288+
fontSize: 14,
289+
color: theme.colors.textMuted,
290+
lineHeight: 20,
291+
marginBottom: 16,
292+
},
293+
sectionTitle: {
294+
fontSize: 16,
295+
fontWeight: '700',
296+
color: '#212529',
297+
marginBottom: 10,
298+
},
299+
textInput: {
300+
height: 120,
301+
borderColor: theme.colors.border,
302+
borderWidth: 1,
303+
borderRadius: theme.radius.small,
304+
paddingHorizontal: 12,
305+
paddingVertical: 10,
306+
color: theme.colors.textSecondary,
307+
backgroundColor: theme.colors.background,
308+
fontSize: 14,
309+
lineHeight: 20,
310+
textAlignVertical: 'top',
311+
},
312+
buttonRow: {
313+
flexDirection: 'row',
314+
gap: theme.spacing.small,
315+
},
316+
progressContainer: {
317+
marginTop: 12,
318+
padding: 10,
319+
backgroundColor: '#e8f4fd',
320+
borderRadius: theme.radius.small,
321+
},
322+
progressText: {
323+
fontSize: 13,
324+
color: '#1a73e8',
325+
fontWeight: '500',
326+
textAlign: 'center',
327+
},
328+
resultContainer: {
329+
marginTop: 12,
330+
padding: 10,
331+
backgroundColor: '#e6f9e6',
332+
borderRadius: theme.radius.small,
333+
},
334+
resultText: {
335+
fontSize: 13,
336+
color: '#2e7d32',
337+
fontWeight: '500',
338+
textAlign: 'center',
339+
},
340+
errorContainer: {
341+
backgroundColor: theme.colors.errorBackground,
342+
padding: 12,
343+
borderRadius: theme.radius.small,
344+
marginBottom: 20,
345+
},
346+
errorText: {
347+
color: theme.colors.errorText,
348+
fontSize: 14,
349+
textAlign: 'center',
350+
},
351+
});

apps/speech/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"main": "expo-router/entry",
55
"react-native-executorch": {
66
"features": [
7-
"vad"
7+
"vad",
8+
"textToSpeech"
89
]
910
},
1011
"scripts": {

0 commit comments

Comments
 (0)