Skip to content

Commit 35ef417

Browse files
committed
Big refactor of TTS native code
1 parent c4d94b4 commit 35ef417

37 files changed

Lines changed: 931 additions & 946 deletions

apps/speech/screens/TextToSpeechScreen.tsx

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,48 @@ const STEPS: ModelOption<number>[] = [
6060
{ label: '16 (best quality)', value: 16 },
6161
];
6262

63-
// Supertonic renders audio at 44.1 kHz.
64-
const SAMPLE_RATE = 44100;
63+
// Kokoro: language-specific voices. Each voice bundles its own phonemizer
64+
// config, so only the voice picker is shown (no language/steps controls).
65+
const KOKORO_LANG_LABELS: Record<string, string> = {
66+
en_us: '🇺🇸 US English',
67+
en_gb: '🇬🇧 UK English',
68+
fr: '🇫🇷 French',
69+
es: '🇪🇸 Spanish',
70+
it: '🇮🇹 Italian',
71+
pt: '🇵🇹 Portuguese',
72+
hi: '🇮🇳 Hindi',
73+
pl: '🇵🇱 Polish',
74+
de: '🇩🇪 German',
75+
};
76+
77+
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
78+
79+
// `models.text_to_speech.kokoro.<lang>.<voice>` are factory functions
80+
// (`() => TextToSpeechModelConfig`); call them to get the actual configs.
81+
const kokoroVoicesByLang = models.text_to_speech.kokoro as unknown as Record<
82+
string,
83+
Record<string, () => TextToSpeechModelConfig>
84+
>;
85+
86+
const KOKORO_VOICES: ModelOption<TextToSpeechModelConfig>[] = Object.entries(
87+
kokoroVoicesByLang
88+
).flatMap(([lang, voices]) =>
89+
Object.entries(voices).map(([name, factory]) => ({
90+
label: `${KOKORO_LANG_LABELS[lang] ?? lang} · ${capitalize(name)}`,
91+
value: factory(),
92+
}))
93+
);
94+
95+
type TtsModelType = 'supertonic' | 'kokoro';
96+
97+
const TTS_MODEL_OPTIONS: ModelOption<TtsModelType>[] = [
98+
{ label: 'Supertonic', value: 'supertonic' },
99+
{ label: 'Kokoro', value: 'kokoro' },
100+
];
101+
102+
// Supertonic renders at 44.1 kHz, Kokoro at 24 kHz.
103+
const SUPERTONIC_SAMPLE_RATE = 44100;
104+
const KOKORO_SAMPLE_RATE = 24000;
65105

66106
import FontAwesome from '@expo/vector-icons/FontAwesome';
67107
import {
@@ -83,7 +123,7 @@ import ErrorBanner from '../components/ErrorBanner';
83123
const createAudioBufferFromVector = (
84124
audioVector: Float32Array,
85125
audioContext: AudioContext | null = null,
86-
sampleRate: number = SAMPLE_RATE
126+
sampleRate: number = SUPERTONIC_SAMPLE_RATE
87127
): AudioBuffer => {
88128
if (audioContext == null) audioContext = new AudioContext({ sampleRate });
89129

@@ -99,6 +139,8 @@ const createAudioBufferFromVector = (
99139
};
100140

101141
export const TextToSpeechScreen = ({ onBack }: { onBack: () => void }) => {
142+
const [selectedTtsModel, setSelectedTtsModel] =
143+
useState<TtsModelType>('supertonic');
102144
const [selectedSpeaker, setSelectedSpeaker] =
103145
useState<TextToSpeechModelConfig>(tts.m1());
104146
const [selectedLang, setSelectedLang] =
@@ -107,6 +149,19 @@ export const TextToSpeechScreen = ({ onBack }: { onBack: () => void }) => {
107149

108150
const model = useTextToSpeech(selectedSpeaker);
109151

152+
const sampleRate =
153+
selectedTtsModel === 'supertonic'
154+
? SUPERTONIC_SAMPLE_RATE
155+
: KOKORO_SAMPLE_RATE;
156+
157+
const handleSelectTtsModel = (type: TtsModelType) => {
158+
if (type === selectedTtsModel) return;
159+
setSelectedTtsModel(type);
160+
setSelectedSpeaker(
161+
type === 'supertonic' ? VOICES[0].value : KOKORO_VOICES[0].value
162+
);
163+
};
164+
110165
const [inputText, setInputText] = useState('');
111166
const [isPlaying, setIsPlaying] = useState(false);
112167
const [readyToGenerate, setReadyToGenerate] = useState(false);
@@ -123,7 +178,7 @@ export const TextToSpeechScreen = ({ onBack }: { onBack: () => void }) => {
123178
iosOptions: ['defaultToSpeaker'],
124179
});
125180

126-
const context = new AudioContext({ sampleRate: SAMPLE_RATE });
181+
const context = new AudioContext({ sampleRate });
127182
audioContextRef.current = context;
128183
context.suspend();
129184

@@ -138,7 +193,7 @@ export const TextToSpeechScreen = ({ onBack }: { onBack: () => void }) => {
138193
audioContextRef.current = null;
139194
gainNodeRef.current = null;
140195
};
141-
}, []);
196+
}, [sampleRate]);
142197

