diff --git a/.cspell-wordlist.txt b/.cspell-wordlist.txt
index 964521ab13..ea3e2b263a 100644
--- a/.cspell-wordlist.txt
+++ b/.cspell-wordlist.txt
@@ -314,3 +314,5 @@ Partitioner
denoised
ttfa
TTFA
+phonemized
+subsentences
diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml
index d0ecea74fd..8f4af4a919 100644
--- a/.github/workflows/clang-tidy.yml
+++ b/.github/workflows/clang-tidy.yml
@@ -24,6 +24,11 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
+ with:
+ # The phonemis submodule carries headers that cpp/extensions/speech
+ # includes; without it those sources fail to parse and clang-tidy
+ # reports misleading findings on the broken AST.
+ submodules: true
- name: Setup
uses: ./.github/actions/setup
diff --git a/apps/speech/app/_layout.tsx b/apps/speech/app/_layout.tsx
index 1cf61aa30b..e98be4cd43 100644
--- a/apps/speech/app/_layout.tsx
+++ b/apps/speech/app/_layout.tsx
@@ -48,6 +48,13 @@ export default function Layout() {
title: 'Text-to-Speech (SuperTonic)',
}}
/>
+
);
}
diff --git a/apps/speech/app/index.tsx b/apps/speech/app/index.tsx
index c0dd5ee7b7..61cd5f6097 100644
--- a/apps/speech/app/index.tsx
+++ b/apps/speech/app/index.tsx
@@ -29,6 +29,12 @@ export default function Home() {
router.navigate('text-to-speech/')}>
Text-to-Speech (SuperTonic)
+ router.navigate('kokoro-text-to-speech/')}
+ >
+ Text-to-Speech (Kokoro)
+
);
diff --git a/apps/speech/app/kokoro-text-to-speech/index.tsx b/apps/speech/app/kokoro-text-to-speech/index.tsx
new file mode 100644
index 0000000000..121bfa702d
--- /dev/null
+++ b/apps/speech/app/kokoro-text-to-speech/index.tsx
@@ -0,0 +1,350 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+import { View, Text, StyleSheet, ScrollView, TextInput } from 'react-native';
+import {
+ useTextToSpeech,
+ models,
+ KOKORO_SAMPLE_RATE,
+ type KokoroTtsModel,
+} from 'react-native-executorch';
+import { AudioContext, type AudioBufferQueueSourceNode } from 'react-native-audio-api';
+
+import ScreenWrapper from '../../components/ScreenWrapper';
+import { ModelPicker } from '../../components/ModelPicker';
+import { ModelStatus } from '../../components/ModelStatus';
+import { Button } from '../../components/Button';
+import { theme } from '../../theme';
+
+const LANGUAGE_OPTIONS = [
+ { label: 'English (US)', value: 'EN_US' as const },
+ { label: 'English (GB)', value: 'EN_GB' as const },
+ { label: 'Spanish', value: 'ES' as const },
+ { label: 'French', value: 'FR' as const },
+ { label: 'Italian', value: 'IT' as const },
+ { label: 'Portuguese', value: 'PT' as const },
+ { label: 'Hindi', value: 'HI' as const },
+ { label: 'Polish', value: 'PL' as const },
+ { label: 'German', value: 'DE' as const },
+];
+
+type KokoroLanguage = (typeof LANGUAGE_OPTIONS)[number]['value'];
+
+// cspell:disable
+const SAMPLE_TEXTS: Record = {
+ EN_US:
+ 'Kokoro is a compact text-to-speech model that runs entirely on your device. ' +
+ 'It converts text into phonemes first, then predicts how long each sound should last, ' +
+ 'and finally synthesizes the waveform. No internet connection is required.',
+ EN_GB:
+ 'Kokoro is a compact text-to-speech model that runs entirely on your device, ' +
+ 'converting text into phonemes before synthesising the waveform.',
+ ES: 'Kokoro es un modelo de síntesis de voz que funciona completamente en tu dispositivo, sin conexión a internet.',
+ FR: 'Kokoro est un modèle de synthèse vocale qui fonctionne entièrement sur votre appareil, sans connexion internet.',
+ IT: 'Kokoro è un modello di sintesi vocale che funziona interamente sul tuo dispositivo, senza connessione a internet.',
+ PT: 'Kokoro é um modelo de síntese de voz que funciona inteiramente no seu dispositivo, sem ligação à internet.',
+ HI: 'कोकोरो एक छोटा टेक्स्ट-टू-स्पीच मॉडल है जो पूरी तरह से आपके डिवाइस पर चलता है।',
+ PL: 'Kokoro to niewielki model syntezy mowy, który działa w całości na twoim urządzeniu, bez połączenia z internetem.',
+ DE: 'Kokoro ist ein kompaktes Sprachsynthesemodell, das vollständig auf deinem Gerät läuft, ganz ohne Internetverbindung.',
+};
+// cspell:enable
+
+const SPEED_OPTIONS = [
+ { label: '0.8x', value: 0.8 },
+ { label: '0.9x', value: 0.9 },
+ { label: '1.0x', value: 1.0 },
+ { label: '1.1x', value: 1.1 },
+ { label: '1.25x', value: 1.25 },
+];
+
+function KokoroContent() {
+ const [language, setLanguage] = useState('EN_US');
+ const [text, setText] = useState(SAMPLE_TEXTS.EN_US);
+ const [speed, setSpeed] = useState(1.0);
+ const [isSynthesizing, setIsSynthesizing] = useState(false);
+ const [isPlaying, setIsPlaying] = useState(false);
+ const [chunkProgress, setChunkProgress] = useState(null);
+ const [runError, setRunError] = useState(null);
+ const [totalDuration, setTotalDuration] = useState(null);
+
+ const model = models.textToSpeech.KOKORO[language].XNNPACK_FP32 as KokoroTtsModel;
+ const voiceNames = Object.keys(model.voices);
+ const [voice, setVoice] = useState(voiceNames[0]!);
+
+ const audioCtxRef = useRef(null);
+ const queueSourceRef = useRef(null);
+
+ const { isReady, downloadProgress, error, synthesize, synthesizeStop } = useTextToSpeech(model);
+
+ useEffect(() => {
+ setVoice(Object.keys(models.textToSpeech.KOKORO[language].XNNPACK_FP32.voices)[0]!);
+ setText(SAMPLE_TEXTS[language]);
+ }, [language]);
+
+ const getAudioContext = useCallback(async () => {
+ if (!audioCtxRef.current || audioCtxRef.current.state === 'closed') {
+ audioCtxRef.current = new AudioContext({ sampleRate: KOKORO_SAMPLE_RATE });
+ }
+ if (audioCtxRef.current.state === 'suspended') {
+ await audioCtxRef.current.resume();
+ }
+ return audioCtxRef.current;
+ }, []);
+
+ const stopAudioQueue = useCallback(() => {
+ if (queueSourceRef.current) {
+ queueSourceRef.current.clearBuffers();
+ queueSourceRef.current.stop();
+ queueSourceRef.current = null;
+ }
+ setIsPlaying(false);
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ synthesizeStop?.();
+ stopAudioQueue();
+ if (audioCtxRef.current) {
+ audioCtxRef.current.close().catch(() => {});
+ audioCtxRef.current = null;
+ }
+ };
+ }, [stopAudioQueue, synthesizeStop]);
+
+ const preparePlaybackSource = useCallback(async () => {
+ stopAudioQueue();
+ const ctx = await getAudioContext();
+
+ const source = ctx.createBufferQueueSource();
+ source.connect(ctx.destination);
+ source.onBufferEnded = (event) => {
+ if (event.isLastBufferInQueue) setIsPlaying(false);
+ };
+ queueSourceRef.current = source;
+ return { ctx, source };
+ }, [getAudioContext, stopAudioQueue]);
+
+ const handleSynthesize = async () => {
+ if (!synthesize || isSynthesizing || !text.trim()) return;
+
+ setRunError(null);
+ setChunkProgress(null);
+ setTotalDuration(null);
+ setIsSynthesizing(true);
+
+ try {
+ const { ctx, source } = await preparePlaybackSource();
+ let durationSum = 0;
+ let started = false;
+
+ for await (const chunk of synthesize(text, { voice, speed })) {
+ setChunkProgress(
+ `Chunk ${chunk.chunkIndex + 1}/${chunk.totalChunks} (${chunk.duration.toFixed(1)}s)`
+ );
+ durationSum += chunk.duration;
+
+ const buffer = ctx.createBuffer(1, chunk.audio.length, KOKORO_SAMPLE_RATE);
+ buffer.copyToChannel(chunk.audio as Float32Array, 0);
+ source.enqueueBuffer(buffer);
+
+ if (!started) {
+ started = true;
+ setIsPlaying(true);
+ source.start(0, 0);
+ }
+ }
+
+ setTotalDuration(durationSum);
+ setChunkProgress(null);
+ } catch (err) {
+ setRunError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setIsSynthesizing(false);
+ }
+ };
+
+ const handleStopPlayback = () => {
+ synthesizeStop?.();
+ stopAudioQueue();
+ setIsSynthesizing(false);
+ };
+
+ const isBusy = isSynthesizing || isPlaying;
+
+ return (
+
+
+ Kokoro Text-to-Speech
+
+ Phoneme-driven on-device speech synthesis. Pick a language to load its model, phonemizer
+ assets and voices.
+
+
+
+
+ {runError && (
+
+ {runError}
+
+ )}
+
+
+ ({ ...l, disabled: isBusy }))}
+ selectedValue={language}
+ onValueChange={setLanguage}
+ />
+ ({ label: name, value: name, disabled: isBusy }))}
+ selectedValue={voice}
+ onValueChange={setVoice}
+ />
+ ({ ...s, disabled: isBusy }))}
+ selectedValue={speed}
+ onValueChange={setSpeed}
+ />
+
+
+
+ Input Text
+
+
+
+
+
+ {!isPlaying ? (
+
+ ) : (
+
+ )}
+
+
+ {chunkProgress && (
+
+ {chunkProgress}
+
+ )}
+
+ {totalDuration !== null && !isSynthesizing && (
+
+
+ Generated {totalDuration.toFixed(1)}s of audio at {KOKORO_SAMPLE_RATE} Hz
+
+
+ )}
+
+
+ );
+}
+
+export default function KokoroScreen() {
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: { flex: 1, backgroundColor: theme.colors.background },
+ content: { padding: theme.spacing.large, paddingBottom: 40 },
+ card: {
+ backgroundColor: theme.colors.cardBackground,
+ borderRadius: theme.radius.large,
+ padding: 20,
+ marginBottom: 20,
+ borderWidth: 1,
+ borderColor: theme.colors.lightBorder,
+ },
+ cardTitle: {
+ fontSize: theme.typography.title.fontSize,
+ fontWeight: theme.typography.title.fontWeight,
+ color: theme.colors.strongPrimary,
+ marginBottom: 8,
+ },
+ cardDescription: {
+ fontSize: 14,
+ color: theme.colors.textMuted,
+ lineHeight: 20,
+ marginBottom: 16,
+ },
+ sectionTitle: {
+ fontSize: 16,
+ fontWeight: '700',
+ color: '#212529',
+ marginBottom: 10,
+ },
+ textInput: {
+ height: 120,
+ borderColor: theme.colors.border,
+ borderWidth: 1,
+ borderRadius: theme.radius.small,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ color: theme.colors.textSecondary,
+ backgroundColor: theme.colors.background,
+ fontSize: 14,
+ lineHeight: 20,
+ textAlignVertical: 'top',
+ },
+ buttonRow: {
+ flexDirection: 'row',
+ gap: theme.spacing.small,
+ },
+ progressContainer: {
+ marginTop: 12,
+ padding: 10,
+ backgroundColor: '#e8f4fd',
+ borderRadius: theme.radius.small,
+ },
+ progressText: {
+ fontSize: 13,
+ color: '#1a73e8',
+ fontWeight: '500',
+ textAlign: 'center',
+ },
+ resultContainer: {
+ marginTop: 12,
+ padding: 10,
+ backgroundColor: '#e6f9e6',
+ borderRadius: theme.radius.small,
+ },
+ resultText: {
+ fontSize: 13,
+ color: '#2e7d32',
+ fontWeight: '500',
+ textAlign: 'center',
+ },
+ errorContainer: {
+ backgroundColor: theme.colors.errorBackground,
+ padding: 12,
+ borderRadius: theme.radius.small,
+ marginBottom: 20,
+ },
+ errorText: {
+ color: theme.colors.errorText,
+ fontSize: 14,
+ textAlign: 'center',
+ },
+});
diff --git a/apps/speech/package.json b/apps/speech/package.json
index 3b27e92ad7..23d387d2df 100644
--- a/apps/speech/package.json
+++ b/apps/speech/package.json
@@ -4,7 +4,8 @@
"main": "expo-router/entry",
"react-native-executorch": {
"features": [
- "vad"
+ "vad",
+ "textToSpeech"
]
},
"scripts": {
diff --git a/packages/react-native-executorch/android/CMakeLists.txt b/packages/react-native-executorch/android/CMakeLists.txt
index 0212e869f8..703a78db8b 100644
--- a/packages/react-native-executorch/android/CMakeLists.txt
+++ b/packages/react-native-executorch/android/CMakeLists.txt
@@ -29,6 +29,9 @@ file(GLOB NLP_SOURCES ${CPP_DIR}/extensions/nlp/*.cpp)
file(GLOB SPEECH_SOURCES ${CPP_DIR}/extensions/speech/*.cpp)
file(GLOB OPENCV_SOURCES ${CPP_DIR}/extensions/cv/*.cpp)
+set(PHONEMIS_SOURCES ${CPP_DIR}/extensions/speech/phonemizer.cpp)
+list(FILTER SPEECH_SOURCES EXCLUDE REGEX "/phonemizer\\.cpp$")
+
set(RNE_SOURCES
${CPP_DIR}/RnExecutorch.cpp
${CORE_SOURCES}
@@ -42,6 +45,10 @@ if(RNE_ENABLE_OPENCV)
list(APPEND RNE_SOURCES ${OPENCV_SOURCES})
endif()
+if(RNE_ENABLE_PHONEMIS)
+ list(APPEND RNE_SOURCES ${PHONEMIS_SOURCES})
+endif()
+
add_library(${CMAKE_PROJECT_NAME} SHARED ${RNE_SOURCES})
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE
@@ -56,6 +63,16 @@ if(RNE_ENABLE_PHONEMIS)
target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE RNE_ENABLE_PHONEMIS)
endif()
+# ------- phonemis (optional, static, built from in-tree source) -------
+if(RNE_ENABLE_PHONEMIS)
+ set(PHONEMIS_DIR "${THIRD_PARTY_DIR}/common/phonemis")
+ add_subdirectory(${PHONEMIS_DIR} ${CMAKE_BINARY_DIR}/phonemis)
+ # phonemis uses ET_ON to detect an available ExecuTorch build (NeuralPhonemizer).
+ target_compile_definitions(phonemis PRIVATE ET_ON)
+ target_include_directories(phonemis PRIVATE "${INCLUDE_DIR}")
+ target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE "${PHONEMIS_DIR}/src")
+endif()
+
target_include_directories(${CMAKE_PROJECT_NAME} PRIVATE
${CPP_DIR}
${INCLUDE_DIR}
@@ -121,6 +138,10 @@ target_link_libraries(${CMAKE_PROJECT_NAME}
z
)
+if(RNE_ENABLE_PHONEMIS)
+ target_link_libraries(${CMAKE_PROJECT_NAME} phonemis)
+endif()
+
# Linking against the backend .so (when enabled) makes Gradle bundle them and
# instructs the dynamic linker to load them with libreact-native-executorch.so.
if(RNE_ENABLE_XNNPACK)
diff --git a/packages/react-native-executorch/compile_flags.txt b/packages/react-native-executorch/compile_flags.txt
index 2c10ab04f7..0645dc75ca 100644
--- a/packages/react-native-executorch/compile_flags.txt
+++ b/packages/react-native-executorch/compile_flags.txt
@@ -4,6 +4,7 @@
-Icpp
-isystem../../node_modules/react-native/ReactCommon/jsi
-isystemthird-party/include
+-isystemthird-party/common/phonemis/src
-isystemthird-party/include/executorch/extension/llm/tokenizers/include
-isystemthird-party/include/executorch/extension/llm/tokenizers/third-party/json/include
-isystemthird-party/include/executorch/extension/llm/tokenizers/third-party/re2
diff --git a/packages/react-native-executorch/cpp/core/dtype.cpp b/packages/react-native-executorch/cpp/core/dtype.cpp
index 8e827206d3..d6283539e1 100644
--- a/packages/react-native-executorch/cpp/core/dtype.cpp
+++ b/packages/react-native-executorch/cpp/core/dtype.cpp
@@ -1,4 +1,5 @@
#include "dtype.h"
+#include
#include
#include "core/error.h"
@@ -17,7 +18,11 @@ DType dtypeFromString(const std::string &s) {
if (s == "float32") {
return DType::float32;
}
- throw error::InvalidArgument("Unsupported dtype: '" + s + "'. Expected 'uint8', 'int32', 'int64', or 'float32'");
+ if (s == "bool") {
+ return DType::boolean;
+ }
+ throw error::InvalidArgument(
+ std::format("Unsupported dtype: '{}'. Expected 'uint8', 'int32', 'int64', 'float32' or 'bool'", s));
}
std::string dtypeToString(DType dtype) {
@@ -30,6 +35,8 @@ std::string dtypeToString(DType dtype) {
return "int64";
case DType::float32:
return "float32";
+ case DType::boolean:
+ return "bool";
}
}
@@ -43,6 +50,8 @@ executorch::aten::ScalarType dtypeToScalarType(DType dtype) {
return executorch::aten::ScalarType::Long;
case DType::float32:
return executorch::aten::ScalarType::Float;
+ case DType::boolean:
+ return executorch::aten::ScalarType::Bool;
}
}
@@ -56,6 +65,8 @@ DType dtypeFromScalarType(executorch::aten::ScalarType st) {
return DType::int64;
case executorch::aten::ScalarType::Float:
return DType::float32;
+ case executorch::aten::ScalarType::Bool:
+ return DType::boolean;
default:
throw error::InvalidArgument("Unsupported ScalarType");
}
@@ -63,6 +74,9 @@ DType dtypeFromScalarType(executorch::aten::ScalarType st) {
size_t elementSize(DType dtype) {
switch (dtype) {
+ // NOLINTNEXTLINE(bugprone-branch-clone): boolean and uint8 are both 1 bytes; the identical branches are intentional.
+ case DType::boolean:
+ return 1;
case DType::uint8:
return 1;
// NOLINTNEXTLINE(bugprone-branch-clone): int32 and float32 are both 4 bytes; the identical branches are intentional.
diff --git a/packages/react-native-executorch/cpp/core/dtype.h b/packages/react-native-executorch/cpp/core/dtype.h
index e0528734f0..99b84e5c8d 100644
--- a/packages/react-native-executorch/cpp/core/dtype.h
+++ b/packages/react-native-executorch/cpp/core/dtype.h
@@ -13,13 +13,14 @@ enum class DType {
uint8,
int32,
int64,
- float32
+ float32,
+ boolean
};
/**
* Parses a string representation into a DType enum value.
*
- * @param s The string name of the data type (e.g. "uint8", "int32", "int64", "float32").
+ * @param s The string name of the data type (e.g. "uint8", "int32", "int64", "float32", "bool").
* @return The corresponding DType enum value.
* @throws error::RnExecuTorchException with code InvalidArgument if the string
* does not match any known DType.
diff --git a/packages/react-native-executorch/cpp/extensions/cv/utils.h b/packages/react-native-executorch/cpp/extensions/cv/utils.h
index d300a55614..f9b8c468a8 100644
--- a/packages/react-native-executorch/cpp/extensions/cv/utils.h
+++ b/packages/react-native-executorch/cpp/extensions/cv/utils.h
@@ -23,6 +23,7 @@ inline int dtypeToCvDepth(rnexecutorch::core::types::DType dtype) {
case rnexecutorch::core::types::DType::float32:
return CV_32F;
case rnexecutorch::core::types::DType::int64:
+ case rnexecutorch::core::types::DType::boolean:
break;
}
throw core::error::InvalidArgument("unsupported dtype");
diff --git a/packages/react-native-executorch/cpp/extensions/speech/install.cpp b/packages/react-native-executorch/cpp/extensions/speech/install.cpp
index 78b014eb89..94edbca161 100644
--- a/packages/react-native-executorch/cpp/extensions/speech/install.cpp
+++ b/packages/react-native-executorch/cpp/extensions/speech/install.cpp
@@ -1,6 +1,10 @@
#include "install.h"
#include "operations.h"
+#ifdef RNE_ENABLE_PHONEMIS
+#include "phonemizer.h"
+#endif
+
namespace rnexecutorch::extensions::speech {
namespace jsi = facebook::jsi;
@@ -8,6 +12,9 @@ void install(jsi::Runtime &rt, jsi::Object &module) {
jsi::Object speechModule(rt);
install_extractFrames(rt, speechModule);
+#ifdef RNE_ENABLE_PHONEMIS
+ install_createPhonemizer(rt, speechModule);
+#endif
module.setProperty(rt, "speech", speechModule);
}
diff --git a/packages/react-native-executorch/cpp/extensions/speech/phonemizer.cpp b/packages/react-native-executorch/cpp/extensions/speech/phonemizer.cpp
new file mode 100644
index 0000000000..2d80791a01
--- /dev/null
+++ b/packages/react-native-executorch/cpp/extensions/speech/phonemizer.cpp
@@ -0,0 +1,124 @@
+#include "phonemizer.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "core/conversions.h"
+#include "core/error.h"
+
+#include
+
+namespace rnexecutorch::extensions::speech {
+
+namespace jsi = facebook::jsi;
+namespace conversions = rnexecutorch::core::conversions;
+namespace error = rnexecutorch::core::error;
+using phonemis::utils::conversions::u32_to_utf8;
+using phonemis::utils::conversions::utf8_to_u32;
+
+PhonemizerHostObject::PhonemizerHostObject(
+ const std::string &lang,
+ const std::optional &taggerPath,
+ const std::optional &lexiconPath,
+ const std::optional &neuralModelPath)
+ : pipeline_(std::make_unique(phonemis::Config{
+ .lang = lang,
+ .tagger = taggerPath ? std::make_optional(phonemis::tagger::Config{.data_filepath = taggerPath})
+ : std::nullopt,
+ .phonemizer = phonemis::phonemizer::Config{
+ .lang = lang,
+ .lexicon_filepath = lexiconPath,
+ .nn_model_filepath = neuralModelPath,
+ }})) {}
+
+std::unique_lock PhonemizerHostObject::tryLockUnique(std::string_view context) {
+ std::unique_lock lock(mutex_, std::try_to_lock);
+ if (!lock.owns_lock()) {
+ throw error::ResourceBusy(std::format("{} is currently in use", context));
+ }
+ if (!pipeline_) {
+ throw error::ResourceDisposed(std::format("{} has been disposed", context));
+ }
+
+ return lock;
+}
+
+jsi::Value PhonemizerHostObject::get(jsi::Runtime &rt,
+ const jsi::PropNameID &name) {
+ auto nameStr = name.utf8(rt);
+
+ if (nameStr == "phonemize") {
+ auto self = shared_from_this();
+ auto fnBody = [self](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args, size_t count) -> jsi::Value {
+ if (count != 1) {
+ throw error::InvalidArgument("phonemize: Usage: phonemize(text)");
+ }
+
+ auto lock = self->tryLockUnique("phonemize: Phonemizer");
+
+ auto utf8 = conversions::asType(rt, "phonemize: text", args[0]);
+ auto phonemes = (*self->pipeline_)(utf8_to_u32(utf8));
+
+ return jsi::String::createFromUtf8(rt, u32_to_utf8(phonemes));
+ };
+ return jsi::Function::createFromHostFunction(
+ rt, jsi::PropNameID::forAscii(rt, "phonemize"), 1, error::guarded(fnBody));
+ }
+
+ if (nameStr == "dispose") {
+ auto self = shared_from_this();
+ auto fnBody = [self](jsi::Runtime & /*rt*/, const jsi::Value &, const jsi::Value * /*args*/, size_t count) -> jsi::Value {
+ if (count != 0) {
+ throw error::InvalidArgument("dispose: Usage: dispose()");
+ }
+
+ std::unique_lock lock(self->mutex_);
+ self->pipeline_.reset();
+ return jsi::Value::undefined();
+ };
+ return jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, "dispose"), 0, error::guarded(fnBody));
+ }
+
+ return jsi::Value::undefined();
+}
+
+std::vector PhonemizerHostObject::getPropertyNames(
+ jsi::Runtime &rt) {
+ std::vector props;
+ props.push_back(jsi::PropNameID::forAscii(rt, "phonemize"));
+ props.push_back(jsi::PropNameID::forAscii(rt, "dispose"));
+ return props;
+}
+
+void install_createPhonemizer(jsi::Runtime &rt, jsi::Object &module) {
+ const auto *name = "createPhonemizer";
+ auto fnBody = [](jsi::Runtime &rt, const jsi::Value &,
+ const jsi::Value *args, size_t count) -> jsi::Value {
+ if (count != 1) {
+ throw error::InvalidArgument("createPhonemizer: Usage: createPhonemizer(config)");
+ }
+
+ constexpr auto *ctx = "createPhonemizer: config";
+ auto config = conversions::asType(rt, ctx, args[0]);
+
+ auto lang = conversions::getRequiredProperty(rt, ctx, config, "lang");
+ auto taggerPath = conversions::getOptionalProperty(rt, ctx, config, "taggerSource");
+ auto lexiconPath = conversions::getOptionalProperty(rt, ctx, config, "lexiconSource");
+ auto neuralPath = conversions::getOptionalProperty(rt, ctx, config, "neuralModelSource");
+
+ try {
+ auto instance = std::make_shared(lang, taggerPath, lexiconPath, neuralPath);
+ return jsi::Object::createFromHostObject(rt, instance);
+ } catch (const std::exception &e) {
+ throw error::LoadFailed(std::format("createPhonemizer: {}", e.what()));
+ }
+ };
+
+ module.setProperty(rt, name, jsi::Function::createFromHostFunction(rt, jsi::PropNameID::forAscii(rt, name), 1, error::guarded(fnBody)));
+}
+
+} // namespace rnexecutorch::extensions::speech
diff --git a/packages/react-native-executorch/cpp/extensions/speech/phonemizer.h b/packages/react-native-executorch/cpp/extensions/speech/phonemizer.h
new file mode 100644
index 0000000000..590f1b9f4f
--- /dev/null
+++ b/packages/react-native-executorch/cpp/extensions/speech/phonemizer.h
@@ -0,0 +1,45 @@
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+namespace rnexecutorch::extensions::speech {
+
+class PhonemizerHostObject : public facebook::jsi::HostObject,
+ public std::enable_shared_from_this {
+public:
+ explicit PhonemizerHostObject(const std::string &lang,
+ const std::optional &taggerPath,
+ const std::optional &lexiconPath,
+ const std::optional &neuralModelPath);
+
+ facebook::jsi::Value get(facebook::jsi::Runtime &rt, const facebook::jsi::PropNameID &name) override;
+ std::vector getPropertyNames(facebook::jsi::Runtime &rt) override;
+
+private:
+ /**
+ * Tries to acquire a unique lock on the phonemizer's mutex.
+ *
+ * @param context Context description used to generate helpful error messages.
+ * @return A unique lock protecting the pipeline.
+ * @throws core::error::RnExecuTorchException with code ResourceBusy if the
+ * lock is currently held by another thread, or ResourceDisposed if the
+ * phonemizer has already been disposed.
+ */
+ [[nodiscard]] std::unique_lock tryLockUnique(std::string_view context);
+
+ /** Owning pointer to the underlying phonemis G2P pipeline. */
+ std::unique_ptr pipeline_;
+ /** Mutex guarding concurrent access to the pipeline. */
+ std::mutex mutex_;
+};
+
+void install_createPhonemizer(facebook::jsi::Runtime &rt, facebook::jsi::Object &module);
+
+} // namespace rnexecutorch::extensions::speech
diff --git a/packages/react-native-executorch/react-native-executorch.podspec b/packages/react-native-executorch/react-native-executorch.podspec
index 2d091e33d8..983694081b 100644
--- a/packages/react-native-executorch/react-native-executorch.podspec
+++ b/packages/react-native-executorch/react-native-executorch.podspec
@@ -54,21 +54,30 @@ Pod::Spec.new do |s|
"cpp/extensions/cv/**/*.{cpp,c,h,hpp}",
]
- s.source_files = [
+ # phonemis is built from in-tree source (third-party/common/phonemis submodule);
+ # its runner entrypoint is excluded so only the library sources compile.
+ phonemis_source_files = [
+ "cpp/extensions/speech/phonemizer.{cpp,h}",
+ "third-party/common/phonemis/src/**/*.{cpp,hpp,h}",
+ ]
+
+ source_files = [
"ios/**/*.{h,m,mm}",
"cpp/**/*.{cpp,c,h,hpp}",
]
+ source_files += phonemis_source_files if enable_phonemis
+ s.source_files = source_files
- exclude_files = []
+ exclude_files = ["third-party/common/phonemis/src/phonemis/main.cpp"]
exclude_files += opencv_source_files unless enable_opencv
+ exclude_files += phonemis_source_files unless enable_phonemis
s.exclude_files = exclude_files
# --- Preprocessor flags ---
- # phonemis is wired for forward-compat (the TTS task is not yet ported to the
- # rewrite, so no source compiles against it today).
extra_compiler_flags = []
extra_compiler_flags << "-DRNE_ENABLE_OPENCV" if enable_opencv
- extra_compiler_flags << "-DRNE_ENABLE_PHONEMIS" if enable_phonemis
+ # ET_ON lets phonemis detect the available ExecuTorch build (NeuralPhonemizer).
+ extra_compiler_flags += ["-DRNE_ENABLE_PHONEMIS", "-DET_ON"] if enable_phonemis
extra_compiler_flags << "-DRNE_ENABLE_XNNPACK" if enable_xnnpack
extra_compiler_flags << "-DRNE_ENABLE_COREML" if enable_coreml
extra_compiler_flags << "-DRNE_ENABLE_MLX" if enable_mlx
@@ -127,6 +136,7 @@ Pod::Spec.new do |s|
"\"$(PODS_TARGET_SRCROOT)/third-party/include/executorch/extension/llm/tokenizers/third-party/json/include\"",
"\"$(PODS_TARGET_SRCROOT)/third-party/include/executorch/extension/llm/tokenizers/third-party/re2\"",
"\"$(PODS_TARGET_SRCROOT)/third-party/include/executorch/extension/llm/tokenizers/third-party/abseil-cpp\"",
+ "\"$(PODS_TARGET_SRCROOT)/third-party/common/phonemis/src\"",
].join(' '),
"WARNING_CFLAGS" => "-Wno-documentation",
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'x86_64',
diff --git a/packages/react-native-executorch/src/core/schema.ts b/packages/react-native-executorch/src/core/schema.ts
index d0d1aa012b..446e5f615c 100644
--- a/packages/react-native-executorch/src/core/schema.ts
+++ b/packages/react-native-executorch/src/core/schema.ts
@@ -345,6 +345,7 @@ export const f32 = (...shape: SymbolicShape) => SymbolicTensor('float32', shape)
export const i64 = (...shape: SymbolicShape) => SymbolicTensor('int64', shape);
export const i32 = (...shape: SymbolicShape) => SymbolicTensor('int32', shape);
export const ui8 = (...shape: SymbolicShape) => SymbolicTensor('uint8', shape);
+export const bool = (...shape: SymbolicShape) => SymbolicTensor('bool', shape);
/** Helper namespace for declaring runtime constraints. */
export const constr = {
diff --git a/packages/react-native-executorch/src/core/tensor.ts b/packages/react-native-executorch/src/core/tensor.ts
index 4c54c0d32f..b54df997c5 100644
--- a/packages/react-native-executorch/src/core/tensor.ts
+++ b/packages/react-native-executorch/src/core/tensor.ts
@@ -6,7 +6,7 @@ declare const tensorBrand: unique symbol;
* Element data type of a {@link Tensor}.
* @category Types
*/
-export type DType = 'float32' | 'uint8' | 'int32' | 'int64';
+export type DType = 'float32' | 'uint8' | 'int32' | 'int64' | 'bool';
/**
* A native ExecuTorch tensor allocated in C++ memory.
@@ -50,7 +50,8 @@ export type Tensor = {
/**
* Writes data from a typed array into this tensor's native buffer.
* @param src The source typed array. Its size in bytes must match the
- * tensor's size. Use a `BigInt64Array` for `int64` tensors.
+ * tensor's size. Use a `BigInt64Array` for `int64` tensors and a
+ * `Uint8Array` for `bool` tensors.
* @returns `this` tensor.
*/
setData(src: Float32Array | Uint8Array | Int32Array | BigInt64Array): Tensor;
diff --git a/packages/react-native-executorch/src/extensions/math.ts b/packages/react-native-executorch/src/extensions/math.ts
index 85469455be..a178bd9169 100644
--- a/packages/react-native-executorch/src/extensions/math.ts
+++ b/packages/react-native-executorch/src/extensions/math.ts
@@ -1,5 +1,6 @@
import { rnexecutorchJsi } from '../native/bridge';
import type { Tensor } from '../core/tensor';
+import { RnExecuTorchError } from '../core/error';
/**
* Computes the element-wise sigmoid activation on a float32 source tensor and
@@ -117,3 +118,49 @@ export function randomNormal(
}
return out;
}
+
+/**
+ * Repeats each element of `values` as many times as the matching entry of
+ * `repeats`, concatenating the runs into a newly allocated array of the same
+ * kind — the equivalent of PyTorch's `repeat_interleave` over a 1-D input.
+ *
+ * Non-positive repeat counts drop their element.
+ * @category Typescript API
+ * @typeParam T The array kind of `values` (any typed array or a plain array),
+ * preserved in the result.
+ * @param values The values to repeat.
+ * @param repeats The repeat count for each value. Must be the same length as
+ * `values`.
+ * @returns A new array of the same kind holding the repeated runs.
+ * @throws {RnExecuTorchError} With code `INVALID_ARGUMENT` if `repeats` and
+ * `values` have different lengths.
+ */
+export function repeatInterleave | ArrayLike>(
+ values: T,
+ repeats: ArrayLike
+): T {
+ 'worklet';
+ if (repeats.length !== values.length) {
+ throw RnExecuTorchError(
+ 'INVALID_ARGUMENT',
+ `repeatInterleave: repeats length (${repeats.length}) must match values length (${values.length}).`
+ );
+ }
+
+ let total = 0;
+ for (let i = 0; i < repeats.length; i++) total += Math.max(0, repeats[i]!);
+
+ const Ctor = values.constructor as new (length: number) => T;
+ const out = new Ctor(total);
+ const target = out as Record;
+
+ let next = 0;
+ for (let i = 0; i < values.length; i++) {
+ const value = values[i]!;
+ const count = Math.max(0, repeats[i]!);
+
+ for (let j = 0; j < count; j++) target[next++] = value;
+ }
+
+ return out;
+}
diff --git a/packages/react-native-executorch/src/extensions/speech/index.ts b/packages/react-native-executorch/src/extensions/speech/index.ts
index c17541c21b..42c3635917 100644
--- a/packages/react-native-executorch/src/extensions/speech/index.ts
+++ b/packages/react-native-executorch/src/extensions/speech/index.ts
@@ -1,3 +1,5 @@
export * from './utils/vadUtils';
export * from './utils/supertonicUtils';
+export * from './utils/kokoroUtils';
+export * from './utils/phonemizer';
export * from './utils/textPartitioner';
diff --git a/packages/react-native-executorch/src/extensions/speech/tasks/kokoroTextToSpeech.ts b/packages/react-native-executorch/src/extensions/speech/tasks/kokoroTextToSpeech.ts
new file mode 100644
index 0000000000..ee3d0459d7
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/speech/tasks/kokoroTextToSpeech.ts
@@ -0,0 +1,421 @@
+import type { WorkletRuntime } from 'react-native-worklets';
+
+import RNBlobUtil from 'react-native-blob-util';
+
+import { tensor, type Tensor } from '../../../core/tensor';
+import { loadModel } from '../../../core/model';
+import {
+ validateSpec,
+ method,
+ i64,
+ f32,
+ bool,
+ DynamicDim as Dyn,
+ constr,
+} from '../../../core/schema';
+import { wrapAsync } from '../../../core/runtime';
+import { RnExecuTorchError } from '../../../core/error';
+import { createPhonemizer, type PhonemizerConfig } from '../utils/phonemizer';
+import { partition } from '../utils/textPartitioner';
+import { repeatInterleave } from '../../math';
+import {
+ parseVoice,
+ scaleDurations,
+ stripAudio,
+ tokenize,
+ KOKORO_PAUSE_MS,
+ KOKORO_TICKS_PER_DURATION as TICKS_PER_DURATION,
+ KOKORO_VOICE_REF_SIZE as VOICE_REF_SIZE,
+} from '../utils/kokoroUtils';
+
+/**
+ * Kokoro audio sampling rate in Hz (24000).
+ * @category Constants
+ */
+export const KOKORO_SAMPLE_RATE = 24000;
+
+const SAMPLES_PER_MS = KOKORO_SAMPLE_RATE / 1000;
+const VOICE_REF_HALF_SIZE = VOICE_REF_SIZE / 2;
+const DURATION_FEATURE_DIM = 640; // per-token duration features consumed by the synthesizer
+const MIN_DURATION_TICKS = 16;
+
+const DEFAULT_SPEED = 1.0;
+const MIN_SPEED = 0.1;
+const MAX_SPEED = 3.0;
+const SILENCE_PADDING_MS = 50; // silence kept at both edges of a synthesized chunk
+
+// Distinguishes spoken phonemes from punctuation and suprasegmental markers.
+const LETTER_PATTERN = /\p{L}/u;
+
+/**
+ * Model configuration required to instantiate the Kokoro Text-to-Speech pipeline.
+ * @category Types
+ * @typeParam K Voice keys record constraint (strictly inferred from voices keys).
+ */
+export type KokoroTtsModel = {
+ /** Discriminates this config from the other Text-to-Speech pipelines. */
+ readonly name: 'kokoro';
+ /** Local or remote file paths to the 2 Kokoro `.pte` sub-models. */
+ readonly modelPaths: {
+ /** Path to the duration predictor `.pte` model. */
+ readonly durationPredictor: string;
+ /** Path to the synthesizer `.pte` model. */
+ readonly synthesizer: string;
+ };
+ /** Grapheme-to-phoneme configuration matching the model's language. */
+ readonly phonemizer: PhonemizerConfig;
+ /** Map of voice names to local or remote voice `.bin` file paths. */
+ readonly voices: Record;
+};
+
+/**
+ * Per-call execution options for Kokoro Text-to-Speech synthesis.
+ * @category Types
+ * @typeParam K Voice keys record constraint.
+ */
+export type KokoroTtsOptions = {
+ /** Voice name matching one of the keys in `config.voices`. */
+ readonly voice: K;
+ /** Speech speed factor (range: 0.1 to 3.0). */
+ readonly speed?: number;
+ /** If false, the input is treated as IPA phonemes and not phonemized. */
+ readonly phonemize?: boolean;
+ /** Maximum phoneme count per chunk. Defaults to the model's token limit. */
+ readonly maxChunkLength?: number;
+};
+
+/**
+ * Audio output chunk yielded by the {@link createKokoroTextToSpeech} generator.
+ * @category Types
+ */
+export type KokoroTtsChunk = {
+ /** Float32 PCM audio samples for this chunk, normalized in `[-1, 1]`. */
+ readonly audio: Float32Array;
+ /** Audio sampling rate in Hz (see {@link KOKORO_SAMPLE_RATE}). */
+ readonly sampleRate: number;
+ /** Duration of this audio chunk in seconds. */
+ readonly duration: number;
+ /** Zero-based index of this chunk. */
+ readonly chunkIndex: number;
+ /** Total number of chunks partitioned from the input text. */
+ readonly totalChunks: number;
+};
+
+/**
+ * Creates a Kokoro Text-to-Speech pipeline.
+ *
+ * It validates both sub-model method schemas, builds the grapheme-to-phoneme
+ * pipeline, pre-parses the voice files into memory, and registers disposal
+ * hooks to release all native resources.
+ * @category Typescript API
+ * @typeParam K Voice keys record constraint.
+ * @param config Kokoro TTS pipeline configuration containing model and asset paths.
+ * @param runtime Optional worklet runtime thread on which to run inference.
+ * @returns A promise resolving to an object with audio synthesis and disposal controls.
+ */
+export async function createKokoroTextToSpeech(
+ config: KokoroTtsModel,
+ runtime?: WorkletRuntime
+): Promise<{
+ /** Releases the allocated native models, phonemizer and execution tensors. */
+ dispose: () => void;
+
+ /**
+ * Streams synthesized audio chunks as an async generator as each text chunk finishes.
+ * @param text Input text (or IPA phonemes) to synthesize into speech.
+ * @param options Per-call execution options.
+ * @param options.voice Voice name, one of the keys of the config's `voices`.
+ * @param options.speed Speech speed factor (range: 0.1 to 3.0). Defaults to 1.0.
+ * @param options.phonemize Whether to phonemize the input. Defaults to true.
+ * @param options.maxChunkLength Maximum phoneme count per chunk.
+ * @returns An AsyncGenerator yielding {@link KokoroTtsChunk} audio buffers.
+ */
+ synthesize: (text: string, options: KokoroTtsOptions) => AsyncGenerator;
+
+ /** Cancels any in-flight synthesis started by {@link synthesize}. */
+ synthesizeStop: () => void;
+}> {
+ const load = wrapAsync(loadModel, runtime);
+ const [durationPredictor, synthesizer] = await Promise.all([
+ load(config.modelPaths.durationPredictor),
+ load(config.modelPaths.synthesizer),
+ ]);
+ const models = { durationPredictor, synthesizer };
+
+ const allocated: { dispose: () => void }[] = [durationPredictor, synthesizer];
+ const dispose = () => allocated.forEach((resource) => resource.dispose());
+
+ try {
+ const predictorSpec = validateSpec(models.durationPredictor.schema, {
+ default: method(
+ 'forward',
+ [
+ i64(1, Dyn('T')), // tokens
+ bool(1, Dyn('T')), // textMask
+ f32(1, VOICE_REF_HALF_SIZE), // voiceRef
+ f32(1), // speed
+ ],
+ [
+ i64(Dyn('T')), // predictedDurations
+ f32(1, Dyn('T'), DURATION_FEATURE_DIM), // durationFeatures
+ ],
+ [
+ constr.eq(
+ { paramSide: 'input', tensorIdx: 0, dimIdx: 1 },
+ { paramSide: 'input', tensorIdx: 1, dimIdx: 1 },
+ { paramSide: 'output', tensorIdx: 0, dimIdx: 0 },
+ { paramSide: 'output', tensorIdx: 1, dimIdx: 1 }
+ ),
+ ]
+ ),
+ });
+
+ const synthesizerSpec = validateSpec(models.synthesizer.schema, {
+ default: method(
+ 'forward',
+ [
+ i64(1, Dyn('T')), // tokens
+ bool(1, Dyn('T')), // textMask
+ i64(Dyn('D')), // indices
+ f32(1, Dyn('T'), DURATION_FEATURE_DIM), // durationFeatures
+ f32(1, VOICE_REF_SIZE), // voiceRef
+ ],
+ [f32(1, 1, Dyn('AUDIO_LEN'))], // audio
+ [
+ constr.eq(
+ { paramSide: 'input', tensorIdx: 0, dimIdx: 1 },
+ { paramSide: 'input', tensorIdx: 1, dimIdx: 1 },
+ { paramSide: 'input', tensorIdx: 3, dimIdx: 1 }
+ ),
+ constr.linear(
+ { paramSide: 'output', tensorIdx: 0, dimIdx: 2 },
+ { paramSide: 'input', tensorIdx: 2, dimIdx: 0 },
+ TICKS_PER_DURATION
+ ),
+ ]
+ ),
+ });
+
+ const [predictorTokens] = predictorSpec.dims.range('T');
+ const [synthesizerTokens, durations] = synthesizerSpec.dims.range('T', 'D');
+
+ const minTokens = Math.max(predictorTokens.min, synthesizerTokens.min);
+ const maxTokens = predictorTokens.max;
+ const maxDurationTicks = durations.max;
+
+ const phonemizer = await wrapAsync(createPhonemizer, runtime)(config.phonemizer);
+ allocated.push(phonemizer);
+
+ // Pre-parse the voice matrices into memory
+ const parsedVoices = {} as Record;
+ for (const [key, path] of Object.entries(config.voices) as [K, string][]) {
+ parsedVoices[key] = parseVoice(await RNBlobUtil.fs.readFile(path, 'base64'));
+ }
+
+ const tensors = [
+ tensor('float32', [1, VOICE_REF_HALF_SIZE]),
+ tensor('float32', [1, VOICE_REF_SIZE]),
+ tensor('float32', [1]),
+ ] as const;
+
+ const [tVoiceRefHalf, tVoiceRef, tSpeed] = tensors;
+ allocated.push(...tensors);
+
+ const synthesizeChunkWorklet = (
+ chunkPhonemes: string,
+ chunkOpts: { voice: K; speed: number }
+ ): { audio: Float32Array; sampleRate: number; duration: number } => {
+ 'worklet';
+
+ const phonemes = Array.from(chunkPhonemes.trim());
+
+ const voice = parsedVoices[chunkOpts.voice];
+ if (!voice) {
+ throw RnExecuTorchError(
+ 'INVALID_ARGUMENT',
+ `synthesize: Unknown voice: ${String(chunkOpts.voice)}.`
+ );
+ }
+
+ // 2 tokens are reserved for the leading and trailing padding
+ const numTokens = Math.min(Math.max(phonemes.length + 2, minTokens), maxTokens);
+ const tokens = tokenize(phonemes, numTokens);
+
+ // Exclude all paddings except the leading and the trailing one
+ const textMask = new Uint8Array(numTokens);
+ textMask.fill(1, 0, Math.min(phonemes.length + 2, numTokens));
+
+ // Each input token count corresponds to a different voice reference vector
+ const voiceRows = voice.length / VOICE_REF_SIZE;
+ const voiceRow = Math.min(phonemes.length - 1, numTokens - 1, voiceRows - 1);
+ const voiceOffset = Math.max(0, voiceRow) * VOICE_REF_SIZE;
+
+ tVoiceRef.setData(voice.subarray(voiceOffset, voiceOffset + VOICE_REF_SIZE));
+ tVoiceRefHalf.setData(
+ voice.subarray(voiceOffset + VOICE_REF_HALF_SIZE, voiceOffset + VOICE_REF_SIZE)
+ );
+ tSpeed.setData(new Float32Array([chunkOpts.speed]));
+
+ // Collect dynamic execution tensors for cleanup in a single try/finally block
+ const auxTensors: Tensor[] = [];
+
+ try {
+ // 1. Predict per-token durations
+ const tTokens = tensor('int64', [1, numTokens], tokens);
+ const tTextMask = tensor('bool', [1, numTokens], textMask);
+ const tPredictedDurations = tensor('int64', [numTokens]);
+ const tDurationFeatures = tensor('float32', [1, numTokens, DURATION_FEATURE_DIM]);
+ auxTensors.push(tTokens, tTextMask, tPredictedDurations, tDurationFeatures);
+
+ models.durationPredictor.execute(
+ 'forward',
+ [tTokens, tTextMask, tVoiceRefHalf, tSpeed],
+ [tPredictedDurations, tDurationFeatures]
+ );
+
+ const predicted = tPredictedDurations.getData(new BigInt64Array(numTokens));
+ const tokenDurations = new Int32Array(numTokens);
+ let totalDuration = 0;
+ for (let i = 0; i < numTokens; i++) {
+ tokenDurations[i] = Number(predicted[i]!);
+ totalDuration += tokenDurations[i]!;
+ }
+
+ // 2. Fit the predicted durations into the model's supported range
+ const clampedDuration = Math.min(
+ Math.max(totalDuration, MIN_DURATION_TICKS),
+ maxDurationTicks
+ );
+ if (clampedDuration !== totalDuration) {
+ scaleDurations(tokenDurations, clampedDuration);
+ }
+
+ // Expand each token index over its predicted duration
+ const tokenIndices = new BigInt64Array(numTokens);
+ for (let i = 0; i < numTokens; i++) tokenIndices[i] = BigInt(i);
+ const indices = repeatInterleave(tokenIndices, tokenDurations);
+ if (indices.length === 0) {
+ return { audio: new Float32Array(0), sampleRate: KOKORO_SAMPLE_RATE, duration: 0 };
+ }
+
+ // 3. Synthesize the waveform
+ const tIndices = tensor('int64', [indices.length], indices);
+ const tAudio = tensor('float32', [1, 1, indices.length * TICKS_PER_DURATION]);
+ auxTensors.push(tIndices, tAudio);
+
+ models.synthesizer.execute(
+ 'forward',
+ [tTokens, tTextMask, tIndices, tDurationFeatures, tVoiceRef],
+ [tAudio]
+ );
+
+ // 4. Post-processing: trim the audio down to the spoken phonemes.
+ // The padded tail of the input contributes trailing artifacts, so the
+ // waveform is cut at the effective duration, then at the last spoken
+ // token's timestamp, and finally stripped of the remaining silence.
+ let padIndex = numTokens;
+ for (let i = 1; i < numTokens; i++) {
+ if (tokens[i] === 0n) {
+ padIndex = i;
+ break;
+ }
+ }
+
+ let effectiveDuration = 0;
+ for (let i = 0; i <= padIndex && i < numTokens; i++) {
+ effectiveDuration += tokenDurations[i]!;
+ }
+
+ let audio: Float32Array = tAudio.getData(new Float32Array(tAudio.numel));
+ audio = audio.subarray(0, Math.min(effectiveDuration * TICKS_PER_DURATION, audio.length));
+
+ const lastPhoneme = phonemes[phonemes.length - 1] ?? '';
+
+ if (numTokens > 2) {
+ // Skip the trailing PAD token, as well as any punctuation just before it
+ const lastTokenIndex = LETTER_PATTERN.test(lastPhoneme) ? numTokens - 2 : numTokens - 3;
+
+ let lastTimestamp = 0;
+ for (let i = 0; i <= lastTokenIndex; i++) lastTimestamp += tokenDurations[i]!;
+ audio = audio.subarray(0, Math.min(lastTimestamp * TICKS_PER_DURATION, audio.length));
+ }
+
+ audio = stripAudio(audio, SILENCE_PADDING_MS * SAMPLES_PER_MS);
+
+ // 5. Append a natural pause matching the chunk's ending punctuation
+ const pauseSamples = (KOKORO_PAUSE_MS[lastPhoneme] ?? 0) * SAMPLES_PER_MS;
+ const result = new Float32Array(audio.length + pauseSamples);
+ result.set(audio);
+
+ return {
+ audio: result,
+ sampleRate: KOKORO_SAMPLE_RATE,
+ duration: result.length / KOKORO_SAMPLE_RATE,
+ };
+ } finally {
+ auxTensors.forEach((t) => t.dispose());
+ }
+ };
+
+ const synthesizeChunk = wrapAsync(synthesizeChunkWorklet, runtime);
+ const phonemize = wrapAsync(phonemizer.phonemize, runtime);
+
+ let isSynthesizing = false;
+ const synthesizeStop = (): void => {
+ isSynthesizing = false;
+ };
+
+ async function* synthesize(
+ text: string,
+ options: KokoroTtsOptions
+ ): AsyncGenerator {
+ if (isSynthesizing) {
+ throw RnExecuTorchError('INVALID_STATE', 'synthesize: Synthesis is already in progress.');
+ }
+
+ if (!text || !text.trim()) {
+ throw RnExecuTorchError('INVALID_ARGUMENT', 'synthesize: Input text cannot be empty.');
+ }
+
+ if (!(options.voice in parsedVoices)) {
+ throw RnExecuTorchError(
+ 'INVALID_ARGUMENT',
+ `synthesize: Unknown voice: ${String(options.voice)}.`
+ );
+ }
+
+ const speed = options.speed ?? DEFAULT_SPEED;
+ if (speed < MIN_SPEED || speed > MAX_SPEED) {
+ throw RnExecuTorchError(
+ 'INVALID_ARGUMENT',
+ `synthesize: speed must be between ${MIN_SPEED} and ${MAX_SPEED}.`
+ );
+ }
+
+ const maxChunkLength = Math.min(options.maxChunkLength ?? maxTokens - 2, maxTokens - 2);
+
+ isSynthesizing = true;
+
+ // Phonemize once up front, then partition the phonemes — every chunk is
+ // then guaranteed to fit the models' token limit.
+ const phonemes = options.phonemize === false ? text : await phonemize(text);
+ const chunks = partition(phonemes, maxChunkLength, { prioritizeInitialTtfa: true });
+
+ try {
+ for (const [chunkIndex, chunk] of chunks.entries()) {
+ if (!isSynthesizing) break;
+
+ const audioChunk = await synthesizeChunk(chunk, { voice: options.voice, speed });
+ yield { ...audioChunk, chunkIndex, totalChunks: chunks.length };
+ }
+ } finally {
+ isSynthesizing = false;
+ }
+ }
+
+ return { dispose, synthesize, synthesizeStop };
+ } catch (error) {
+ dispose();
+ throw error;
+ }
+}
diff --git a/packages/react-native-executorch/src/extensions/speech/tasks/supertonicTextToSpeech.ts b/packages/react-native-executorch/src/extensions/speech/tasks/supertonicTextToSpeech.ts
index 92d53ba761..68cacbe917 100644
--- a/packages/react-native-executorch/src/extensions/speech/tasks/supertonicTextToSpeech.ts
+++ b/packages/react-native-executorch/src/extensions/speech/tasks/supertonicTextToSpeech.ts
@@ -61,6 +61,8 @@ function getDefaultMaxChunkLength(lang?: SupertonicLanguage): number {
* @typeParam K Voice style keys record constraint (strictly inferred from voiceStyles keys).
*/
export type SupertonicTtsModel = {
+ /** Discriminates this config from the other Text-to-Speech pipelines. */
+ readonly name: 'supertonic';
/** Local or remote file paths to the 4 Supertonic `.pte` sub-models. */
readonly modelPaths: {
/** Path to the duration predictor `.pte` model. */
diff --git a/packages/react-native-executorch/src/extensions/speech/utils/kokoroUtils.ts b/packages/react-native-executorch/src/extensions/speech/utils/kokoroUtils.ts
new file mode 100644
index 0000000000..36d9896bc1
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/speech/utils/kokoroUtils.ts
@@ -0,0 +1,190 @@
+/**
+ * Number of audio samples generated per single predicted duration tick.
+ * @category Constants
+ */
+export const KOKORO_TICKS_PER_DURATION = 600;
+
+/**
+ * Length of a single Kokoro voice reference vector (one row of a voice file).
+ * @category Constants
+ */
+export const KOKORO_VOICE_REF_SIZE = 256;
+
+const PAD_TOKEN = 0n;
+
+// IPA phoneme -> Kokoro vocabulary token id. Phonemes absent from the map are
+// dropped at tokenization time.
+// prettier-ignore
+const VOCAB: Record = {
+ ';': 1, ':': 2, ',': 3, '.': 4, '!': 5, '?': 6, '—': 9, '…': 10, '"': 11, '(': 12, ')': 13,
+ '“': 14, '”': 15, ' ': 16, '\u0303': 17, 'ʣ': 18, 'ʥ': 19, 'ʦ': 20, 'ʨ': 21, 'ᵝ': 22,
+ '\uab67': 23, 'A': 24, 'I': 25, 'O': 31, 'Q': 33, 'S': 35, 'T': 36, 'W': 39, 'Y': 41, 'ᵊ': 42,
+ 'a': 43, 'b': 44, 'c': 45, 'd': 46, 'e': 47, 'f': 48, 'h': 50, 'i': 51, 'j': 52, 'k': 53,
+ 'l': 54, 'm': 55, 'n': 56, 'o': 57, 'p': 58, 'q': 59, 'r': 60, 's': 61, 't': 62, 'u': 63,
+ 'v': 64, 'w': 65, 'x': 66, 'y': 67, 'z': 68, 'ɑ': 69, 'ɐ': 70, 'ɒ': 71, 'æ': 72, 'β': 75,
+ 'ɔ': 76, 'ɕ': 77, 'ç': 78, 'ɖ': 80, 'ð': 81, 'ʤ': 82, 'ə': 83, 'ɚ': 85, 'ɛ': 86, 'ɜ': 87,
+ 'ɟ': 90, 'ɡ': 92, 'ɥ': 99, 'ɨ': 101, 'ɪ': 102, 'ʝ': 103, 'ɯ': 110, 'ɰ': 111, 'ŋ': 112,
+ 'ɳ': 113, 'ɲ': 114, 'ɴ': 115, 'ø': 116, 'ɸ': 118, 'θ': 119, 'œ': 120, 'ɹ': 123, 'ɾ': 125,
+ 'ɻ': 126, 'ʁ': 128, 'ɽ': 129, 'ʂ': 130, 'ʃ': 131, 'ʈ': 132, 'ʧ': 133, 'ʊ': 135, 'ʋ': 136,
+ 'ʌ': 138, 'ɣ': 139, 'ɤ': 140, 'χ': 142, 'ʎ': 143, 'ʒ': 147, 'ʔ': 148, 'ˈ': 156, 'ˌ': 157,
+ 'ː': 158, 'ʰ': 162, 'ʲ': 164, '↓': 169, '→': 171, '↗': 172, '↘': 173, 'ᵻ': 177
+};
+
+/**
+ * Silence (in milliseconds) appended after a chunk ending with a given phoneme,
+ * so pauses between subsentences sound natural. Phonemes absent from the map
+ * get no pause.
+ * @category Constants
+ */
+// prettier-ignore
+export const KOKORO_PAUSE_MS: Record = {
+ '.': 375, '?': 500, '!': 250, ';': 400, '…': 600, ',': 130, ':': 250, '-': 200,
+ '—': 250, '|': 375, '।': 375, '॥': 500, '¿': 50, '¡': 50,
+ '«': 50, '»': 100,
+};
+
+// Character -> 6-bit value table backing the voice file decoder below.
+const BASE64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+const BASE64_LOOKUP = /* @__PURE__ */ (() => {
+ const lookup = new Int8Array(128).fill(-1);
+ for (let i = 0; i < BASE64_ALPHABET.length; i++) lookup[BASE64_ALPHABET.charCodeAt(i)] = i;
+ return lookup;
+})();
+
+/**
+ * Parses a base64-encoded Kokoro voice file into its raw float rows. Each row
+ * holds a {@link KOKORO_VOICE_REF_SIZE}-long reference vector for one input
+ * token count.
+ * @category Utils
+ * @param base64 The base64 contents of the voice `.bin` file.
+ * @returns The flattened voice matrix, row-major.
+ */
+export function parseVoice(base64: string): Float32Array {
+ 'worklet';
+ /* eslint-disable no-bitwise */
+ const bytes = new Uint8Array((base64.length * 3) >> 2);
+
+ let buffer = 0;
+ let bits = 0;
+ let next = 0;
+ for (let i = 0; i < base64.length; i++) {
+ const code = base64.charCodeAt(i);
+ const value = code < 128 ? BASE64_LOOKUP[code]! : -1;
+ if (value < 0) continue;
+
+ buffer = ((buffer << 6) | value) & 0xffff;
+ bits += 6;
+ if (bits >= 8) {
+ bits -= 8;
+ bytes[next++] = (buffer >> bits) & 0xff;
+ }
+ }
+
+ return new Float32Array(bytes.buffer, 0, next >> 2);
+ /* eslint-enable no-bitwise */
+}
+
+/**
+ * Maps phonemes to vocabulary tokens, padded with the pad token on both ends.
+ * @category Utils
+ * @param phonemes The phoneme sequence, split into code points.
+ * @param totalLength The exact token count to produce, including padding.
+ * @returns Token ids ready to be written into an `int64` tensor.
+ */
+export function tokenize(phonemes: string[], totalLength: number): BigInt64Array {
+ 'worklet';
+ const tokens = new BigInt64Array(totalLength).fill(PAD_TOKEN);
+ const count = Math.min(totalLength - 2, phonemes.length);
+
+ let next = 1;
+ for (let i = 0; i < count; i++) {
+ const token = VOCAB[phonemes[i]!];
+ if (token !== undefined) tokens[next++] = BigInt(token);
+ }
+
+ return tokens;
+}
+
+/**
+ * Scales per-token durations in place so that they sum up exactly to
+ * `targetDuration`, distributing the rounding error by largest remainder.
+ * @category Utils
+ * @param durations The per-token durations to scale in place.
+ * @param targetDuration The exact sum the scaled durations must add up to.
+ */
+export function scaleDurations(durations: Int32Array, targetDuration: number): void {
+ 'worklet';
+ let total = 0;
+ for (const duration of durations) total += duration;
+ if (total === 0) return;
+
+ const scale = targetDuration / total;
+ const shrinking = scale < 1;
+ const remainders: { remainder: number; index: number }[] = [];
+
+ let scaledSum = 0;
+ for (let i = 0; i < durations.length; i++) {
+ const scaled = scale * durations[i]!;
+ const rounded = shrinking ? Math.ceil(scaled) : Math.floor(scaled);
+ durations[i] = rounded;
+ scaledSum += rounded;
+ remainders.push({ remainder: Math.abs(rounded - scaled), index: i });
+ }
+
+ remainders.sort((a, b) => b.remainder - a.remainder);
+ const diff = Math.abs(targetDuration - scaledSum);
+ for (let i = 0; i < diff && i < remainders.length; i++) {
+ const { index } = remainders[i]!;
+ durations[index] = durations[index]! + (shrinking ? -1 : 1);
+ }
+}
+
+// Finds the first (or, when scanning in reverse, the last) sample whose moving
+// average amplitude rises above the silence threshold.
+function findAudioBound(
+ audio: Float32Array,
+ reverse: boolean,
+ steps: number,
+ threshold: number
+): number {
+ 'worklet';
+ const length = audio.length;
+ const normalize = (sample: number) => Math.max(0, Math.abs(sample) - threshold);
+
+ let windowSum = 0;
+ let index = reverse ? length - 1 : 0;
+ for (let processed = 1; processed <= length; processed++) {
+ windowSum += normalize(audio[index]!);
+ if (processed > steps) {
+ windowSum -= normalize(audio[reverse ? index + steps : index - steps]!);
+ }
+ if (processed >= steps && windowSum / steps >= threshold) return index;
+ index += reverse ? -1 : 1;
+ }
+
+ return reverse ? 0 : length - 1;
+}
+
+/**
+ * Strips leading and trailing silence using a sliding-window moving average.
+ * @category Utils
+ * @param audio The audio samples to strip.
+ * @param margin The number of silence samples to preserve at each edge.
+ * @param steps The moving average window length.
+ * @param threshold The amplitude below which audio counts as silence.
+ * @returns A view of `audio` with the silent edges removed.
+ */
+export function stripAudio(
+ audio: Float32Array,
+ margin: number,
+ steps: number = 10,
+ threshold: number = 0.005
+): Float32Array {
+ 'worklet';
+ if (audio.length === 0) return audio;
+
+ const start = Math.max(0, findAudioBound(audio, false, steps, threshold) - margin);
+ const end = Math.min(audio.length - 1, findAudioBound(audio, true, steps, threshold) + margin);
+
+ return end >= start ? audio.subarray(start, end + 1) : audio.subarray(0, 0);
+}
diff --git a/packages/react-native-executorch/src/extensions/speech/utils/phonemizer.ts b/packages/react-native-executorch/src/extensions/speech/utils/phonemizer.ts
new file mode 100644
index 0000000000..e732ad3c89
--- /dev/null
+++ b/packages/react-native-executorch/src/extensions/speech/utils/phonemizer.ts
@@ -0,0 +1,55 @@
+import { rnexecutorchJsi } from '../../../native/bridge';
+import { RnExecuTorchError } from '../../../core/error';
+
+declare const phonemizerBrand: unique symbol;
+
+/**
+ * Union of all (currently) supported languages in our G2P pipeline.
+ */
+export type PhonemizerLanguage = 'en-us' | 'en-gb' | 'fr' | 'es' | 'it' | 'pt' | 'de' | 'pl' | 'hi';
+
+/**
+ * A configuration type compatible with the underlying
+ * Phonemis library interface.
+ */
+export type PhonemizerConfig = {
+ lang: PhonemizerLanguage;
+ taggerSource?: string;
+ lexiconSource?: string;
+ neuralModelSource?: string;
+};
+
+export type Phonemizer = {
+ /**
+ * A standard G2P (grapheme to phoneme) utility.
+ * @param text Input text to be phonemized.
+ */
+ phonemize(text: string): string;
+
+ /** Releases the native phonemizer. The instance must not be used afterwards. */
+ dispose(): void;
+
+ /**
+ * Prevents plain JS objects from being cast as Phonemizers.
+ * @internal
+ */
+ readonly [phonemizerBrand]: never;
+};
+
+/**
+ * Creates a grapheme-to-phoneme pipeline for the configured language.
+ * @category Typescript API
+ * @param config The phonemizer configuration and asset paths.
+ * @returns The native {@link Phonemizer} instance.
+ */
+export function createPhonemizer(config: PhonemizerConfig): Phonemizer {
+ 'worklet';
+ if (!rnexecutorchJsi.speech.createPhonemizer) {
+ throw RnExecuTorchError(
+ 'INVALID_STATE',
+ "createPhonemizer: The native build has no phonemizer. Add the 'textToSpeech' feature (or " +
+ "the 'phonemis' lib) to the app's react-native-executorch config and rebuild."
+ );
+ }
+ return rnexecutorchJsi.speech.createPhonemizer(config) as Phonemizer;
+}
diff --git a/packages/react-native-executorch/src/hooks/useTextToSpeech.ts b/packages/react-native-executorch/src/hooks/useTextToSpeech.ts
index 706ff985b4..2d6bfecc64 100644
--- a/packages/react-native-executorch/src/hooks/useTextToSpeech.ts
+++ b/packages/react-native-executorch/src/hooks/useTextToSpeech.ts
@@ -1,10 +1,62 @@
import { useModel } from './useModel';
import { useResourceDownload, type ResourceOptions } from './useResourceDownload';
+import {
+ createKokoroTextToSpeech,
+ type KokoroTtsModel,
+} from '../extensions/speech/tasks/kokoroTextToSpeech';
import {
createSupertonicTextToSpeech,
type SupertonicTtsModel,
} from '../extensions/speech/tasks/supertonicTextToSpeech';
+type KokoroTts = Awaited<
+ // prettier-ignore
+ ReturnType>
+>;
+type SupertonicTts = Awaited<
+ // prettier-ignore
+ ReturnType>
+>;
+
+export type TtsHookResult<
+ C,
+ P extends {
+ synthesize: (...args: any[]) => any;
+ synthesizeStop: (...args: any[]) => any;
+ },
+> = {
+ /** Whether the pipeline is loaded and ready to synthesize. */
+ isReady: boolean;
+ /** The download or load error, if any. */
+ error: Error | null;
+ /** Download progress across every asset, in percent. */
+ downloadProgress: number;
+ /** The config with every remote URL resolved to a local path. */
+ resource: C | undefined;
+ /** Streams synthesized audio chunks. Undefined until the pipeline is ready. */
+ synthesize: P['synthesize'] | undefined;
+ /** Cancels an in-flight synthesis. Undefined until the pipeline is ready. */
+ synthesizeStop: P['synthesizeStop'] | undefined;
+};
+
+/**
+ * React hook to load and manage the Kokoro Text-to-Speech pipeline.
+ *
+ * It manages downloading (if the sources are remote URLs) and loading the 2 sub-model
+ * `.pte` files, the phonemizer assets and all voice `.bin` files, tracking download
+ * progress and errors, and cleaning up native memory when unmounting.
+ * @category Hooks
+ * @typeParam K Voice keys record constraint.
+ * @param config The Kokoro TTS model configuration.
+ * @param options Load and caching options. See {@link ResourceOptions}.
+ * @returns An object containing the model's loading state, error, download progress,
+ * and synthesis functions.
+ */
+export function useTextToSpeech(
+ config: KokoroTtsModel,
+ options?: ResourceOptions
+): TtsHookResult, KokoroTts>;
+
/**
* React hook to load and manage the Supertonic 3 Text-to-Speech pipeline.
*
@@ -21,9 +73,22 @@ import {
export function useTextToSpeech(
config: SupertonicTtsModel,
options?: ResourceOptions
+): TtsHookResult, SupertonicTts>;
+
+export function useTextToSpeech(
+ config: KokoroTtsModel | SupertonicTtsModel,
+ options?: ResourceOptions
) {
+ // Each config names the pipeline it belongs to, so the factory is resolved
+ // from that tag alone.
+ const create = (
+ config.name === 'kokoro' ? createKokoroTextToSpeech : createSupertonicTextToSpeech
+ ) as (
+ ttsConfig: KokoroTtsModel | SupertonicTtsModel
+ ) => Promise | SupertonicTts>;
+
const { resource, downloadProgress, downloadError } = useResourceDownload(config, options);
- const { model, error } = useModel(createSupertonicTextToSpeech, resource ?? null);
+ const { model, error } = useModel(create, resource ?? null);
return {
isReady: !!model,
diff --git a/packages/react-native-executorch/src/index.ts b/packages/react-native-executorch/src/index.ts
index 0865295145..5d7d87c471 100644
--- a/packages/react-native-executorch/src/index.ts
+++ b/packages/react-native-executorch/src/index.ts
@@ -38,6 +38,7 @@ export * from './extensions/nlp/tasks/privacyFilter';
export * from './extensions/speech/tasks/fsmnVoiceActivityDetection';
export * from './extensions/speech/tasks/whisperSpeechToText';
export * from './extensions/speech/tasks/supertonicTextToSpeech';
+export * from './extensions/speech/tasks/kokoroTextToSpeech';
// Core primitives — for library builders and power users
export * from './core/error';
diff --git a/packages/react-native-executorch/src/models.ts b/packages/react-native-executorch/src/models.ts
index fe9155015e..0eef8c3aeb 100644
--- a/packages/react-native-executorch/src/models.ts
+++ b/packages/react-native-executorch/src/models.ts
@@ -10,6 +10,8 @@ import type { TextEmbedderModel } from './extensions/nlp/tasks/textEmbedding';
import type { PrivacyFilterModel } from './extensions/nlp/tasks/privacyFilter';
import type { FsmnVadModel } from './extensions/speech/tasks/fsmnVoiceActivityDetection';
import type { SupertonicTtsModel } from './extensions/speech/tasks/supertonicTextToSpeech';
+import type { KokoroTtsModel } from './extensions/speech/tasks/kokoroTextToSpeech';
+import type { PhonemizerLanguage } from './extensions/speech/utils/phonemizer';
import {
type WhisperSttModel,
WHISPER_LANGUAGES,
@@ -749,6 +751,7 @@ const SUPERTONIC_DEFAULT_VOICE_STYLES = SUPERTONIC_DEFAULT_VOICE_NAMES.reduce(
);
const SUPERTONIC_3_XNNPACK_FP32: SupertonicTtsModel = {
+ name: 'supertonic',
modelPaths: {
durationPredictor: `${BASE_URL}-supertonic/${NEXT_VERSION_TAG}/xnnpack/duration_predictor_xnnpack_fp32.pte`,
vectorEstimator: `${BASE_URL}-supertonic/${NEXT_VERSION_TAG}/xnnpack/vector_estimator_xnnpack_fp32.pte`,
@@ -760,6 +763,7 @@ const SUPERTONIC_3_XNNPACK_FP32: SupertonicTtsModel
};
const SUPERTONIC_3_MLX_FP32: SupertonicTtsModel = {
+ name: 'supertonic',
modelPaths: {
durationPredictor: `${BASE_URL}-supertonic/${NEXT_VERSION_TAG}/mlx/duration_predictor_mlx_fp32.pte`,
vectorEstimator: `${BASE_URL}-supertonic/${NEXT_VERSION_TAG}/mlx/vector_estimator_mlx_fp32.pte`,
@@ -770,6 +774,97 @@ const SUPERTONIC_3_MLX_FP32: SupertonicTtsModel = {
voiceStyles: SUPERTONIC_DEFAULT_VOICE_STYLES,
};
+const KOKORO_ROOT = `${BASE_URL}-kokoro/${NEXT_VERSION_TAG}`;
+const KOKORO_PHONEMIZER_ROOT = `${KOKORO_ROOT}/phonemizer`;
+
+const kokoroModelPaths = (variant: 'std' | 'pl' | 'de', dir: string) => ({
+ durationPredictor: `${KOKORO_ROOT}/xnnpack/${dir}/duration_predictor_${variant}_xnnpack_fp32.pte`,
+ synthesizer: `${KOKORO_ROOT}/xnnpack/${dir}/synthesizer_${variant}_xnnpack_fp32.pte`,
+});
+
+const KOKORO_STANDARD_PATHS = kokoroModelPaths('std', 'standard');
+const KOKORO_POLISH_PATHS = kokoroModelPaths('pl', 'polish');
+const KOKORO_GERMAN_PATHS = kokoroModelPaths('de', 'german');
+
+const kokoroVoices = (names: readonly N[]) =>
+ names.reduce(
+ (acc, name) => ({ ...acc, [name]: `${KOKORO_ROOT}/voices/${name}.bin` }),
+ {} as Record
+ );
+
+// English relies on a part-of-speech tagger and a pronunciation lexicon; the
+// remaining languages are phonemized by a neural grapheme-to-phoneme model.
+const kokoroEnglishPhonemizer = (lang: 'en-us' | 'en-gb') => ({
+ lang,
+ taggerSource: `${KOKORO_PHONEMIZER_ROOT}/${lang}/tags.json`,
+ lexiconSource: `${KOKORO_PHONEMIZER_ROOT}/${lang}/lexicon.json`,
+ neuralModelSource: `${KOKORO_PHONEMIZER_ROOT}/${lang}/phonemizer_${lang.replace('-', '_')}.pte`,
+});
+
+const kokoroNeuralPhonemizer = >(
+ lang: L
+) => ({
+ lang,
+ neuralModelSource: `${KOKORO_PHONEMIZER_ROOT}/${lang}/phonemizer_${lang}.pte`,
+});
+
+const KOKORO_EN_US_XNNPACK_FP32: KokoroTtsModel<
+ 'af_heart' | 'af_river' | 'af_sarah' | 'am_adam' | 'am_michael' | 'am_santa'
+> = {
+ name: 'kokoro',
+ modelPaths: KOKORO_STANDARD_PATHS,
+ phonemizer: kokoroEnglishPhonemizer('en-us'),
+ voices: kokoroVoices(['af_heart', 'af_river', 'af_sarah', 'am_adam', 'am_michael', 'am_santa']),
+};
+const KOKORO_EN_GB_XNNPACK_FP32: KokoroTtsModel<'bf_emma' | 'bm_daniel'> = {
+ name: 'kokoro',
+ modelPaths: KOKORO_STANDARD_PATHS,
+ phonemizer: kokoroEnglishPhonemizer('en-gb'),
+ voices: kokoroVoices(['bf_emma', 'bm_daniel']),
+};
+const KOKORO_ES_XNNPACK_FP32: KokoroTtsModel<'ef_dora' | 'em_alex'> = {
+ name: 'kokoro',
+ modelPaths: KOKORO_STANDARD_PATHS,
+ phonemizer: kokoroNeuralPhonemizer('es'),
+ voices: kokoroVoices(['ef_dora', 'em_alex']),
+};
+const KOKORO_FR_XNNPACK_FP32: KokoroTtsModel<'ff_siwis'> = {
+ name: 'kokoro',
+ modelPaths: KOKORO_STANDARD_PATHS,
+ phonemizer: kokoroNeuralPhonemizer('fr'),
+ voices: kokoroVoices(['ff_siwis']),
+};
+const KOKORO_IT_XNNPACK_FP32: KokoroTtsModel<'if_sara' | 'im_nicola'> = {
+ name: 'kokoro',
+ modelPaths: KOKORO_STANDARD_PATHS,
+ phonemizer: kokoroNeuralPhonemizer('it'),
+ voices: kokoroVoices(['if_sara', 'im_nicola']),
+};
+const KOKORO_PT_XNNPACK_FP32: KokoroTtsModel<'pf_dora' | 'pm_santa'> = {
+ name: 'kokoro',
+ modelPaths: KOKORO_STANDARD_PATHS,
+ phonemizer: kokoroNeuralPhonemizer('pt'),
+ voices: kokoroVoices(['pf_dora', 'pm_santa']),
+};
+const KOKORO_HI_XNNPACK_FP32: KokoroTtsModel<'hf_alpha' | 'hm_omega' | 'hm_psi'> = {
+ name: 'kokoro',
+ modelPaths: KOKORO_STANDARD_PATHS,
+ phonemizer: kokoroNeuralPhonemizer('hi'),
+ voices: kokoroVoices(['hf_alpha', 'hm_omega', 'hm_psi']),
+};
+const KOKORO_PL_XNNPACK_FP32: KokoroTtsModel<'pm_mateusz'> = {
+ name: 'kokoro',
+ modelPaths: KOKORO_POLISH_PATHS,
+ phonemizer: kokoroNeuralPhonemizer('pl'),
+ voices: kokoroVoices(['pm_mateusz']),
+};
+const KOKORO_DE_XNNPACK_FP32: KokoroTtsModel<'df_anna'> = {
+ name: 'kokoro',
+ modelPaths: KOKORO_GERMAN_PATHS,
+ phonemizer: kokoroNeuralPhonemizer('de'),
+ voices: kokoroVoices(['df_anna']),
+};
+
// =============================================================================
// Privacy Filter
// =============================================================================
@@ -1427,5 +1522,49 @@ export const models = {
XNNPACK_FP32: SUPERTONIC_3_XNNPACK_FP32,
MLX_FP32: SUPERTONIC_3_MLX_FP32,
},
+
+ /**
+ * Kokoro — a lightweight phoneme-driven Text-to-Speech model. Each language
+ * entry bundles the matching model weights, grapheme-to-phoneme assets and
+ * the voices available for that language, nested per backend.
+ */
+ KOKORO: {
+ EN_US: {
+ ...KOKORO_EN_US_XNNPACK_FP32,
+ XNNPACK_FP32: KOKORO_EN_US_XNNPACK_FP32,
+ },
+ EN_GB: {
+ ...KOKORO_EN_GB_XNNPACK_FP32,
+ XNNPACK_FP32: KOKORO_EN_GB_XNNPACK_FP32,
+ },
+ ES: {
+ ...KOKORO_ES_XNNPACK_FP32,
+ XNNPACK_FP32: KOKORO_ES_XNNPACK_FP32,
+ },
+ FR: {
+ ...KOKORO_FR_XNNPACK_FP32,
+ XNNPACK_FP32: KOKORO_FR_XNNPACK_FP32,
+ },
+ IT: {
+ ...KOKORO_IT_XNNPACK_FP32,
+ XNNPACK_FP32: KOKORO_IT_XNNPACK_FP32,
+ },
+ PT: {
+ ...KOKORO_PT_XNNPACK_FP32,
+ XNNPACK_FP32: KOKORO_PT_XNNPACK_FP32,
+ },
+ HI: {
+ ...KOKORO_HI_XNNPACK_FP32,
+ XNNPACK_FP32: KOKORO_HI_XNNPACK_FP32,
+ },
+ PL: {
+ ...KOKORO_PL_XNNPACK_FP32,
+ XNNPACK_FP32: KOKORO_PL_XNNPACK_FP32,
+ },
+ DE: {
+ ...KOKORO_DE_XNNPACK_FP32,
+ XNNPACK_FP32: KOKORO_DE_XNNPACK_FP32,
+ },
+ },
},
};