143198
useEffect(() => {
144199
setReadyToGenerate(!model.isGenerating && model.isReady && !isPlaying);
@@ -165,7 +220,7 @@ export const TextToSpeechScreen = ({ onBack }: { onBack: () => void }) => {
165220
const audioBuffer = createAudioBufferFromVector(
166221
audioVec,
167222
audioContext,
168-
SAMPLE_RATE
223+
sampleRate
169224
);
170225

171226
const source = (sourceRef.current =
@@ -192,8 +247,9 @@ export const TextToSpeechScreen = ({ onBack }: { onBack: () => void }) => {
192247
await model.stream({
193248
text: inputText,
194249
speed: 1.0,
195-
totalSteps,
196-
lang: selectedLang,
250+
...(selectedTtsModel === 'supertonic'
251+
? { totalSteps, lang: selectedLang }
252+
: {}),
197253
onNext,
198254
onEnd,
199255
});
@@ -234,29 +290,41 @@ export const TextToSpeechScreen = ({ onBack }: { onBack: () => void }) => {
234290
</View>
235291
<ErrorBanner message={error} onDismiss={() => setError(null)} />
236292

293+
<ModelPicker
294+
label="Model"
295+
models={TTS_MODEL_OPTIONS}
296+
selectedModel={selectedTtsModel}
297+
disabled={model.isGenerating || isPlaying}
298+
onSelect={handleSelectTtsModel}
299+
/>
300+
237301
<ModelPicker
238302
label="Voice"
239-
models={VOICES}
303+
models={selectedTtsModel === 'supertonic' ? VOICES : KOKORO_VOICES}
240304
selectedModel={selectedSpeaker}
241305
disabled={model.isGenerating}
242306
onSelect={(m) => setSelectedSpeaker(m)}
243307
/>
244308

245-
<ModelPicker
246-
label="Language"
247-
models={LANGUAGES}
248-
selectedModel={selectedLang}
249-
disabled={isPlaying}
250-
onSelect={(l) => setSelectedLang(l)}
251-
/>
309+
{selectedTtsModel === 'supertonic' && (
310+
<>
311+
<ModelPicker
312+
label="Language"
313+
models={LANGUAGES}
314+
selectedModel={selectedLang}
315+
disabled={isPlaying}
316+
onSelect={(l) => setSelectedLang(l)}
317+
/>
252318

253-
<ModelPicker
254-
label="Steps"
255-
models={STEPS}
256-
selectedModel={totalSteps}
257-
disabled={isPlaying}
258-
onSelect={(s) => setTotalSteps(s)}
259-
/>
319+
<ModelPicker
320+
label="Steps"
321+
models={STEPS}
322+
selectedModel={totalSteps}
323+
disabled={isPlaying}
324+
onSelect={(s) => setTotalSteps(s)}
325+
/>
326+
</>
327+
)}
260328

261329
<View style={styles.inputContainer}>
262330
<Text style={styles.inputLabel}>Enter text to synthesize</Text>

apps/speech/screens/VoiceActivityDetectionScreen.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,7 @@ import {
88
Platform,
99
} from 'react-native';
1010
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
11-
import {
12-
models,
13-
useVAD
14-
} from 'react-native-executorch';
11+
import { models, useVAD } from 'react-native-executorch';
1512
import FontAwesome from '@expo/vector-icons/FontAwesome';
1613
import { AudioManager, AudioRecorder } from 'react-native-audio-api';
1714
import SWMIcon from '../assets/swm_icon.svg';
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#pragma once
2+
3+
#include <cstdint>
4+
#include <unordered_set>
5+
6+
namespace rnexecutorch::models::text_to_speech::constants {
7+
8+
// Special text characters - end of sentence markers
9+
inline const std::unordered_set<char32_t> kEndOfSentenceCharacters = {
10+
U'.', U'?', U'!', U';',
11+
U'', // Ellipsis
12+
U'|', // ASCII Pipe (often used as Hindi Purna Viram)
13+
U'', // Hindi Purna Viram (U+0964)
14+
U'', // Hindi Deergh Viram (U+0965)
15+
U'¿', // Spanish Inverted Question Mark (U+00BF)
16+
U'¡', // Spanish Inverted Exclamation Mark (U+00A1)
17+
};
18+
19+
// Special text characters - mid-sentence pause markers
20+
inline const std::unordered_set<char32_t> kPauseCharacters = {
21+
U',', U':', U'-',
22+
U'', // Em Dash (U+2014)
23+
U'«', // Left Guillemet (U+00AB)
24+
U'»', // Right Guillemet (U+00BB)
25+
};
26+
27+
} // namespace rnexecutorch::models::text_to_speech::constants
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
#include "TextPartitioner.h"
2+
#include "Constants.h"
3+
4+
#include <algorithm>
5+
#include <deque>
6+
#include <limits>
7+
#include <ranges>
8+
9+
namespace rnexecutorch::models::text_to_speech {
10+
11+
constexpr TextPartitioner::Cost INF = 1e7;
12+
13+
TextPartitioner::TextPartitioner(const TextPartitionerConfig &cfg)
14+
: config_(cfg) {}
15+
16+
TextPartitioner::Partition TextPartitioner::partition(std::u32string_view input,
17+
size_t limit) const {
18+
if (input.empty()) {
19+
return {input, {}};
20+
}
21+
22+
size_t n = input.size();
23+
std::vector<std::pair<Cost, int64_t>> dp(n, {INF, -1});
24+
25+
std::deque<size_t> eosPoints, pausePoints, whitePoints;
26+
27+
// Helper function to estimate the cost of given partitioning.
28+
auto costFn = [this, limit](Cost acc, size_t beg, int64_t prevBp, int64_t bp,
29+
size_t end, Separator sep) -> Cost {
30+
if (end - bp > limit) {
31+
return INF;
32+
}
33+
34+
Cost sepPenalty = sep == Separator::EOS ? config_.eosCost
35+
: sep == Separator::PAUSE ? config_.pauseCost
36+
: sep == Separator::WHITE ? config_.whiteCost
37+
: 0;
38+
39+
int64_t rightmostRangeLength = end - bp;
40+
int64_t prevRangeLength = bp - prevBp;
41+
42+
int64_t latency = std::max(static_cast<int64_t>(0),
43+
rightmostRangeLength - prevRangeLength);
44+
int64_t discount =
45+
config_.tokenDiscountFactor *
46+
std::max(static_cast<int64_t>(0), config_.tokenDiscountRange - bp - 1);
47+
48+
return acc +
49+
static_cast<Cost>(latency * discount / config_.tokenDiscountRange) +
50+
sepPenalty;
51+
};
52+
53+
for (size_t i = 0; i < n; ++i) {
54+
auto &[bestCost, prevBpIdx] = dp[i];
55+
56+
bestCost = costFn(0, 0, -1, -1, i + 1, Separator::NO_SEP);
57+
58+
for (auto *q : {&eosPoints, &pausePoints, &whitePoints}) {
59+
while (!q->empty() && q->front() + limit < i) {
60+
q->pop_front();
61+
}
62+
63+
Separator sep = q == &eosPoints ? Separator::EOS
64+
: q == &pausePoints ? Separator::PAUSE
65+
: Separator::WHITE;
66+
for (size_t breakIdx : (*q)) {
67+
auto cost = costFn(dp[breakIdx].first, 0, dp[breakIdx].second, breakIdx,
68+
i, sep);
69+
if (cost < bestCost && breakIdx > 0) {
70+
bestCost = cost;
71+
prevBpIdx = breakIdx;
72+
}
73+
}
74+
}
75+
76+
char32_t c = input[i];
77+
if (constants::kEndOfSentenceCharacters.contains(c)) {
78+
eosPoints.push_back(i);
79+
} else if (constants::kPauseCharacters.contains(c)) {
80+
pausePoints.push_back(i);
81+
} else if (c < 256 && std::isspace(static_cast<char>(c))) {
82+
whitePoints.push_back(i);
83+
}
84+
}
85+
86+
std::vector<std::pair<size_t, size_t>> segments;
87+
int64_t currBp = dp.back().second;
88+
size_t lastIdx = n;
89+
90+
// Backtracking
91+
while (currBp != -1) {
92+
size_t start = static_cast<size_t>(currBp + 1);
93+
segments.emplace_back(start, lastIdx - start);
94+
lastIdx = currBp + 1;
95+
currBp = dp[currBp].second;
96+
}
97+
segments.emplace_back(0, lastIdx);
98+
99+
// Because of backtracking, the segments are placed in reversed order.
100+
std::ranges::reverse(segments);
101+
102+
return {input, std::move(segments)};
103+
}
104+
105+
} // namespace rnexecutorch::models::text_to_speech

0 commit comments

Comments
 (0)