From 61d286a7e91d1b960933165defe43641cec4e62b Mon Sep 17 00:00:00 2001
From: Dewan Shakil
Date: Tue, 12 May 2026 21:58:05 +0530
Subject: [PATCH 1/6] Add React Native Web support
---
AGENTS.md | 2 +
CHANGELOG.md | 2 +
README.md | 29 +-
docs/getting-started.md | 29 ++
docs/guides/playback.md | 17 +
package-lock.json | 143 +++++++-
package.json | 16 +
src/KittenTTS.web.ts | 519 +++++++++++++++++++++++++++++
src/KittenTTSBundledAssets.web.ts | 160 +++++++++
src/KittenTTSConfig.web.ts | 121 +++++++
src/audio/AudioOutput.ts | 13 +
src/audio/AudioOutput.web.ts | 132 ++++++++
src/engine/TTSEngine.web.ts | 363 ++++++++++++++++++++
src/index.ts | 6 +-
src/index.web.ts | 36 ++
src/loader/ModelDownloader.web.ts | 511 ++++++++++++++++++++++++++++
src/loader/NPZLoader.web.ts | 311 +++++++++++++++++
src/phonemizer/CEPhonemizer.web.ts | 431 ++++++++++++++++++++++++
src/storage/AssetStorage.ts | 138 ++++++++
src/web-globals.d.ts | 21 ++
20 files changed, 2988 insertions(+), 12 deletions(-)
create mode 100644 src/KittenTTS.web.ts
create mode 100644 src/KittenTTSBundledAssets.web.ts
create mode 100644 src/KittenTTSConfig.web.ts
create mode 100644 src/audio/AudioOutput.web.ts
create mode 100644 src/engine/TTSEngine.web.ts
create mode 100644 src/index.web.ts
create mode 100644 src/loader/ModelDownloader.web.ts
create mode 100644 src/loader/NPZLoader.web.ts
create mode 100644 src/phonemizer/CEPhonemizer.web.ts
create mode 100644 src/storage/AssetStorage.ts
create mode 100644 src/web-globals.d.ts
diff --git a/AGENTS.md b/AGENTS.md
index f57a537..add3d2a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -21,6 +21,7 @@ Main flow:
- `src/index.ts`: public exports.
- `src/KittenTTS.ts`: main SDK class and lifecycle.
- `src/KittenTTSConfig.ts`: user config and defaults.
+- `src/*.web.ts`: React Native Web entrypoints and platform-specific browser implementations.
- `src/KittenTTSError.ts`: SDK error codes and helpers.
- `src/KittenModel.ts`: model names, download URLs, sizes, speed priors.
- `src/KittenVoice.ts`: voice enum and display helpers.
@@ -29,6 +30,7 @@ Main flow:
- `src/engine/TTSEngine.ts`: text-to-token-to-ONNX inference.
- `src/phonemizer/CEPhonemizer.ts`: JS/Emscripten phonemizer adapter.
- `src/audio/AudioOutput.ts`: optional playback helpers.
+- `src/storage/AssetStorage.ts`: web/Node asset cache abstraction used by the web platform files.
- `vendor/cephonemizer/`: vendored C++ phonemizer source.
- `scripts/build-cephonemizer.js`: builds generated phonemizer runtime.
- `scripts/patch-onnxruntime-react-native.js`: postinstall ONNX Runtime compatibility patches.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 45fa833..d75fd3b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,8 @@
- Added Swift-parity word timing metadata via `KittenTTSResult.wordTimings`.
- Added `KittenTTS.generateStreaming()` for sentence-by-sentence generation.
- Added `tts.play(result)` so apps can inspect timings before playback.
+- Added React Native Web support through browser-specific ONNX Runtime Web,
+ Cache API asset storage, CE phonemizer, and audio playback implementations.
## 0.8.0
diff --git a/README.md b/README.md
index 2818c6c..94c793b 100644
--- a/README.md
+++ b/README.md
@@ -5,9 +5,9 @@
- On-device text-to-speech for React Native.
+ On-device text-to-speech for React Native and React Native Web.
- Generate speech on iOS and Android without sending text to a cloud TTS API.
+ Generate speech on iOS, Android, and web without sending text to a cloud TTS API.
@@ -21,9 +21,10 @@
> Developer preview. APIs may change between releases.
-> Expo Go will not work. KittenTTS uses native modules
-> (`onnxruntime-react-native` and `react-native-fs`), so Expo apps need a
-> development build or a prebuilt native project.
+> Expo Go will not work for native iOS/Android. KittenTTS uses native modules
+> (`onnxruntime-react-native` and `react-native-fs`) on mobile, so Expo apps
+> need a development build or a prebuilt native project. Web builds use
+> `onnxruntime-web` and browser storage instead.
## See It In Action
@@ -60,6 +61,7 @@ No cloud. No API key. No text leaving the device for speech generation.
| --- | --- | --- |
| React Native iOS | Developer preview | [Getting started](docs/getting-started.md) |
| React Native Android | Developer preview | [Getting started](docs/getting-started.md) |
+| React Native Web | Developer preview | [Getting started](docs/getting-started.md#web) |
| Expo development build | Supported | [Expo setup](docs/getting-started.md#expo-development-build) |
| Expo Go | Not supported | [Why not?](docs/troubleshooting.md#expo-go-fails) |
@@ -109,6 +111,21 @@ const tts = await KittenTTS.create({
await tts.speak('This voice is generated on the device.');
```
+Play audio in a web build:
+
+```tsx
+import {
+ KittenTTS,
+ createBrowserAudioPlayer,
+} from '@kittentts/react-native';
+
+const tts = await KittenTTS.create({
+ player: createBrowserAudioPlayer(),
+});
+
+await tts.speak('This voice is generated in the browser.');
+```
+
[Full getting started guide →](docs/getting-started.md)
---
@@ -153,7 +170,7 @@ If the app opens in Expo Go, stop it and run `npx expo run:ios` or
## Features
-- [On-device TTS inference](docs/getting-started.md) on iOS and Android.
+- [On-device TTS inference](docs/getting-started.md) on iOS, Android, and web.
- [Model download and cache](docs/reference/api.md#cache-methods) with progress callbacks.
- [Bundled offline assets](docs/guides/offline-assets.md) for apps that cannot depend on a first-run download.
- [Expo development builds](docs/getting-started.md#expo-development-build); Expo Go is [not supported](docs/troubleshooting.md#expo-go-fails).
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 84ce3d8..88e9ccd 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -10,6 +10,7 @@ instance, and generate speech.
| React Native | `>= 0.72` |
| iOS | `15.1+` |
| Android | API `24+` |
+| Web | modern browser with WebAssembly support |
| Node.js | `20+` recommended for examples |
Expo Go will not work. KittenTTS depends on native modules:
@@ -18,6 +19,8 @@ Expo Go will not work. KittenTTS depends on native modules:
- `react-native-fs`
Use a bare React Native app, an Expo development build, or a prebuilt Expo app.
+React Native Web builds use `onnxruntime-web` and do not require those native
+modules at runtime.
## Install
@@ -57,6 +60,30 @@ npm install react-native-sound
cd ios && pod install && cd ..
```
+## Web
+
+React Native Web builds resolve the package's browser entrypoint. The web
+runtime uses `onnxruntime-web`, Cache API storage for downloaded model files,
+and the same JavaScript CE phonemizer.
+
+```tsx
+import {
+ KittenTTS,
+ createBrowserAudioPlayer,
+} from '@kittentts/react-native';
+
+const tts = await KittenTTS.create({
+ player: createBrowserAudioPlayer(),
+});
+
+await tts.speak('Hello from KittenTTS on web.');
+await tts.dispose();
+```
+
+The browser path also supports `generate()`, `wordTimings`, `wavData()`, and
+`wavBase64()`. Pass `ortWasmPath` if your app needs to self-host ONNX Runtime
+WASM assets instead of using the SDK defaults.
+
## Generate Audio
Use `generate()` when you want audio data back without playing it immediately.
@@ -117,6 +144,8 @@ await tts.dispose();
The first `KittenTTS.create()` downloads the selected model, `voices.npz`, and
phonemizer files. Later calls reuse the device cache.
+On web, the cache is stored through the browser Cache API when available and
+falls back to memory storage.
Default model cache:
diff --git a/docs/guides/playback.md b/docs/guides/playback.md
index efffa23..da50256 100644
--- a/docs/guides/playback.md
+++ b/docs/guides/playback.md
@@ -52,6 +52,23 @@ const tts = await KittenTTS.create({
await tts.speak('This plays through react-native-sound.');
```
+## Browser Audio
+
+React Native Web builds can use the browser audio helper:
+
+```tsx
+import {
+ KittenTTS,
+ createBrowserAudioPlayer,
+} from '@kittentts/react-native';
+
+const tts = await KittenTTS.create({
+ player: createBrowserAudioPlayer(),
+});
+
+await tts.speak('This plays through an HTML audio element.');
+```
+
## Generate First, Then Play
This is useful when the UI needs metadata from the generated result before
diff --git a/package-lock.json b/package-lock.json
index ad15167..ee5a43c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,22 +1,23 @@
{
"name": "@kittentts/react-native",
- "version": "1.1.0",
+ "version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@kittentts/react-native",
- "version": "1.1.0",
+ "version": "1.2.0",
"hasInstallScript": true,
"license": "Apache-2.0",
- "bin": {
- "kittentts-react-native": "bin/kittentts-react-native.js"
- },
"dependencies": {
"onnxruntime-react-native": "^1.24.3",
+ "onnxruntime-web": "^1.24.3",
"pako": "^2.1.0",
"react-native-fs": "npm:@dr.pogodin/react-native-fs@^2.38.2"
},
+ "bin": {
+ "kittentts-react-native": "bin/kittentts-react-native.js"
+ },
"devDependencies": {
"@types/pako": "^2.0.3",
"@types/react": "^18.2.0",
@@ -2157,6 +2158,70 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@protobufjs/aspromise": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+ "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/base64": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+ "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/codegen": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
+ "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/eventemitter": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
+ "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/fetch": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
+ "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.1",
+ "@protobufjs/inquire": "^1.1.0"
+ }
+ },
+ "node_modules/@protobufjs/float": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/inquire": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz",
+ "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/path": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+ "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/pool": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+ "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/@protobufjs/utf8": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
+ "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/@react-native-community/cli": {
"version": "12.3.7",
"resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-12.3.7.tgz",
@@ -3948,6 +4013,12 @@
"node": ">=8"
}
},
+ "node_modules/flatbuffers": {
+ "version": "25.9.23",
+ "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
+ "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
+ "license": "Apache-2.0"
+ },
"node_modules/flow-enums-runtime": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz",
@@ -4072,6 +4143,12 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
+ "node_modules/guid-typescript": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
+ "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
+ "license": "ISC"
+ },
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -4885,6 +4962,12 @@
"node": ">=6"
}
},
+ "node_modules/long": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+ "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+ "license": "Apache-2.0"
+ },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -5661,6 +5744,26 @@
"react-native": "*"
}
},
+ "node_modules/onnxruntime-web": {
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.26.0.tgz",
+ "integrity": "sha512-LbRr/8zZt2xilI2smrVQGGKINo0U46i8qJp+UXyMBGfqN7KjnH1BiwCwLwyNIVV4i9CKFv7Sf4PwLKWnT8/bEA==",
+ "license": "MIT",
+ "dependencies": {
+ "flatbuffers": "^25.1.24",
+ "guid-typescript": "^1.0.9",
+ "long": "^5.2.3",
+ "onnxruntime-common": "1.26.0",
+ "platform": "^1.3.6",
+ "protobufjs": "^7.2.4"
+ }
+ },
+ "node_modules/onnxruntime-web/node_modules/onnxruntime-common": {
+ "version": "1.26.0",
+ "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.26.0.tgz",
+ "integrity": "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==",
+ "license": "MIT"
+ },
"node_modules/open": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/open/-/open-6.4.0.tgz",
@@ -5908,6 +6011,12 @@
"node": ">=4"
}
},
+ "node_modules/platform": {
+ "version": "1.3.6",
+ "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
+ "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
+ "license": "MIT"
+ },
"node_modules/pretty-format": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz",
@@ -5993,6 +6102,30 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "node_modules/protobufjs": {
+ "version": "7.5.8",
+ "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz",
+ "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@protobufjs/aspromise": "^1.1.2",
+ "@protobufjs/base64": "^1.1.2",
+ "@protobufjs/codegen": "^2.0.5",
+ "@protobufjs/eventemitter": "^1.1.0",
+ "@protobufjs/fetch": "^1.1.0",
+ "@protobufjs/float": "^1.0.2",
+ "@protobufjs/inquire": "^1.1.1",
+ "@protobufjs/path": "^1.1.2",
+ "@protobufjs/pool": "^1.1.0",
+ "@protobufjs/utf8": "^1.1.1",
+ "@types/node": ">=13.7.0",
+ "long": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/queue": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
diff --git a/package.json b/package.json
index e8139d3..e7ff31a 100644
--- a/package.json
+++ b/package.json
@@ -4,9 +4,24 @@
"description": "On-device text-to-speech for React Native, powered by KittenTTS + ONNX Runtime",
"main": "lib/index.js",
"types": "lib/index.d.ts",
+ "browser": {
+ "./lib/index.js": "./lib/index.web.js",
+ "./lib/KittenTTS.js": "./lib/KittenTTS.web.js",
+ "./lib/KittenTTSBundledAssets.js": "./lib/KittenTTSBundledAssets.web.js",
+ "./lib/KittenTTSConfig.js": "./lib/KittenTTSConfig.web.js",
+ "./lib/audio/AudioOutput.js": "./lib/audio/AudioOutput.web.js",
+ "./lib/engine/TTSEngine.js": "./lib/engine/TTSEngine.web.js",
+ "./lib/loader/ModelDownloader.js": "./lib/loader/ModelDownloader.web.js",
+ "./lib/loader/NPZLoader.js": "./lib/loader/NPZLoader.web.js",
+ "./lib/phonemizer/CEPhonemizer.js": "./lib/phonemizer/CEPhonemizer.web.js"
+ },
"exports": {
".": {
"types": "./lib/index.d.ts",
+ "browser": {
+ "types": "./lib/index.web.d.ts",
+ "default": "./lib/index.web.js"
+ },
"react-native": "./lib/index.js",
"require": "./lib/index.js",
"default": "./lib/index.js"
@@ -92,6 +107,7 @@
},
"dependencies": {
"onnxruntime-react-native": "^1.24.3",
+ "onnxruntime-web": "^1.24.3",
"react-native-fs": "npm:@dr.pogodin/react-native-fs@^2.38.2",
"pako": "^2.1.0"
}
diff --git a/src/KittenTTS.web.ts b/src/KittenTTS.web.ts
new file mode 100644
index 0000000..1780868
--- /dev/null
+++ b/src/KittenTTS.web.ts
@@ -0,0 +1,519 @@
+import {
+ KittenTTSConfig,
+ OUTPUT_SAMPLE_RATE,
+ type ResolvedKittenTTSConfig,
+ resolveConfig,
+} from './KittenTTSConfig.web';
+import {
+ KittenTTSError,
+ KittenTTSErrorCode,
+ isKittenTTSError,
+} from './KittenTTSError';
+import { KittenTTSResult } from './KittenTTSResult';
+import { KittenModel, speedPrior } from './KittenModel';
+import { KittenVoice } from './KittenVoice';
+import type { KittenWordTiming } from './KittenWordTiming';
+import { TTSEngine } from './engine/TTSEngine.web';
+import { splitSentences } from './engine/SentenceSplitter';
+import { joinTimestamps } from './engine/TimestampJoiner';
+import { loadNPZ, loadNPZData } from './loader/NPZLoader.web';
+import {
+ clearModelCache as deleteCachedModel,
+ getModelCacheInfo,
+ getProvidedModelCacheInfo,
+ isModelCached as checkModelCached,
+ type ModelCacheInfo,
+ type ModelPaths,
+ type ProgressHandler,
+ resolveModelPaths,
+} from './loader/ModelDownloader.web';
+import {
+ AudioOutput,
+ type AudioPlayer,
+ type AudioPlayOptions,
+} from './audio/AudioOutput.web';
+
+/** Options for {@link KittenTTS.create}. */
+export interface KittenTTSCreateOptions extends KittenTTSConfig {
+ /**
+ * Delete cached model files and download fresh copies before initialising.
+ * Useful after a failed/interrupted first-run setup.
+ */
+ forceRedownload?: boolean;
+
+ /**
+ * Audio player for the `speak()` and `play()` methods.
+ *
+ * Use {@link createBrowserAudioPlayer} in browser apps, or provide your own
+ * implementation for frameworks, workers, or Node.js.
+ *
+ * @example
+ * ```typescript
+ * import { KittenTTS, createBrowserAudioPlayer } from '@kittentts/react-native';
+ *
+ * const tts = await KittenTTS.create({
+ * player: createBrowserAudioPlayer(),
+ * });
+ * await tts.speak('Hello!');
+ * ```
+ */
+ player?: AudioPlayer;
+}
+
+/**
+ * The KittenTTS speech-synthesis engine for React Native Web runtimes.
+ *
+ * Downloads the model on first use, initialises ONNX Runtime inference,
+ * and exposes an async API for generating and playing speech.
+ *
+ * @example
+ * ```typescript
+ * import { KittenTTS, createBrowserAudioPlayer } from '@kittentts/react-native';
+ *
+ * const tts = await KittenTTS.create({
+ * player: createBrowserAudioPlayer(),
+ * });
+ *
+ * // Generate audio
+ * const result = await tts.generate('Hello from KittenTTS!');
+ *
+ * // Play through speakers
+ * await tts.speak('Good morning!');
+ * ```
+ */
+export class KittenTTS {
+ /** The configuration this instance was created with. */
+ readonly config: ResolvedKittenTTSConfig;
+
+ private engine: TTSEngine;
+ private audioOutput: AudioOutput;
+ private disposed = false;
+ private disposePromise: Promise | null = null;
+
+ private constructor(
+ engine: TTSEngine,
+ config: ResolvedKittenTTSConfig,
+ player?: AudioPlayer,
+ ) {
+ this.engine = engine;
+ this.config = config;
+ this.audioOutput = new AudioOutput(player);
+ }
+
+ /**
+ * Create and initialise a KittenTTS instance.
+ *
+ * Downloads all required files if not cached, loads the ONNX model, and
+ * prepares the engine for inference.
+ *
+ * @param options - Configuration and player for this session.
+ * @param onProgress - Optional callback for download progress [0, 1].
+ * @returns A ready-to-use KittenTTS instance.
+ */
+ static async create(
+ options?: KittenTTSCreateOptions,
+ onProgress?: ProgressHandler,
+ ): Promise {
+ const resolved = resolveConfig(options);
+ const hasPhonemizerDownload =
+ typeof resolved.phonemizer.downloadIfNeeded === 'function';
+ const setupProgress = createAggregateProgress(onProgress);
+
+ const phonemizerDownload = hasPhonemizerDownload
+ ? resolved.phonemizer.downloadIfNeeded?.(
+ resolved.storageDirectory,
+ setupProgress,
+ )
+ : Promise.resolve();
+
+ const modelDownload = resolveModelPaths(
+ resolved.model,
+ resolved.storageDirectory,
+ setupProgress,
+ {
+ modelFiles: resolved.modelFiles,
+ force: options?.forceRedownload ?? false,
+ retries: resolved.downloadRetries,
+ baseURL: resolved.modelBaseURL || undefined,
+ storage: resolved.storage,
+ fetch: resolved.fetch,
+ },
+ );
+
+ const [, downloadedPaths] = await Promise.all([
+ phonemizerDownload,
+ modelDownload,
+ ]);
+ setupProgress(1, { stage: 'complete' });
+
+ let paths = downloadedPaths;
+ const repairCache = async (): Promise => {
+ await deleteCachedModel(
+ resolved.model,
+ resolved.storageDirectory,
+ resolved.storage,
+ );
+ return resolveModelPaths(
+ resolved.model,
+ resolved.storageDirectory,
+ setupProgress,
+ {
+ force: true,
+ retries: resolved.downloadRetries,
+ baseURL: resolved.modelBaseURL || undefined,
+ storage: resolved.storage,
+ fetch: resolved.fetch,
+ },
+ );
+ };
+
+ let voices = await loadVoicesWithCacheRepair(paths, repairCache);
+ let engine: TTSEngine;
+ try {
+ engine = await TTSEngine.create(resolveOnnxModelSource(paths), voices, resolved);
+ } catch (error) {
+ if (resolved.modelFiles || !isRepairableModelCacheError(error)) throw error;
+ paths = await repairCache();
+ voices = await loadVoicesFromModelPaths(paths);
+ engine = await TTSEngine.create(resolveOnnxModelSource(paths), voices, resolved);
+ }
+
+ return new KittenTTS(engine, resolved, options?.player);
+ }
+
+ /**
+ * Synthesise speech for the given text.
+ *
+ * @param text - The English text to synthesise. Must not be empty.
+ * @param voice - The voice to use. Defaults to the config's `defaultVoice`.
+ * @param speed - Speed multiplier (0.5--2.0). Defaults to the config's `speed`.
+ * @returns A {@link KittenTTSResult} containing PCM samples and metadata.
+ */
+ async generate(
+ text: string,
+ voice?: KittenVoice,
+ speed?: number,
+ ): Promise {
+ if (this.disposed) throw KittenTTSError.engineNotReady();
+
+ const trimmed = text.trim();
+ if (!trimmed) throw KittenTTSError.emptyInput();
+
+ const selectedVoice = voice ?? this.config.defaultVoice;
+ const selectedSpeed = Math.min(Math.max(speed ?? this.config.speed, 0.5), 2.0);
+
+ const output = await this.engine.generate(
+ trimmed,
+ selectedVoice,
+ selectedSpeed,
+ );
+ const effectiveSpeed = selectedSpeed * speedPrior(this.config.model, selectedVoice);
+ const wordTimings = normalizeWordTimingsToDuration(
+ joinTimestamps(trimmed, output.phonemes, output.durations),
+ output.samples.length / OUTPUT_SAMPLE_RATE,
+ );
+
+ return new KittenTTSResult(
+ output.samples,
+ OUTPUT_SAMPLE_RATE,
+ selectedVoice,
+ effectiveSpeed,
+ trimmed,
+ wordTimings,
+ );
+ }
+
+ /**
+ * Synthesise speech sentence by sentence.
+ *
+ * This is the streaming counterpart to {@link generate}. It yields each
+ * {@link KittenTTSResult} as soon as that sentence is ready, which lets apps
+ * start playback before a long text has fully generated.
+ */
+ async *generateStreaming(
+ text: string,
+ voice?: KittenVoice,
+ speed?: number,
+ ): AsyncGenerator {
+ if (this.disposed) throw KittenTTSError.engineNotReady();
+
+ const trimmed = text.trim();
+ if (!trimmed) throw KittenTTSError.emptyInput();
+
+ const selectedVoice = voice ?? this.config.defaultVoice;
+ const selectedSpeed = Math.min(Math.max(speed ?? this.config.speed, 0.5), 2.0);
+ for (const sentence of splitSentences(trimmed)) {
+ yield await this.generate(sentence, selectedVoice, selectedSpeed);
+ }
+ }
+
+ /**
+ * Synthesise and play speech through the device speakers.
+ *
+ * Requires an {@link AudioPlayer} to be passed via `KittenTTS.create({ player })`.
+ *
+ * @param text - The English text to synthesise.
+ * @param voice - The voice to use.
+ * @param speed - Speed multiplier (0.5--2.0).
+ * @returns The generated {@link KittenTTSResult}.
+ */
+ async speak(
+ text: string,
+ voice?: KittenVoice,
+ speed?: number,
+ ): Promise {
+ const result = await this.generate(text, voice, speed);
+ await this.play(result);
+ return result;
+ }
+
+ /**
+ * Play a previously generated result.
+ *
+ * Use this when an app needs to inspect metadata such as `wordTimings` before
+ * playback starts.
+ */
+ async play(
+ result: KittenTTSResult,
+ options: AudioPlayOptions = {},
+ ): Promise {
+ if (this.disposed) throw KittenTTSError.engineNotReady();
+ await this.audioOutput.play(result.samples, result.sampleRate, options);
+ }
+
+ /** Stop any currently active audio playback. */
+ async stopSpeaking(): Promise {
+ await this.audioOutput.stop();
+ }
+
+ /** Check if the model files are already cached on disk. */
+ static async isModelCached(config?: KittenTTSConfig): Promise {
+ const resolved = resolveConfig(config);
+ if (resolved.modelFiles) {
+ return (await getProvidedModelCacheInfo(
+ resolved.model,
+ resolved.modelFiles,
+ )).isCached;
+ }
+ return checkModelCached(
+ resolved.model,
+ resolved.storageDirectory,
+ resolved.storage,
+ );
+ }
+
+ /** Detailed cache state for first-run UI. */
+ static async getModelCacheInfo(
+ config?: KittenTTSConfig,
+ ): Promise {
+ const resolved = resolveConfig(config);
+ if (resolved.modelFiles) {
+ return getProvidedModelCacheInfo(resolved.model, resolved.modelFiles);
+ }
+ return getModelCacheInfo(
+ resolved.model,
+ resolved.storageDirectory,
+ resolved.storage,
+ );
+ }
+
+ /** Alias for `isModelCached()` with clearer app-facing wording. */
+ static async isModelDownloaded(config?: KittenTTSConfig): Promise {
+ return KittenTTS.isModelCached(config);
+ }
+
+ /** Delete cached files for the selected model. */
+ static async clearModelCache(config?: KittenTTSConfig): Promise {
+ const resolved = resolveConfig(config);
+ if (resolved.modelFiles) return;
+ await deleteCachedModel(
+ resolved.model,
+ resolved.storageDirectory,
+ resolved.storage,
+ );
+ }
+
+ /** Delete and download the selected model again. */
+ static async redownloadModel(
+ config?: KittenTTSConfig,
+ onProgress?: ProgressHandler,
+ ): Promise {
+ const resolved = resolveConfig(config);
+ if (resolved.modelFiles) {
+ await resolveModelPaths(
+ resolved.model,
+ resolved.storageDirectory,
+ onProgress,
+ {
+ modelFiles: resolved.modelFiles,
+ storage: resolved.storage,
+ fetch: resolved.fetch,
+ },
+ );
+ return;
+ }
+ await deleteCachedModel(
+ resolved.model,
+ resolved.storageDirectory,
+ resolved.storage,
+ );
+ await resolveModelPaths(
+ resolved.model,
+ resolved.storageDirectory,
+ onProgress,
+ {
+ force: true,
+ retries: resolved.downloadRetries,
+ baseURL: resolved.modelBaseURL || undefined,
+ storage: resolved.storage,
+ fetch: resolved.fetch,
+ },
+ );
+ }
+
+ /** Download model and phonemizer assets without creating a long-lived engine. */
+ static async predownload(
+ config?: KittenTTSConfig,
+ onProgress?: ProgressHandler,
+ ): Promise {
+ const resolved = resolveConfig(config);
+ const hasPhonemizerDownload =
+ typeof resolved.phonemizer.downloadIfNeeded === 'function';
+ const setupProgress = createAggregateProgress(onProgress);
+
+ const phonemizerDownload = hasPhonemizerDownload
+ ? resolved.phonemizer.downloadIfNeeded?.(
+ resolved.storageDirectory,
+ setupProgress,
+ )
+ : Promise.resolve();
+
+ const modelDownload = resolveModelPaths(
+ resolved.model,
+ resolved.storageDirectory,
+ setupProgress,
+ {
+ modelFiles: resolved.modelFiles,
+ retries: resolved.downloadRetries,
+ baseURL: resolved.modelBaseURL || undefined,
+ storage: resolved.storage,
+ fetch: resolved.fetch,
+ },
+ );
+
+ await Promise.all([phonemizerDownload, modelDownload]);
+ setupProgress(1, { stage: 'complete' });
+ }
+
+ /** @deprecated Use `predownload()`. This method does not keep an engine warm. */
+ static async prewarm(
+ config?: KittenTTSConfig,
+ onProgress?: ProgressHandler,
+ ): Promise {
+ await KittenTTS.predownload(config, onProgress);
+ }
+
+ /** Release the ONNX session and free resources. */
+ async dispose(): Promise {
+ if (this.disposePromise) return this.disposePromise;
+ this.disposed = true;
+ this.disposePromise = (async () => {
+ await this.audioOutput.stop().catch(() => {});
+ await this.engine.dispose();
+ this.config.phonemizer.dispose?.();
+ })();
+ return this.disposePromise;
+ }
+}
+
+function normalizeWordTimingsToDuration(
+ wordTimings: readonly KittenWordTiming[],
+ audioDuration: number,
+): KittenWordTiming[] {
+ if (wordTimings.length === 0 || audioDuration <= 0) return [...wordTimings];
+
+ const lastEndTime = wordTimings[wordTimings.length - 1].endTime;
+ if (lastEndTime <= 0) return [...wordTimings];
+
+ const scale = audioDuration / lastEndTime;
+ return wordTimings.map(timing => ({
+ ...timing,
+ startTime: clampTime(timing.startTime * scale, audioDuration),
+ endTime: clampTime(timing.endTime * scale, audioDuration),
+ }));
+}
+
+function clampTime(value: number, audioDuration: number): number {
+ return Math.max(0, Math.min(audioDuration, value));
+}
+
+function resolveOnnxModelSource(paths: ModelPaths): string | Uint8Array {
+ if (paths.onnxData) return paths.onnxData;
+ if (paths.onnxPath) return paths.onnxPath;
+ throw KittenTTSError.modelFileNotFound('');
+}
+
+async function loadVoicesFromModelPaths(
+ paths: ModelPaths,
+): Promise>> {
+ if (paths.voicesData) return loadNPZData(paths.voicesData);
+ if (paths.voicesPath) return loadNPZ(paths.voicesPath);
+ throw KittenTTSError.voicesFileNotFound('');
+}
+
+async function loadVoicesWithCacheRepair(
+ paths: ModelPaths,
+ repairCache: () => Promise,
+): Promise>> {
+ try {
+ return await loadVoicesFromModelPaths(paths);
+ } catch (error) {
+ if (!isRepairableModelCacheError(error)) throw error;
+ const repairedPaths = await repairCache();
+ return loadVoicesFromModelPaths(repairedPaths);
+ }
+}
+
+function isRepairableModelCacheError(error: unknown): boolean {
+ return (
+ isKittenTTSError(error) &&
+ (error.code === KittenTTSErrorCode.InvalidModelData ||
+ error.code === KittenTTSErrorCode.VoicesFileNotFound ||
+ error.code === KittenTTSErrorCode.ModelFileNotFound ||
+ error.code === KittenTTSErrorCode.InferenceFailed)
+ );
+}
+
+function createAggregateProgress(
+ progressHandler?: ProgressHandler,
+): ProgressHandler {
+ const files = new Map();
+
+ return (progress, info) => {
+ if (info?.asset && info.contentLength && info.contentLength > 0) {
+ files.set(info.asset, {
+ bytesWritten: Math.max(0, Math.min(info.bytesWritten ?? 0, info.contentLength)),
+ contentLength: info.contentLength,
+ });
+ }
+
+ const totalBytes = Array.from(files.values()).reduce(
+ (sum, file) => sum + file.contentLength,
+ 0,
+ );
+ const writtenBytes = Array.from(files.values()).reduce(
+ (sum, file) => sum + file.bytesWritten,
+ 0,
+ );
+
+ if (totalBytes > 0) {
+ progressHandler?.(
+ Math.max(0, Math.min(1, writtenBytes / totalBytes)),
+ info,
+ );
+ return;
+ }
+
+ progressHandler?.(progress, info);
+ };
+}
diff --git a/src/KittenTTSBundledAssets.web.ts b/src/KittenTTSBundledAssets.web.ts
new file mode 100644
index 0000000..f9963a6
--- /dev/null
+++ b/src/KittenTTSBundledAssets.web.ts
@@ -0,0 +1,160 @@
+import { KittenModel } from './KittenModel';
+import { CEPhonemizer } from './phonemizer/CEPhonemizer.web';
+import type { KittenTTSConfig } from './KittenTTSConfig.web';
+import type { KittenPhonemizerProtocol } from './phonemizer/types';
+
+export interface KittenTTSBundledModelFiles {
+ onnx: string;
+ voices: string;
+}
+
+export interface KittenTTSBundledAssetsManifestV1 {
+ version: 1;
+ model: KittenModel | string;
+ files: KittenTTSBundledModelFiles & {
+ phonemizerRules: string;
+ phonemizerList: string;
+ };
+}
+
+export interface KittenTTSBundledAssetsManifestV2 {
+ version: 2;
+ defaultModel: KittenModel | string;
+ models: Record;
+ files: {
+ phonemizerRules: string;
+ phonemizerList: string;
+ };
+}
+
+export type KittenTTSBundledAssetsManifest =
+ | KittenTTSBundledAssetsManifestV1
+ | KittenTTSBundledAssetsManifestV2;
+
+export interface CreateBundledAssetConfigOptions
+ extends Omit {
+ /** Directory or URL prefix containing the files listed in the manifest. */
+ basePath?: string;
+ /** Browser asset directory. Defaults to `kittentts`. */
+ assetRoot?: string;
+ /** Model to load from a multi-model manifest. Defaults to manifest.defaultModel. */
+ model?: KittenModel | string;
+ /** Override the default CEPhonemizer constructed from manifest text. */
+ phonemizer?: KittenPhonemizerProtocol;
+}
+
+/**
+ * Create a KittenTTS web config from the manifest generated by the CLI.
+ *
+ * Browser builds fetch asset bytes from `basePath` when provided, otherwise
+ * from `assetRoot`. The returned config uses in-memory model bytes so bundlers
+ * do not need filesystem access.
+ */
+export async function createBundledAssetConfig(
+ manifest: KittenTTSBundledAssetsManifest,
+ options: CreateBundledAssetConfigOptions = {},
+): Promise {
+ const {
+ basePath,
+ assetRoot = 'kittentts',
+ model: selectedModel,
+ phonemizer,
+ ...rest
+ } = options;
+ const root = stripTrailingSlash(basePath ?? assetRoot);
+ const model = parseModel(selectedModel ?? defaultManifestModel(manifest));
+ const modelFiles = manifestModelFiles(manifest, model);
+
+ const [onnxData, voicesData, rulesText, listText] = await Promise.all([
+ readBundledAssetBinary(root, modelFiles.onnx),
+ readBundledAssetBinary(root, modelFiles.voices),
+ readBundledAssetText(root, manifest.files.phonemizerRules),
+ readBundledAssetText(root, manifest.files.phonemizerList),
+ ]);
+
+ return {
+ ...rest,
+ model,
+ modelFiles: {
+ onnxData,
+ voicesData,
+ },
+ phonemizer: phonemizer ?? new CEPhonemizer({
+ rulesText,
+ listText,
+ }),
+ };
+}
+
+async function readBundledAssetBinary(
+ root: string,
+ filePath: string,
+): Promise {
+ const response = await fetch(joinPath(root, filePath));
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status} loading bundled asset ${filePath}`);
+ }
+ return new Uint8Array(await response.arrayBuffer());
+}
+
+async function readBundledAssetText(
+ root: string,
+ filePath: string,
+): Promise {
+ const response = await fetch(joinPath(root, filePath));
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status} loading bundled asset ${filePath}`);
+ }
+ return response.text();
+}
+
+export function bundledAssetModels(
+ manifest: KittenTTSBundledAssetsManifest,
+): KittenModel[] {
+ if (manifest.version === 1) return [parseModel(manifest.model)];
+ return Object.keys(manifest.models).map(parseModel);
+}
+
+function defaultManifestModel(
+ manifest: KittenTTSBundledAssetsManifest,
+): KittenModel | string {
+ return manifest.version === 1 ? manifest.model : manifest.defaultModel;
+}
+
+function manifestModelFiles(
+ manifest: KittenTTSBundledAssetsManifest,
+ model: KittenModel,
+): KittenTTSBundledModelFiles {
+ if (manifest.version === 1) {
+ if (parseModel(manifest.model) !== model) {
+ throw new Error(`Model ${model} is not present in bundled assets manifest.`);
+ }
+ return manifest.files;
+ }
+
+ const files = manifest.models[model];
+ if (!files) {
+ throw new Error(`Model ${model} is not present in bundled assets manifest.`);
+ }
+ return files;
+}
+
+function parseModel(model: KittenModel | string): KittenModel {
+ if (Object.values(KittenModel).includes(model as KittenModel)) {
+ return model as KittenModel;
+ }
+ throw new Error(`Unknown KittenTTS model in bundled assets manifest: ${model}`);
+}
+
+function joinPath(basePath: string, filePath: string): string {
+ if (!basePath) return filePath;
+ return `${basePath}/${stripLeadingSlash(filePath)}`;
+}
+
+function stripLeadingSlash(filePath: string): string {
+ return filePath.replace(/^\/+/, '');
+}
+
+function stripTrailingSlash(filePath: string): string {
+ return filePath.replace(/\/+$/, '');
+}
diff --git a/src/KittenTTSConfig.web.ts b/src/KittenTTSConfig.web.ts
new file mode 100644
index 0000000..e827b70
--- /dev/null
+++ b/src/KittenTTSConfig.web.ts
@@ -0,0 +1,121 @@
+import { KittenModel } from './KittenModel';
+import { KittenVoice } from './KittenVoice';
+import { CEPhonemizer } from './phonemizer/CEPhonemizer.web';
+import type { KittenPhonemizerProtocol } from './phonemizer/types';
+import type { ModelPaths } from './loader/ModelDownloader.web';
+import { defaultAssetStorage, type AssetStorage } from './storage/AssetStorage';
+
+export type KittenTTSModelFiles = ModelPaths;
+
+export type ResolvedKittenTTSConfig =
+ Required> &
+ Pick;
+
+/**
+ * Configuration for a {@link KittenTTS} session.
+ *
+ * @example
+ * ```typescript
+ * const config: KittenTTSConfig = {
+ * model: KittenModel.Nano,
+ * defaultVoice: KittenVoice.Luna,
+ * speed: 1.1,
+ * };
+ * const tts = await KittenTTS.create(config);
+ * ```
+ */
+export interface KittenTTSConfig {
+ /** The model variant to use. Defaults to {@link KittenModel.Nano}. */
+ model?: KittenModel;
+
+ /** Default voice when `voice` is omitted from generate/speak calls. Defaults to {@link KittenVoice.Bella}. */
+ defaultVoice?: KittenVoice;
+
+ /** Default speed multiplier (0.5--2.0). Defaults to 1.0 (natural speed). */
+ speed?: number;
+
+ /**
+ * Root directory where downloaded SDK assets are cached.
+ * Model files live under `//`.
+ */
+ storageDirectory?: string;
+
+ /**
+ * Override the model file host. The URL must point at a directory containing
+ * the ONNX file and voices.npz for the selected model.
+ */
+ modelBaseURL?: string;
+
+ /**
+ * Local ONNX model and voices.npz paths. When provided, KittenTTS uses these
+ * files directly and skips model downloads/cache lookup.
+ */
+ modelFiles?: KittenTTSModelFiles;
+
+ /** Total download attempts per model file before failing. Defaults to 4. */
+ downloadRetries?: number;
+
+ /** Number of ONNX Runtime intra-op threads. Defaults to 4. */
+ ortNumThreads?: number;
+
+ /** Maximum tokens per inference chunk. Long texts are split to prevent OOM. Defaults to 400. */
+ maxTokensPerChunk?: number;
+
+ /** Trim trailing near-silence from generated chunks. Defaults to true. */
+ trimTrailingSilence?: boolean;
+
+ /** Amplitude threshold used for trailing silence trimming. Defaults to 0.005. */
+ silenceThreshold?: number;
+
+ /** Maximum trailing silence to trim from each chunk, in milliseconds. Defaults to 250. */
+ maxSilenceTrimMs?: number;
+
+ /** Text-to-IPA phonemizer. Defaults to the JS-compiled CEPhonemizer. */
+ phonemizer?: KittenPhonemizerProtocol;
+
+ /** Asset cache implementation. Defaults to Cache API in browsers and filesystem cache in Node. */
+ storage?: AssetStorage;
+
+ /** Fetch implementation. Defaults to globalThis.fetch. */
+ fetch?: typeof fetch;
+
+ /**
+ * Browser ONNX Runtime wasm asset location.
+ *
+ * Defaults to the matching onnxruntime-web CDN asset in browsers. Pass
+ * `false` when your app configures `ort.env.wasm` itself.
+ */
+ ortWasmPath?: string | { wasm?: string | URL; mjs?: string | URL } | false;
+}
+
+/** The fixed output sample rate for all KittenTTS audio (24 kHz). */
+export const OUTPUT_SAMPLE_RATE = 24_000;
+
+function defaultPhonemizer(config?: KittenTTSConfig): KittenPhonemizerProtocol {
+ return new CEPhonemizer({
+ storage: config?.storage ?? defaultAssetStorage(config?.storageDirectory ?? 'KittenTTS'),
+ fetch: config?.fetch ?? globalThis.fetch?.bind(globalThis),
+ });
+}
+
+/** Resolve config with defaults applied. */
+export function resolveConfig(config?: KittenTTSConfig): ResolvedKittenTTSConfig {
+ return {
+ model: config?.model ?? KittenModel.Nano,
+ defaultVoice: config?.defaultVoice ?? KittenVoice.Bella,
+ speed: Math.min(Math.max(config?.speed ?? 1.0, 0.5), 2.0),
+ storageDirectory: config?.storageDirectory ?? 'KittenTTS',
+ modelBaseURL: config?.modelBaseURL ?? '',
+ modelFiles: config?.modelFiles,
+ downloadRetries: Math.max(1, Math.floor(config?.downloadRetries ?? 4)),
+ ortNumThreads: Math.max(1, config?.ortNumThreads ?? 4),
+ maxTokensPerChunk: Math.max(50, config?.maxTokensPerChunk ?? 400),
+ trimTrailingSilence: config?.trimTrailingSilence ?? true,
+ silenceThreshold: Math.max(0, config?.silenceThreshold ?? 0.005),
+ maxSilenceTrimMs: Math.max(0, config?.maxSilenceTrimMs ?? 250),
+ phonemizer: config?.phonemizer ?? defaultPhonemizer(config),
+ storage: config?.storage ?? defaultAssetStorage(config?.storageDirectory ?? 'KittenTTS'),
+ fetch: config?.fetch ?? globalThis.fetch?.bind(globalThis),
+ ortWasmPath: config?.ortWasmPath,
+ };
+}
diff --git a/src/audio/AudioOutput.ts b/src/audio/AudioOutput.ts
index 017e354..ece6eff 100644
--- a/src/audio/AudioOutput.ts
+++ b/src/audio/AudioOutput.ts
@@ -298,3 +298,16 @@ export function createRNSoundPlayer(Sound: RNSoundConstructor): AudioPlayer {
},
};
}
+
+/**
+ * Create a browser audio player for React Native Web builds.
+ *
+ * Native iOS and Android builds should use `createExpoAudioPlayer()` or
+ * `createRNSoundPlayer()`. The actual browser implementation is provided by
+ * the package's web entrypoint.
+ */
+export function createBrowserAudioPlayer(): AudioPlayer {
+ throw KittenTTSError.playbackFailed(
+ 'createBrowserAudioPlayer() is only available in React Native Web builds.',
+ );
+}
diff --git a/src/audio/AudioOutput.web.ts b/src/audio/AudioOutput.web.ts
new file mode 100644
index 0000000..140d693
--- /dev/null
+++ b/src/audio/AudioOutput.web.ts
@@ -0,0 +1,132 @@
+import {
+ KittenTTSError,
+ errorMessage,
+ isKittenTTSError,
+} from '../KittenTTSError';
+import { WAVEncoder } from './WAVEncoder';
+
+export interface AudioPlayOptions {
+ /** Called after the configured player has started playback. */
+ onPlaybackStart?: () => void;
+}
+
+/** Audio player interface that users can provide. */
+export interface AudioPlayer {
+ /** Play generated PCM samples. Resolves when playback finishes. */
+ play(
+ samples: Float32Array,
+ sampleRate: number,
+ options?: AudioPlayOptions,
+ ): Promise;
+ /** Stop current playback. */
+ stop(): Promise;
+}
+
+export class AudioOutput {
+ private player: AudioPlayer | null;
+ private playing = false;
+
+ constructor(player?: AudioPlayer) {
+ this.player = player ?? null;
+ }
+
+ async play(
+ samples: Float32Array,
+ sampleRate: number,
+ options: AudioPlayOptions = {},
+ ): Promise {
+ if (!this.player) {
+ throw KittenTTSError.playbackFailed(
+ 'No audio player configured. Pass an AudioPlayer to KittenTTS.create(), ' +
+ 'or use createBrowserAudioPlayer() in browser apps.',
+ );
+ }
+
+ await this.stop();
+ this.playing = true;
+ try {
+ await this.player.play(samples, sampleRate, options);
+ } catch (error) {
+ if (isKittenTTSError(error)) throw error;
+ throw KittenTTSError.playbackFailed(errorMessage(error), error);
+ } finally {
+ this.playing = false;
+ }
+ }
+
+ async stop(): Promise {
+ if (this.player && this.playing) {
+ try {
+ await this.player.stop();
+ } catch (error) {
+ throw KittenTTSError.playbackFailed(errorMessage(error), error);
+ }
+ }
+ this.playing = false;
+ }
+}
+
+export function createBrowserAudioPlayer(): AudioPlayer {
+ let current: HTMLAudioElement | null = null;
+ let currentUrl: string | null = null;
+
+ const cleanup = () => {
+ if (currentUrl) URL.revokeObjectURL(currentUrl);
+ currentUrl = null;
+ current = null;
+ };
+
+ return {
+ async play(
+ samples: Float32Array,
+ sampleRate: number,
+ options: AudioPlayOptions = {},
+ ): Promise {
+ await this.stop();
+ const wav = WAVEncoder.encode(samples, sampleRate);
+ const blob = new Blob([toArrayBuffer(wav) as any], { type: 'audio/wav' } as any);
+ const url = URL.createObjectURL(blob);
+ const audio = new Audio(url);
+ current = audio;
+ currentUrl = url;
+
+ return new Promise((resolve, reject) => {
+ let started = false;
+ audio.onplaying = () => {
+ if (started) return;
+ started = true;
+ options.onPlaybackStart?.();
+ };
+ audio.onended = () => {
+ cleanup();
+ resolve();
+ };
+ audio.onerror = () => {
+ const error = new Error('Browser audio playback failed.');
+ cleanup();
+ reject(error);
+ };
+ audio.play().catch((error: unknown) => {
+ cleanup();
+ reject(error);
+ });
+ });
+ },
+
+ async stop(): Promise {
+ const audio = current;
+ if (audio) {
+ audio.pause();
+ audio.currentTime = 0;
+ }
+ cleanup();
+ },
+ };
+}
+
+function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
+ return bytes.buffer.slice(
+ bytes.byteOffset,
+ bytes.byteOffset + bytes.byteLength,
+ ) as ArrayBuffer;
+}
diff --git a/src/engine/TTSEngine.web.ts b/src/engine/TTSEngine.web.ts
new file mode 100644
index 0000000..c9aa52b
--- /dev/null
+++ b/src/engine/TTSEngine.web.ts
@@ -0,0 +1,363 @@
+import * as ort from 'onnxruntime-web';
+import {
+ KittenTTSError,
+ errorMessage,
+ isKittenTTSError,
+} from '../KittenTTSError';
+import { KittenVoice } from '../KittenVoice';
+import { speedPrior } from '../KittenModel';
+import { OUTPUT_SAMPLE_RATE, type ResolvedKittenTTSConfig } from '../KittenTTSConfig.web';
+import { preprocess } from './TextPreprocessor';
+import * as TextCleaner from './TextCleaner';
+import type { VoiceEmbeddings } from '../loader/NPZLoader.web';
+
+export interface TTSEngineOutput {
+ /** Raw Float32 PCM samples at 24 kHz. */
+ samples: Float32Array;
+
+ /** Predicted frame count per input token, including wrapper tokens. */
+ durations: number[];
+
+ /** IPA phoneme string returned by the phonemizer. */
+ phonemes: string;
+}
+
+/**
+ * Internal ONNX inference engine.
+ *
+ * Orchestrates: text -> TextPreprocessor -> Phonemizer -> TextCleaner -> ONNX -> Float32 PCM
+ */
+export class TTSEngine {
+ private session: ort.InferenceSession;
+ private voices: VoiceEmbeddings;
+ private config: ResolvedKittenTTSConfig;
+ private waveformOutputName: string | undefined;
+ private durationOutputName: string | undefined;
+ private disposed = false;
+
+ private constructor(
+ session: ort.InferenceSession,
+ voices: VoiceEmbeddings,
+ config: ResolvedKittenTTSConfig,
+ waveformOutputName: string | undefined,
+ durationOutputName: string | undefined,
+ ) {
+ this.session = session;
+ this.voices = voices;
+ this.config = config;
+ this.waveformOutputName = waveformOutputName;
+ this.durationOutputName = durationOutputName;
+ }
+
+ /**
+ * Create a new TTSEngine by loading the ONNX model and voice embeddings.
+ */
+ static async create(
+ model: string | Uint8Array,
+ voices: VoiceEmbeddings,
+ config: ResolvedKittenTTSConfig,
+ ): Promise {
+ try {
+ await configureOnnxRuntime(config);
+ const sessionOptions = {
+ executionProviders: ['wasm'],
+ graphOptimizationLevel: 'all',
+ intraOpNumThreads: config.ortNumThreads,
+ } as const;
+ const session = await ort.InferenceSession.create(
+ model as Uint8Array,
+ sessionOptions,
+ );
+ const outputNames = session.outputNames ?? [];
+ const waveformOutputName = outputNames.includes('waveform')
+ ? 'waveform'
+ : outputNames[0];
+ const durationOutputName = outputNames.includes('duration')
+ ? 'duration'
+ : undefined;
+ return new TTSEngine(
+ session,
+ voices,
+ config,
+ waveformOutputName,
+ durationOutputName,
+ );
+ } catch (error) {
+ throw KittenTTSError.inferenceFailed(
+ `Could not initialise ONNX Runtime: ${errorMessage(error)}`,
+ error,
+ );
+ }
+ }
+
+ /**
+ * Synthesise speech and return PCM samples plus optional timing metadata.
+ */
+ async generate(
+ text: string,
+ voice: KittenVoice,
+ speed: number,
+ ): Promise {
+ if (this.disposed) throw KittenTTSError.engineNotReady();
+
+ const embedding = this.voices[voice];
+ if (!embedding) {
+ throw KittenTTSError.noVoiceEmbedding(voice);
+ }
+
+ const normalised = preprocess(text);
+ if (!normalised) throw KittenTTSError.emptyInput();
+
+ let phonemes: string;
+ try {
+ phonemes = await this.config.phonemizer.phonemize(normalised);
+ } catch (error) {
+ if (isKittenTTSError(error)) throw error;
+ throw KittenTTSError.phonemizerFailed(errorMessage(error), error);
+ }
+
+ try {
+ const tokens = TextCleaner.encode(phonemes);
+ const chunks = this.splitIntoChunks(tokens);
+ const effectiveSpeed = speed * speedPrior(this.config.model, voice);
+ const singleChunk = chunks.length === 1;
+
+ const allChunks: Float32Array[] = [];
+ let durations: number[] = [];
+ for (const chunk of chunks) {
+ const chunkTextLength = Math.max(0, chunk.length - 3);
+ const output = await this.runChunk(
+ chunk,
+ embedding,
+ chunkTextLength,
+ effectiveSpeed,
+ );
+ allChunks.push(output.samples);
+ if (singleChunk) {
+ durations = output.durations;
+ }
+ }
+
+ // Concatenate all chunks
+ const totalLength = allChunks.reduce((sum, c) => sum + c.length, 0);
+ if (totalLength === 0) throw KittenTTSError.emptyOutput();
+
+ const result = new Float32Array(totalLength);
+ let offset = 0;
+ for (const chunk of allChunks) {
+ result.set(chunk, offset);
+ offset += chunk.length;
+ }
+ return { samples: result, durations, phonemes };
+ } catch (error) {
+ if (isKittenTTSError(error)) throw error;
+ throw KittenTTSError.inferenceFailed(errorMessage(error), error);
+ }
+ }
+
+ private async runChunk(
+ tokens: number[],
+ embedding: { rows: number; cols: number; data: Float32Array },
+ phonemeLength: number,
+ speed: number,
+ ): Promise<{ samples: Float32Array; durations: number[] }> {
+ // Get style vector for this text length
+ const rowIdx = Math.min(phonemeLength, embedding.rows - 1);
+ const styleVec = embedding.data.slice(
+ rowIdx * embedding.cols,
+ (rowIdx + 1) * embedding.cols,
+ );
+
+ // Create tensors
+ const inputIds = new ort.Tensor(
+ 'int64',
+ BigInt64Array.from(tokens.map(t => BigInt(t))),
+ [1, tokens.length],
+ );
+ const styleTensor = new ort.Tensor('float32', styleVec, [1, styleVec.length]);
+ const speedTensor = new ort.Tensor('float32', Float32Array.of(speed), [1]);
+
+ const feeds = {
+ input_ids: inputIds,
+ style: styleTensor,
+ speed: speedTensor,
+ };
+ const fetches = this.createOutputFetches();
+ const results = fetches
+ ? await this.session.run(feeds, fetches)
+ : await this.session.run(feeds);
+
+ const outputKey = this.resolveWaveformOutputKey(results);
+ if (!outputKey) throw KittenTTSError.emptyOutput();
+
+ const outputTensor = results[outputKey];
+ const samples = outputTensor.data as Float32Array;
+ if (samples.length === 0) throw KittenTTSError.emptyOutput();
+
+ return {
+ samples: this.trimTrailingSilence(samples),
+ durations: this.readDurations(results, outputKey),
+ };
+ }
+
+ private createOutputFetches(): Record | undefined {
+ const outputNames = [
+ this.waveformOutputName,
+ this.durationOutputName,
+ ].filter((name): name is string => Boolean(name));
+
+ if (outputNames.length === 0) return undefined;
+ return Object.fromEntries(outputNames.map(name => [name, null]));
+ }
+
+ private resolveWaveformOutputKey(
+ results: Awaited>,
+ ): string | undefined {
+ if (this.waveformOutputName && results[this.waveformOutputName]) {
+ return this.waveformOutputName;
+ }
+
+ const keys = Object.keys(results);
+ return (
+ keys.find(key => results[key].data instanceof Float32Array) ??
+ keys.find(key => key !== this.durationOutputName) ??
+ keys[0]
+ );
+ }
+
+ private readDurations(
+ results: Awaited>,
+ waveformOutputKey: string,
+ ): number[] {
+ const durationKey =
+ this.durationOutputName && results[this.durationOutputName]
+ ? this.durationOutputName
+ : Object.keys(results).find(key => {
+ if (key === waveformOutputKey) return false;
+ const data = results[key].data;
+ return (
+ data instanceof BigInt64Array ||
+ data instanceof BigUint64Array ||
+ data instanceof Int32Array ||
+ data instanceof Uint32Array
+ );
+ });
+
+ if (!durationKey) return [];
+
+ const durationTensor = results[durationKey];
+ if (!durationTensor) return [];
+
+ return Array.from(durationTensor.data as ArrayLike, value =>
+ typeof value === 'bigint' ? Number(value) : value,
+ );
+ }
+
+ private trimTrailingSilence(samples: Float32Array): Float32Array {
+ if (!this.config.trimTrailingSilence || samples.length === 0) {
+ return samples;
+ }
+
+ const maxTrimSamples = Math.min(
+ samples.length,
+ Math.round((this.config.maxSilenceTrimMs / 1000) * OUTPUT_SAMPLE_RATE),
+ );
+ const threshold = this.config.silenceThreshold;
+ let trimCount = 0;
+
+ while (
+ trimCount < maxTrimSamples &&
+ Math.abs(samples[samples.length - 1 - trimCount]) <= threshold
+ ) {
+ trimCount += 1;
+ }
+
+ if (trimCount === 0 || trimCount >= samples.length) {
+ return samples;
+ }
+ return samples.slice(0, samples.length - trimCount);
+ }
+
+ private splitIntoChunks(tokens: number[]): number[][] {
+ // Strip the start/end/pad wrapper tokens to get the body
+ const body = tokens.slice(1, tokens.length - 2);
+ const maxBody = this.config.maxTokensPerChunk - 3;
+
+ if (body.length <= maxBody) return [tokens];
+
+ const chunks: number[][] = [];
+ for (let i = 0; i < body.length; i += maxBody) {
+ const slice = body.slice(i, Math.min(i + maxBody, body.length));
+ chunks.push([
+ TextCleaner.START_TOKEN_ID,
+ ...slice,
+ TextCleaner.END_TOKEN_ID,
+ TextCleaner.PAD_TOKEN_ID,
+ ]);
+ }
+ return chunks;
+ }
+
+ /** Release the ONNX session. */
+ async dispose(): Promise {
+ if (this.disposed) return;
+ this.disposed = true;
+ await this.session.release().catch(() => {});
+ }
+}
+
+async function configureOnnxRuntime(config: ResolvedKittenTTSConfig): Promise {
+ if (config.ortWasmPath === false) return;
+ if (ort.env.wasm.wasmBinary || ort.env.wasm.wasmPaths) return;
+
+ if (typeof config.ortWasmPath === 'string') {
+ ort.env.wasm.wasmPaths = normalizeWasmDirectory(config.ortWasmPath);
+ return;
+ }
+
+ if (config.ortWasmPath) {
+ ort.env.wasm.wasmPaths = config.ortWasmPath;
+ return;
+ }
+
+ if (!isBrowserRuntime()) {
+ await configureNodeOnnxRuntime();
+ return;
+ }
+
+ ort.env.wasm.wasmPaths = {
+ wasm: `${defaultOrtWasmBaseURL()}ort-wasm-simd-threaded.wasm`,
+ };
+}
+
+async function configureNodeOnnxRuntime(): Promise {
+ const [{ readFile }, { createRequire }] = await Promise.all([
+ import('node:fs/promises'),
+ import('node:module'),
+ ]);
+ const require = createRequire(__filename);
+ const wasmPath = require.resolve('onnxruntime-web/ort-wasm-simd-threaded.wasm');
+ ort.env.wasm.wasmBinary = await readFile(wasmPath);
+ ort.env.wasm.numThreads = 1;
+}
+
+function defaultOrtWasmBaseURL(): string {
+ const version = ort.env.versions?.web ?? '1.26.0';
+ return `https://cdn.jsdelivr.net/npm/onnxruntime-web@${version}/dist/`;
+}
+
+function normalizeWasmDirectory(path: string): string {
+ return path.endsWith('/') ? path : `${path}/`;
+}
+
+function isBrowserRuntime(): boolean {
+ const scope = globalThis as {
+ window?: unknown;
+ self?: unknown;
+ process?: { versions?: { node?: string } };
+ };
+ return (
+ typeof scope.window !== 'undefined' ||
+ (typeof scope.self !== 'undefined' && typeof scope.process?.versions?.node === 'undefined')
+ );
+}
diff --git a/src/index.ts b/src/index.ts
index c1824fe..4a7b53e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -25,5 +25,9 @@ export type {
export type { KittenPhonemizerProtocol } from './phonemizer/types';
export { CEPhonemizer } from './phonemizer/CEPhonemizer';
export { WAVEncoder } from './audio/WAVEncoder';
-export { createExpoAudioPlayer, createRNSoundPlayer } from './audio/AudioOutput';
+export {
+ createBrowserAudioPlayer,
+ createExpoAudioPlayer,
+ createRNSoundPlayer,
+} from './audio/AudioOutput';
export type { AudioPlayer, AudioPlayOptions } from './audio/AudioOutput';
diff --git a/src/index.web.ts b/src/index.web.ts
new file mode 100644
index 0000000..1805479
--- /dev/null
+++ b/src/index.web.ts
@@ -0,0 +1,36 @@
+export { KittenTTS } from './KittenTTS.web';
+export type { KittenTTSCreateOptions } from './KittenTTS.web';
+export { KittenTTSResult } from './KittenTTSResult';
+export type { KittenWordTiming } from './KittenWordTiming';
+export {
+ KittenTTSError,
+ KittenTTSErrorCode,
+ errorMessage,
+ isKittenTTSError,
+} from './KittenTTSError';
+export { KittenModel, modelDisplayName, approximateDownloadBytes } from './KittenModel';
+export { KittenVoice, ALL_VOICES, voiceDisplayName, isFemaleVoice } from './KittenVoice';
+export { OUTPUT_SAMPLE_RATE } from './KittenTTSConfig.web';
+export type { KittenTTSConfig, KittenTTSModelFiles } from './KittenTTSConfig.web';
+export { bundledAssetModels, createBundledAssetConfig } from './KittenTTSBundledAssets.web';
+export type {
+ CreateBundledAssetConfigOptions,
+ KittenTTSBundledAssetsManifest,
+} from './KittenTTSBundledAssets.web';
+export type {
+ DownloadProgressInfo,
+ ModelCacheInfo,
+ ProgressHandler,
+} from './loader/ModelDownloader.web';
+export type { AssetStorage } from './storage/AssetStorage';
+export {
+ BrowserCacheAssetStorage,
+ MemoryAssetStorage,
+ NodeFileAssetStorage,
+ defaultAssetStorage,
+} from './storage/AssetStorage';
+export type { KittenPhonemizerProtocol } from './phonemizer/types';
+export { CEPhonemizer } from './phonemizer/CEPhonemizer.web';
+export { WAVEncoder } from './audio/WAVEncoder';
+export { createBrowserAudioPlayer } from './audio/AudioOutput.web';
+export type { AudioPlayer, AudioPlayOptions } from './audio/AudioOutput.web';
diff --git a/src/loader/ModelDownloader.web.ts b/src/loader/ModelDownloader.web.ts
new file mode 100644
index 0000000..8197f6b
--- /dev/null
+++ b/src/loader/ModelDownloader.web.ts
@@ -0,0 +1,511 @@
+import {
+ KittenModel,
+ huggingFaceBaseURL,
+ onnxFileName,
+ voicesFileName,
+} from '../KittenModel';
+import {
+ KittenTTSError,
+ errorMessage,
+ isKittenTTSError,
+} from '../KittenTTSError';
+import {
+ type AssetStorage,
+ defaultAssetStorage,
+ isNodeRuntime,
+} from '../storage/AssetStorage';
+
+export type DownloadProgressStage =
+ | 'checking-cache'
+ | 'cached'
+ | 'downloading'
+ | 'retrying'
+ | 'complete';
+
+export type DownloadProgressAsset =
+ | 'model'
+ | 'voices'
+ | 'phonemizer-rules'
+ | 'phonemizer-list';
+
+export interface DownloadProgressInfo {
+ stage: DownloadProgressStage;
+ asset?: DownloadProgressAsset;
+ cached?: boolean;
+ attempt?: number;
+ totalAttempts?: number;
+ bytesWritten?: number;
+ contentLength?: number;
+ message?: string;
+}
+
+export type ProgressHandler = (
+ progress: number,
+ info?: DownloadProgressInfo,
+) => void;
+
+export interface ModelPaths {
+ onnxPath?: string;
+ voicesPath?: string;
+ onnxData?: Uint8Array;
+ voicesData?: Uint8Array;
+}
+
+export interface FileModelPaths {
+ onnxPath: string;
+ voicesPath: string;
+}
+
+export interface ModelCacheInfo extends FileModelPaths {
+ model: KittenModel;
+ directory: string;
+ onnxExists: boolean;
+ voicesExists: boolean;
+ isCached: boolean;
+}
+
+export interface ModelDownloadOptions {
+ force?: boolean;
+ retries?: number;
+ baseURL?: string;
+ storage?: AssetStorage;
+ fetch?: typeof fetch;
+}
+
+export interface ModelResolveOptions extends ModelDownloadOptions {
+ modelFiles?: ModelPaths;
+}
+
+const activeDownloads = new Map>();
+const DEFAULT_DOWNLOAD_RETRIES = 4;
+const RETRY_DELAY_MS = 750;
+
+export async function isModelCached(
+ model: KittenModel,
+ storageDir: string,
+ storage?: AssetStorage,
+): Promise {
+ return (await getModelCacheInfo(model, storageDir, storage)).isCached;
+}
+
+export async function getModelCacheInfo(
+ model: KittenModel,
+ storageDir: string,
+ storage = defaultAssetStorage(storageDir),
+): Promise {
+ const dir = resolveDir(model, storageDir);
+ const onnxPath = `${dir}/${onnxFileName(model)}`;
+ const voicesPath = `${dir}/${voicesFileName(model)}`;
+ const [onnxExists, voicesExists] = await Promise.all([
+ hasStorageKey(storage, onnxPath),
+ hasStorageKey(storage, voicesPath),
+ ]);
+ return {
+ model,
+ directory: dir,
+ onnxPath,
+ voicesPath,
+ onnxExists,
+ voicesExists,
+ isCached: onnxExists && voicesExists,
+ };
+}
+
+export async function getProvidedModelCacheInfo(
+ model: KittenModel,
+ files: ModelPaths,
+): Promise {
+ if (files.onnxData || files.voicesData) {
+ return {
+ model,
+ directory: '',
+ onnxPath: files.onnxPath ?? '',
+ voicesPath: files.voicesPath ?? '',
+ onnxExists: Boolean(files.onnxData || files.onnxPath),
+ voicesExists: Boolean(files.voicesData || files.voicesPath),
+ isCached: Boolean((files.onnxData || files.onnxPath) && (files.voicesData || files.voicesPath)),
+ };
+ }
+
+ const paths = normalizeModelPaths(files);
+ if (!paths.onnxPath) throw KittenTTSError.modelFileNotFound('');
+ if (!paths.voicesPath) throw KittenTTSError.voicesFileNotFound('');
+
+ if (!isNodeRuntime()) {
+ return {
+ model,
+ directory: commonDirectory(paths.onnxPath, paths.voicesPath),
+ onnxPath: paths.onnxPath,
+ voicesPath: paths.voicesPath,
+ onnxExists: true,
+ voicesExists: true,
+ isCached: true,
+ };
+ }
+
+ const [onnxExists, voicesExists] = await Promise.all([
+ nodeFileExists(paths.onnxPath),
+ nodeFileExists(paths.voicesPath),
+ ]);
+ return {
+ model,
+ directory: commonDirectory(paths.onnxPath, paths.voicesPath),
+ onnxPath: paths.onnxPath,
+ voicesPath: paths.voicesPath,
+ onnxExists,
+ voicesExists,
+ isCached: onnxExists && voicesExists,
+ };
+}
+
+export async function resolveModelPaths(
+ model: KittenModel,
+ storageDir: string,
+ progressHandler?: ProgressHandler,
+ options: ModelResolveOptions = {},
+): Promise {
+ if (options.modelFiles) {
+ progressHandler?.(0, { stage: 'checking-cache', cached: false });
+ const info = await getProvidedModelCacheInfo(model, options.modelFiles);
+ if (!info.onnxExists) throw KittenTTSError.modelFileNotFound(info.onnxPath);
+ if (!info.voicesExists) throw KittenTTSError.voicesFileNotFound(info.voicesPath);
+ progressHandler?.(1, { stage: 'cached', cached: true });
+ return normalizeModelPaths(options.modelFiles);
+ }
+
+ return downloadModelIfNeeded(model, storageDir, progressHandler, options);
+}
+
+export async function downloadModelIfNeeded(
+ model: KittenModel,
+ storageDir: string,
+ progressHandler?: ProgressHandler,
+ options: ModelDownloadOptions = {},
+): Promise {
+ const storage = options.storage ?? defaultAssetStorage(storageDir);
+ const retryCount = normalizeRetryCount(options.retries);
+ const baseURL = options.baseURL ?? huggingFaceBaseURL(model);
+ const dir = resolveDir(model, storageDir);
+ const cacheKey = `${model}:${dir}:${baseURL}:${options.force ? 'force' : 'cached'}:${retryCount}`;
+ const activeDownload = activeDownloads.get(cacheKey);
+ if (activeDownload) {
+ const paths = await activeDownload;
+ progressHandler?.(1, { stage: 'complete' });
+ return paths;
+ }
+
+ const download = downloadModelFilesIfNeeded(model, dir, progressHandler, {
+ force: options.force ?? false,
+ retries: retryCount,
+ baseURL,
+ storage,
+ fetch: options.fetch,
+ });
+ activeDownloads.set(cacheKey, download);
+ try {
+ return await download;
+ } finally {
+ activeDownloads.delete(cacheKey);
+ }
+}
+
+export async function clearModelCache(
+ model: KittenModel,
+ storageDir: string,
+ storage = defaultAssetStorage(storageDir),
+): Promise {
+ const dir = resolveDir(model, storageDir);
+ await Promise.all([
+ storage.delete(`${dir}/${onnxFileName(model)}`),
+ storage.delete(`${dir}/${voicesFileName(model)}`),
+ ]);
+}
+
+async function downloadModelFilesIfNeeded(
+ model: KittenModel,
+ dir: string,
+ progressHandler: ProgressHandler | undefined,
+ options: Required> & {
+ storage: AssetStorage;
+ fetch?: typeof fetch;
+ },
+): Promise {
+ const onnxPath = `${dir}/${onnxFileName(model)}`;
+ const voicesPath = `${dir}/${voicesFileName(model)}`;
+
+ if (options.force) {
+ await Promise.all([
+ options.storage.delete(onnxPath),
+ options.storage.delete(voicesPath),
+ ]);
+ }
+
+ progressHandler?.(0, { stage: 'checking-cache', cached: false });
+
+ const [onnxExists, voicesExists] = await Promise.all([
+ hasStorageKey(options.storage, onnxPath),
+ hasStorageKey(options.storage, voicesPath),
+ ]);
+
+ if (onnxExists && voicesExists) {
+ progressHandler?.(1, { stage: 'cached', cached: true });
+ return {
+ onnxPath,
+ voicesPath,
+ onnxData: await requireStorageData(options.storage, onnxPath),
+ voicesData: await requireStorageData(options.storage, voicesPath),
+ };
+ }
+
+ const aggregateProgress = createAggregateProgress(progressHandler);
+ const downloads: Promise[] = [];
+
+ if (!onnxExists) {
+ downloads.push(
+ downloadFile(
+ `${options.baseURL}/${onnxFileName(model)}`,
+ onnxPath,
+ 'model',
+ options.retries,
+ options.storage,
+ options.fetch,
+ aggregateProgress,
+ ),
+ );
+ }
+
+ if (!voicesExists) {
+ downloads.push(
+ downloadFile(
+ `${options.baseURL}/${voicesFileName(model)}`,
+ voicesPath,
+ 'voices',
+ options.retries,
+ options.storage,
+ options.fetch,
+ aggregateProgress,
+ ),
+ );
+ }
+
+ await Promise.all(downloads);
+ progressHandler?.(1, { stage: 'complete', cached: false });
+ return {
+ onnxPath,
+ voicesPath,
+ onnxData: await requireStorageData(options.storage, onnxPath),
+ voicesData: await requireStorageData(options.storage, voicesPath),
+ };
+}
+
+async function downloadFile(
+ fromURL: string,
+ toKey: string,
+ asset: DownloadProgressAsset,
+ retries: number,
+ storage: AssetStorage,
+ fetchImpl: typeof fetch | undefined,
+ progressHandler?: ProgressHandler,
+): Promise {
+ let lastError: unknown;
+
+ for (let attempt = 1; attempt <= retries; attempt += 1) {
+ try {
+ await downloadFileOnce(fromURL, toKey, asset, attempt, retries, storage, fetchImpl, progressHandler);
+ return;
+ } catch (error) {
+ lastError = error;
+ if (attempt === retries) break;
+ progressHandler?.(0, {
+ stage: 'retrying',
+ asset,
+ attempt: attempt + 1,
+ totalAttempts: retries,
+ message: errorMessage(error),
+ });
+ await sleep(RETRY_DELAY_MS * attempt);
+ }
+ }
+
+ if (isKittenTTSError(lastError)) {
+ throw KittenTTSError.downloadFailed(
+ `Failed after ${retries} attempts: ${lastError.message}`,
+ lastError,
+ );
+ }
+
+ throw KittenTTSError.downloadFailed(
+ `Failed after ${retries} attempts: ${errorMessage(lastError)}`,
+ lastError,
+ );
+}
+
+async function downloadFileOnce(
+ fromURL: string,
+ toKey: string,
+ asset: DownloadProgressAsset,
+ attempt: number,
+ totalAttempts: number,
+ storage: AssetStorage,
+ fetchImpl: typeof fetch | undefined,
+ progressHandler?: ProgressHandler,
+): Promise {
+ const runFetch = fetchImpl ?? globalThis.fetch?.bind(globalThis);
+ if (!runFetch) {
+ throw KittenTTSError.downloadFailed('No fetch implementation is available.');
+ }
+
+ progressHandler?.(0, { stage: 'downloading', asset, attempt, totalAttempts });
+
+ try {
+ const response = await runFetch(fromURL);
+ if (!response.ok) {
+ throw KittenTTSError.downloadFailed(`HTTP ${response.status} downloading ${fromURL}`);
+ }
+
+ const contentLength = Number(response.headers.get('content-length') || 0);
+ const data = await readResponseBytes(response, contentLength, (bytesWritten) => {
+ if (contentLength > 0) {
+ progressHandler?.(
+ Math.max(0, Math.min(1, bytesWritten / contentLength)),
+ {
+ stage: 'downloading',
+ asset,
+ attempt,
+ totalAttempts,
+ bytesWritten,
+ contentLength,
+ },
+ );
+ }
+ });
+
+ await storage.set(toKey, data);
+ progressHandler?.(1, { stage: 'complete', asset, attempt, totalAttempts });
+ } catch (error) {
+ await storage.delete(toKey).catch(() => {});
+ if (isKittenTTSError(error)) throw error;
+ throw KittenTTSError.downloadFailed(errorMessage(error), error);
+ }
+}
+
+async function readResponseBytes(
+ response: Response,
+ contentLength: number,
+ onProgress: (bytesWritten: number) => void,
+): Promise {
+ if (!response.body || !response.body.getReader) {
+ const data = new Uint8Array(await response.arrayBuffer());
+ onProgress(data.byteLength);
+ return data;
+ }
+
+ const reader = response.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (!value) continue;
+ chunks.push(value);
+ total += value.byteLength;
+ onProgress(total);
+ }
+
+ const result = new Uint8Array(contentLength > 0 ? contentLength : total);
+ let offset = 0;
+ for (const chunk of chunks) {
+ result.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return result;
+}
+
+function resolveDir(model: KittenModel, storageDir: string): string {
+ const base = storageDir || 'KittenTTS';
+ return `${base}/${model}`;
+}
+
+function normalizeModelPaths(files: ModelPaths): ModelPaths {
+ return {
+ onnxPath: files.onnxPath ? stripFileScheme(files.onnxPath) : undefined,
+ voicesPath: files.voicesPath ? stripFileScheme(files.voicesPath) : undefined,
+ onnxData: files.onnxData,
+ voicesData: files.voicesData,
+ };
+}
+
+function stripFileScheme(filePath: string): string {
+ return filePath.startsWith('file://') ? filePath.slice('file://'.length) : filePath;
+}
+
+function commonDirectory(firstPath: string, secondPath: string): string {
+ const firstDir = dirname(firstPath);
+ return firstDir === dirname(secondPath) ? firstDir : '';
+}
+
+function dirname(filePath: string): string {
+ const index = filePath.lastIndexOf('/');
+ return index > 0 ? filePath.slice(0, index) : '';
+}
+
+async function hasStorageKey(storage: AssetStorage, key: string): Promise {
+ if (storage.has) return storage.has(key);
+ return (await storage.get(key)) !== null;
+}
+
+async function requireStorageData(storage: AssetStorage, key: string): Promise {
+ const data = await storage.get(key);
+ if (!data) throw KittenTTSError.modelFileNotFound(key);
+ return data;
+}
+
+async function nodeFileExists(filePath: string): Promise {
+ const fs = await import('node:fs/promises');
+ return fs.access(stripFileScheme(filePath)).then(() => true, () => false);
+}
+
+function normalizeRetryCount(retries: number | undefined): number {
+ return Math.max(1, Math.floor(retries ?? DEFAULT_DOWNLOAD_RETRIES));
+}
+
+function createAggregateProgress(
+ progressHandler?: ProgressHandler,
+): ProgressHandler {
+ const files = new Map<
+ DownloadProgressAsset,
+ { bytesWritten: number; contentLength: number }
+ >();
+
+ return (progress, info) => {
+ if (info?.asset && info.contentLength && info.contentLength > 0) {
+ files.set(info.asset, {
+ bytesWritten: Math.max(0, Math.min(info.bytesWritten ?? 0, info.contentLength)),
+ contentLength: info.contentLength,
+ });
+ } else if (info?.asset && info.stage === 'complete' && !files.has(info.asset)) {
+ files.set(info.asset, { bytesWritten: 1, contentLength: 1 });
+ }
+
+ const totalBytes = Array.from(files.values()).reduce(
+ (sum, file) => sum + file.contentLength,
+ 0,
+ );
+ const writtenBytes = Array.from(files.values()).reduce(
+ (sum, file) => sum + file.bytesWritten,
+ 0,
+ );
+
+ const aggregateProgress =
+ totalBytes > 0
+ ? Math.max(0, Math.min(1, writtenBytes / totalBytes))
+ : progress;
+
+ progressHandler?.(aggregateProgress, info);
+ };
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/src/loader/NPZLoader.web.ts b/src/loader/NPZLoader.web.ts
new file mode 100644
index 0000000..84ced24
--- /dev/null
+++ b/src/loader/NPZLoader.web.ts
@@ -0,0 +1,311 @@
+import pako from 'pako';
+import { KittenTTSError, errorMessage, isKittenTTSError } from '../KittenTTSError';
+
+/**
+ * A voice embedding loaded from a `.npz` file.
+ *
+ * The rows dimension is indexed by `min(text_length, rows - 1)` following
+ * the KittenTTS Python implementation.
+ */
+export interface VoiceEmbedding {
+ rows: number;
+ cols: number;
+ data: Float32Array;
+}
+
+/** Map of voice name to embedding data. */
+export type VoiceEmbeddings = Record;
+
+/**
+ * Load all float arrays from a `.npz` file on disk.
+ *
+ * Supports ZIP stored (method 0) and DEFLATE-compressed (method 8) entries,
+ * float32 and float16 NPY arrays, little-endian.
+ *
+ * @param filePath - Absolute path to the `.npz` file on device.
+ * @returns Dictionary mapping array names to VoiceEmbedding values.
+ */
+export async function loadNPZ(filePath: string): Promise {
+ try {
+ const data = await readFileBytes(filePath);
+ return loadNPZData(data, filePath);
+ } catch (error) {
+ if (isKittenTTSError(error)) {
+ throw error;
+ }
+ throw KittenTTSError.invalidModelData(
+ `Could not load voice embeddings from ${filePath}: ${errorMessage(error)}`,
+ error,
+ );
+ }
+}
+
+export function loadNPZData(
+ data: Uint8Array,
+ source = 'provided voice data',
+): VoiceEmbeddings {
+ const embeddings = parseZIP(data);
+ if (Object.keys(embeddings).length === 0) {
+ throw KittenTTSError.invalidModelData(
+ `No voice embeddings were found in ${source}`,
+ );
+ }
+ return embeddings;
+}
+
+// ---------------------------------------------------------------------------
+// ZIP parsing
+// ---------------------------------------------------------------------------
+
+function parseZIP(data: Uint8Array): VoiceEmbeddings {
+ const result: VoiceEmbeddings = {};
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
+ let offset = 0;
+
+ while (offset + 30 <= data.length) {
+ // Check local file header signature
+ if (view.getUint32(offset, true) !== 0x04034b50) break;
+
+ const method = view.getUint16(offset + 8, true);
+ let compressedSize = view.getUint32(offset + 18, true);
+ let uncompressedSize = view.getUint32(offset + 22, true);
+ const nameLen = view.getUint16(offset + 26, true);
+ const extraLen = view.getUint16(offset + 28, true);
+
+ const nameStart = offset + 30;
+ const extraStart = nameStart + nameLen;
+ const dataStart = extraStart + extraLen;
+
+ // ZIP64: sizes are 0xFFFFFFFF -> read from ZIP64 extra field (tag 0x0001)
+ if (compressedSize === 0xFFFFFFFF || uncompressedSize === 0xFFFFFFFF) {
+ let exOff = extraStart;
+ while (exOff + 4 <= extraStart + extraLen) {
+ const tag = view.getUint16(exOff, true);
+ const size = view.getUint16(exOff + 2, true);
+ if (tag === 0x0001 && exOff + 4 + size >= exOff + 20) {
+ const uncompressedHigh = view.getUint32(exOff + 8, true);
+ const compressedHigh = view.getUint32(exOff + 16, true);
+ if (uncompressedHigh !== 0 || compressedHigh !== 0) {
+ throw KittenTTSError.invalidModelData(
+ 'ZIP64 voice embedding entries larger than 4 GB are not supported.',
+ );
+ }
+ uncompressedSize = view.getUint32(exOff + 4, true);
+ compressedSize = view.getUint32(exOff + 12, true);
+ break;
+ }
+ exOff += 4 + size;
+ }
+ }
+
+ const dataEnd = dataStart + compressedSize;
+ if (dataEnd > data.length) break;
+
+ const entryName = TEXT_DECODER.decode(data.slice(nameStart, nameStart + nameLen));
+
+ if (entryName.endsWith('.npy')) {
+ const compressed = data.slice(dataStart, dataEnd);
+ let fileData: Uint8Array;
+ if (method === 0) {
+ fileData = compressed;
+ } else if (method === 8) {
+ fileData = pako.inflateRaw(compressed);
+ } else {
+ offset = dataEnd;
+ continue;
+ }
+
+ const arrayName = entryName.slice(0, -4);
+ const embedding = parseNPY(fileData);
+ if (embedding) {
+ result[arrayName] = embedding;
+ }
+ }
+
+ offset = dataEnd;
+ }
+
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// NPY parsing
+// ---------------------------------------------------------------------------
+
+function parseNPY(data: Uint8Array): VoiceEmbedding | null {
+ if (data.length < 10) return null;
+
+ // Magic bytes: 0x93 NUMPY
+ if (
+ data[0] !== 0x93 ||
+ data[1] !== 0x4e ||
+ data[2] !== 0x55 ||
+ data[3] !== 0x4d ||
+ data[4] !== 0x50 ||
+ data[5] !== 0x59
+ ) {
+ return null;
+ }
+
+ const major = data[6];
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
+
+ let headerLen: number;
+ let headerBase: number;
+ if (major >= 2) {
+ if (data.length < 12) return null;
+ headerLen = view.getUint32(8, true);
+ headerBase = 12;
+ } else {
+ headerLen = view.getUint16(8, true);
+ headerBase = 10;
+ }
+
+ const dataStartOffset = headerBase + headerLen;
+ if (dataStartOffset > data.length) return null;
+
+ const header = TEXT_DECODER.decode(data.slice(headerBase, headerBase + headerLen));
+ const shape = parseShape(header);
+ if (!shape || shape.length < 1) return null;
+
+ const rawData = data.slice(dataStartOffset);
+
+ if (header.includes("'f4'") || header.includes('f4')) {
+ return makeFloat32Embedding(rawData, shape, header.includes('>f4'));
+ }
+ if (header.includes("'f2'") || header.includes('f2')) {
+ return makeFloat16Embedding(rawData, shape, header.includes('>f2'));
+ }
+
+ return null;
+}
+
+function parseShape(header: string): number[] | null {
+ const openIdx = header.indexOf('(');
+ const closeIdx = header.indexOf(')', openIdx);
+ if (openIdx === -1 || closeIdx === -1) return null;
+
+ const inside = header.substring(openIdx + 1, closeIdx).trim();
+ if (!inside) return [1];
+
+ const parts = inside
+ .split(',')
+ .map((s) => s.trim())
+ .filter((s) => s.length > 0)
+ .map(Number);
+
+ if (parts.some(isNaN)) return null;
+ return parts;
+}
+
+function makeFloat32Embedding(
+ rawData: Uint8Array,
+ shape: number[],
+ bigEndian: boolean,
+): VoiceEmbedding {
+ const count = Math.floor(rawData.length / 4);
+ const floats = new Float32Array(count);
+ const view = new DataView(rawData.buffer, rawData.byteOffset, rawData.byteLength);
+
+ for (let i = 0; i < count; i++) {
+ floats[i] = view.getFloat32(i * 4, !bigEndian);
+ }
+
+ const rows = shape.length >= 2 ? shape[0] : 1;
+ const cols = shape.length >= 2 ? shape[1] : shape[0];
+ return { rows, cols, data: floats };
+}
+
+function makeFloat16Embedding(
+ rawData: Uint8Array,
+ shape: number[],
+ bigEndian: boolean,
+): VoiceEmbedding {
+ const count = Math.floor(rawData.length / 2);
+ const floats = new Float32Array(count);
+ const view = new DataView(rawData.buffer, rawData.byteOffset, rawData.byteLength);
+
+ for (let i = 0; i < count; i++) {
+ const bits = view.getUint16(i * 2, !bigEndian);
+ floats[i] = float16ToFloat(bits);
+ }
+
+ const rows = shape.length >= 2 ? shape[0] : 1;
+ const cols = shape.length >= 2 ? shape[1] : shape[0];
+ return { rows, cols, data: floats };
+}
+
+// ---------------------------------------------------------------------------
+// Float16 conversion
+// ---------------------------------------------------------------------------
+
+const FLOAT32_BUFFER = new ArrayBuffer(4);
+const FLOAT32_VIEW = new DataView(FLOAT32_BUFFER);
+
+function float16ToFloat(bits: number): number {
+ const sign = (bits >>> 15) << 31;
+ const exp16 = (bits >>> 10) & 0x1f;
+ const mant16 = bits & 0x3ff;
+
+ if (exp16 === 0) {
+ if (mant16 === 0) {
+ // Signed zero
+ return bitsToFloat32(sign >>> 0);
+ }
+ // Subnormal
+ let m = mant16;
+ let e = -14;
+ while ((m & 0x400) === 0) {
+ m <<= 1;
+ e -= 1;
+ }
+ m &= 0x3ff;
+ const exp32 = ((e + 127) << 23) >>> 0;
+ const bits32 = (sign | exp32 | (m << 13)) >>> 0;
+ return bitsToFloat32(bits32);
+ }
+ if (exp16 === 31) {
+ // Inf or NaN
+ const bits32 = (sign | 0x7f800000 | (mant16 << 13)) >>> 0;
+ return bitsToFloat32(bits32);
+ }
+
+ const exp32 = ((exp16 - 15 + 127) << 23) >>> 0;
+ const bits32 = (sign | exp32 | (mant16 << 13)) >>> 0;
+ return bitsToFloat32(bits32);
+}
+
+function bitsToFloat32(bits: number): number {
+ FLOAT32_VIEW.setUint32(0, bits, false);
+ return FLOAT32_VIEW.getFloat32(0, false);
+}
+
+// ---------------------------------------------------------------------------
+// TextDecoder polyfill for Hermes
+// ---------------------------------------------------------------------------
+
+const TEXT_DECODER: { decode(input: Uint8Array): string } =
+ typeof TextDecoder !== 'undefined'
+ ? new TextDecoder()
+ : {
+ decode(input: Uint8Array): string {
+ let result = '';
+ for (let i = 0; i < input.length; i++) {
+ result += String.fromCharCode(input[i]);
+ }
+ return result;
+ },
+ };
+
+async function readFileBytes(filePath: string): Promise {
+ if (typeof process === 'undefined' || !process.versions?.node) {
+ throw KittenTTSError.voicesFileNotFound(filePath);
+ }
+ const fs = await import('node:fs/promises');
+ const data = await fs.readFile(stripFileScheme(filePath));
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
+}
+
+function stripFileScheme(filePath: string): string {
+ return filePath.startsWith('file://') ? filePath.slice('file://'.length) : filePath;
+}
diff --git a/src/phonemizer/CEPhonemizer.web.ts b/src/phonemizer/CEPhonemizer.web.ts
new file mode 100644
index 0000000..a05b80c
--- /dev/null
+++ b/src/phonemizer/CEPhonemizer.web.ts
@@ -0,0 +1,431 @@
+import createCEPhonemizerModule from './generated/cephonemizer';
+import type { CEPhonemizerModule } from './generated/cephonemizer';
+import type { KittenPhonemizerProtocol } from './types';
+import {
+ KittenTTSError,
+ errorMessage,
+ isKittenTTSError,
+} from '../KittenTTSError';
+import type {
+ DownloadProgressAsset,
+ ProgressHandler,
+} from '../loader/ModelDownloader.web';
+import {
+ type AssetStorage,
+ defaultAssetStorage,
+ isNodeRuntime,
+} from '../storage/AssetStorage';
+
+const DEFAULT_RULES_URL =
+ 'https://raw.githubusercontent.com/espeak-ng/espeak-ng/59eb19938f12e30881c81d86ce4a7de25414c9f4/dictsource/en_rules';
+
+const DEFAULT_LIST_URL =
+ 'https://raw.githubusercontent.com/espeak-ng/espeak-ng/59eb19938f12e30881c81d86ce4a7de25414c9f4/dictsource/en_list';
+
+const VIRTUAL_RULES_PATH = '/cephonemizer/en_rules';
+const VIRTUAL_LIST_PATH = '/cephonemizer/en_list';
+const DEFAULT_DOWNLOAD_RETRIES = 4;
+const RETRY_DELAY_MS = 750;
+
+export interface CEPhonemizerOptions {
+ /** Override the English pronunciation rules URL. Useful for tests or mirrors. */
+ rulesURL?: string;
+ /** Override the English dictionary list URL. Useful for tests or mirrors. */
+ listURL?: string;
+ /** Local English pronunciation rules file. Node.js only. Skips the rules download. */
+ rulesPath?: string;
+ /** Local English dictionary list file. Node.js only. Skips the list download. */
+ listPath?: string;
+ /** English pronunciation rules text. Skips the rules download and file read. */
+ rulesText?: string;
+ /** English dictionary list text. Skips the list download and file read. */
+ listText?: string;
+ /** Dialect passed through to the C++ engine, for example `en-us`. */
+ dialect?: string;
+ /** Asset cache implementation. */
+ storage?: AssetStorage;
+ /** Fetch implementation. Defaults to globalThis.fetch. */
+ fetch?: typeof fetch;
+}
+
+type CreateHandle = (rulesPath: string, listPath: string, dialect: string) => number;
+type DestroyHandle = (handle: number) => void;
+type PhonemizeHandle = (handle: number, text: string) => number;
+type FreeString = (ptr: number) => void;
+type PhonemizerAsset = Extract<
+ DownloadProgressAsset,
+ 'phonemizer-rules' | 'phonemizer-list'
+>;
+
+/**
+ * Web/Node adapter for the original KittenTTS CEPhonemizer C++ engine.
+ *
+ * The C++ source is compiled to a JS-only Emscripten module, so browser and
+ * backend runtimes can use the same phonemizer logic without platform-native
+ * bindings.
+ */
+export class CEPhonemizer implements KittenPhonemizerProtocol {
+ static readonly defaultRulesURL = DEFAULT_RULES_URL;
+ static readonly defaultListURL = DEFAULT_LIST_URL;
+
+ private readonly rulesURL: string;
+ private readonly listURL: string;
+ private readonly rulesPath?: string;
+ private readonly listPath?: string;
+ private readonly rulesText?: string;
+ private readonly listText?: string;
+ private readonly dialect: string;
+ private readonly storage?: AssetStorage;
+ private readonly fetch?: typeof fetch;
+
+ private module: CEPhonemizerModule | null = null;
+ private handle = 0;
+ private createHandle: CreateHandle | null = null;
+ private destroyHandle: DestroyHandle | null = null;
+ private phonemizeHandle: PhonemizeHandle | null = null;
+ private freeString: FreeString | null = null;
+
+ constructor(options: CEPhonemizerOptions = {}) {
+ this.rulesURL = options.rulesURL ?? DEFAULT_RULES_URL;
+ this.listURL = options.listURL ?? DEFAULT_LIST_URL;
+ this.rulesPath = options.rulesPath;
+ this.listPath = options.listPath;
+ this.rulesText = options.rulesText;
+ this.listText = options.listText;
+ this.dialect = options.dialect ?? 'en-us';
+ this.storage = options.storage;
+ this.fetch = options.fetch;
+ }
+
+ async downloadIfNeeded(
+ storageDirectory: string,
+ progressHandler?: ProgressHandler,
+ ): Promise {
+ if (this.hasBundledText() || this.hasBundledPaths()) {
+ await this.loadBundled(progressHandler);
+ return;
+ }
+
+ this.assertNoPartialBundledData();
+
+ const base = storageDirectory || 'KittenTTS';
+ const rulesKey = `${base}/CEPhonemizer/en_rules`;
+ const listKey = `${base}/CEPhonemizer/en_list`;
+ const storage = this.storage ?? defaultAssetStorage(storageDirectory);
+
+ try {
+ const [rulesCached, listCached] = await Promise.all([
+ hasStorageKey(storage, rulesKey),
+ hasStorageKey(storage, listKey),
+ ]);
+
+ if (rulesCached && listCached) {
+ progressHandler?.(1, { stage: 'cached', cached: true });
+ } else {
+ progressHandler?.(0, { stage: 'checking-cache', cached: false });
+ }
+
+ const aggregateProgress = createAggregateProgress(progressHandler);
+ const downloads: Promise[] = [];
+ if (!rulesCached) {
+ downloads.push(
+ downloadTextFile(this.rulesURL, rulesKey, 'phonemizer-rules', storage, this.fetch, aggregateProgress),
+ );
+ }
+ if (!listCached) {
+ downloads.push(
+ downloadTextFile(this.listURL, listKey, 'phonemizer-list', storage, this.fetch, aggregateProgress),
+ );
+ }
+
+ await Promise.all(downloads);
+
+ const [rulesData, listData] = await Promise.all([
+ requireStorageData(storage, rulesKey),
+ requireStorageData(storage, listKey),
+ ]);
+
+ await this.load(TEXT_DECODER.decode(rulesData), TEXT_DECODER.decode(listData));
+ progressHandler?.(1, { stage: 'complete', cached: rulesCached && listCached });
+ } catch (error) {
+ if (isKittenTTSError(error)) throw error;
+ throw KittenTTSError.phonemizerFailed(errorMessage(error), error);
+ }
+ }
+
+ async phonemize(text: string): Promise {
+ if (!this.module || !this.handle || !this.phonemizeHandle || !this.freeString) {
+ throw KittenTTSError.phonemizerFailed(
+ 'CEPhonemizer data is not ready. Call downloadIfNeeded() before phonemize().',
+ );
+ }
+
+ const resultPtr = this.phonemizeHandle(this.handle, text);
+ if (!resultPtr) {
+ throw KittenTTSError.phonemizerFailed('CEPhonemizer failed to phonemize text.');
+ }
+
+ try {
+ return this.module.UTF8ToString(resultPtr);
+ } finally {
+ this.freeString(resultPtr);
+ }
+ }
+
+ dispose(): void {
+ if (this.handle && this.destroyHandle) {
+ this.destroyHandle(this.handle);
+ }
+ this.handle = 0;
+ this.module = null;
+ this.createHandle = null;
+ this.destroyHandle = null;
+ this.phonemizeHandle = null;
+ this.freeString = null;
+ }
+
+ private async load(rules: string, list: string): Promise {
+ this.dispose();
+
+ const module = await createCEPhonemizerModule();
+ ensureDir(module, '/cephonemizer');
+
+ module.FS.writeFile(VIRTUAL_RULES_PATH, rules);
+ module.FS.writeFile(VIRTUAL_LIST_PATH, list);
+
+ const createHandle = module.cwrap(
+ 'phonemizer_create',
+ 'number',
+ ['string', 'string', 'string'],
+ ) as CreateHandle;
+ const destroyHandle = module.cwrap('phonemizer_destroy', null, ['number']) as DestroyHandle;
+ const phonemizeHandle = module.cwrap(
+ 'phonemizer_phonemize',
+ 'number',
+ ['number', 'string'],
+ ) as PhonemizeHandle;
+ const freeString = module.cwrap('phonemizer_free_string', null, ['number']) as FreeString;
+
+ const handle = createHandle(VIRTUAL_RULES_PATH, VIRTUAL_LIST_PATH, this.dialect);
+ if (!handle) {
+ throw KittenTTSError.phonemizerFailed('CEPhonemizer failed to load en_rules/en_list.');
+ }
+
+ this.module = module;
+ this.handle = handle;
+ this.createHandle = createHandle;
+ this.destroyHandle = destroyHandle;
+ this.phonemizeHandle = phonemizeHandle;
+ this.freeString = freeString;
+ }
+
+ private hasBundledText(): boolean {
+ return this.rulesText !== undefined || this.listText !== undefined;
+ }
+
+ private hasBundledPaths(): boolean {
+ return this.rulesPath !== undefined || this.listPath !== undefined;
+ }
+
+ private assertNoPartialBundledData(): void {
+ if (this.rulesText !== undefined || this.listText !== undefined) {
+ if (this.rulesText === undefined || this.listText === undefined) {
+ throw KittenTTSError.phonemizerFailed(
+ 'Both rulesText and listText must be provided for bundled CEPhonemizer data.',
+ );
+ }
+ }
+
+ if (this.rulesPath !== undefined || this.listPath !== undefined) {
+ if (this.rulesPath === undefined || this.listPath === undefined) {
+ throw KittenTTSError.phonemizerFailed(
+ 'Both rulesPath and listPath must be provided for bundled CEPhonemizer data.',
+ );
+ }
+ }
+ }
+
+ private async loadBundled(progressHandler?: ProgressHandler): Promise {
+ this.assertNoPartialBundledData();
+
+ try {
+ progressHandler?.(0, { stage: 'checking-cache', cached: false });
+
+ if (this.rulesText !== undefined && this.listText !== undefined) {
+ await this.load(this.rulesText, this.listText);
+ progressHandler?.(1, { stage: 'complete', cached: true });
+ return;
+ }
+
+ if (!this.rulesPath || !this.listPath) {
+ throw KittenTTSError.phonemizerFailed(
+ 'Bundled CEPhonemizer data must provide text or Node.js file paths.',
+ );
+ }
+ if (!isNodeRuntime()) {
+ throw KittenTTSError.phonemizerFailed(
+ 'rulesPath/listPath are only supported in Node.js. Use rulesText/listText in browsers.',
+ );
+ }
+
+ const [rules, list] = await Promise.all([
+ readNodeTextFile(this.rulesPath),
+ readNodeTextFile(this.listPath),
+ ]);
+ await this.load(rules, list);
+ progressHandler?.(1, { stage: 'complete', cached: true });
+ } catch (error) {
+ if (isKittenTTSError(error)) throw error;
+ throw KittenTTSError.phonemizerFailed(errorMessage(error), error);
+ }
+ }
+}
+
+async function downloadTextFile(
+ fromUrl: string,
+ toKey: string,
+ asset: PhonemizerAsset,
+ storage: AssetStorage,
+ fetchImpl: typeof fetch | undefined,
+ progressHandler?: ProgressHandler,
+): Promise {
+ let lastError: unknown;
+
+ for (let attempt = 1; attempt <= DEFAULT_DOWNLOAD_RETRIES; attempt += 1) {
+ try {
+ await downloadTextFileOnce(
+ fromUrl,
+ toKey,
+ asset,
+ attempt,
+ DEFAULT_DOWNLOAD_RETRIES,
+ storage,
+ fetchImpl,
+ progressHandler,
+ );
+ return;
+ } catch (error) {
+ lastError = error;
+ if (attempt === DEFAULT_DOWNLOAD_RETRIES) break;
+ progressHandler?.(0, {
+ stage: 'retrying',
+ asset,
+ attempt: attempt + 1,
+ totalAttempts: DEFAULT_DOWNLOAD_RETRIES,
+ message: errorMessage(error),
+ });
+ await sleep(RETRY_DELAY_MS * attempt);
+ }
+ }
+
+ throw KittenTTSError.phonemizerFailed(
+ `Failed after ${DEFAULT_DOWNLOAD_RETRIES} attempts: ${errorMessage(lastError)}`,
+ lastError,
+ );
+}
+
+async function downloadTextFileOnce(
+ fromUrl: string,
+ toKey: string,
+ asset: PhonemizerAsset,
+ attempt: number,
+ totalAttempts: number,
+ storage: AssetStorage,
+ fetchImpl: typeof fetch | undefined,
+ progressHandler?: ProgressHandler,
+): Promise {
+ const runFetch = fetchImpl ?? globalThis.fetch?.bind(globalThis);
+ if (!runFetch) {
+ throw KittenTTSError.phonemizerFailed('No fetch implementation is available.');
+ }
+
+ progressHandler?.(0, { stage: 'downloading', asset, attempt, totalAttempts });
+
+ const response = await runFetch(fromUrl);
+ if (!response.ok) {
+ throw KittenTTSError.phonemizerFailed(`HTTP ${response.status} downloading ${fromUrl}`);
+ }
+
+ const contentLength = Number(response.headers.get('content-length') || 0);
+ const data = new Uint8Array(await response.arrayBuffer());
+ progressHandler?.(1, {
+ stage: 'downloading',
+ asset,
+ attempt,
+ totalAttempts,
+ bytesWritten: data.byteLength,
+ contentLength: contentLength || data.byteLength,
+ });
+ await storage.set(toKey, data);
+ progressHandler?.(1, { stage: 'complete', asset, attempt, totalAttempts });
+}
+
+async function hasStorageKey(storage: AssetStorage, key: string): Promise {
+ if (storage.has) return storage.has(key);
+ return (await storage.get(key)) !== null;
+}
+
+async function requireStorageData(storage: AssetStorage, key: string): Promise {
+ const data = await storage.get(key);
+ if (!data) throw KittenTTSError.phonemizerFailed(`Cached phonemizer file not found: ${key}`);
+ return data;
+}
+
+async function readNodeTextFile(filePath: string): Promise {
+ const fs = await import('node:fs/promises');
+ return fs.readFile(stripFileScheme(filePath), 'utf8');
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function stripFileScheme(filePath: string): string {
+ return filePath.startsWith('file://') ? filePath.slice('file://'.length) : filePath;
+}
+
+function createAggregateProgress(
+ progressHandler?: ProgressHandler,
+): ProgressHandler {
+ const files = new Map<
+ DownloadProgressAsset,
+ { bytesWritten: number; contentLength: number }
+ >();
+
+ return (progress, info) => {
+ if (info?.asset && info.contentLength && info.contentLength > 0) {
+ files.set(info.asset, {
+ bytesWritten: Math.max(0, Math.min(info.bytesWritten ?? 0, info.contentLength)),
+ contentLength: info.contentLength,
+ });
+ } else if (info?.asset && info.stage === 'complete' && !files.has(info.asset)) {
+ files.set(info.asset, { bytesWritten: 1, contentLength: 1 });
+ }
+
+ const totalBytes = Array.from(files.values()).reduce(
+ (sum, file) => sum + file.contentLength,
+ 0,
+ );
+ const writtenBytes = Array.from(files.values()).reduce(
+ (sum, file) => sum + file.bytesWritten,
+ 0,
+ );
+
+ const aggregateProgress =
+ totalBytes > 0
+ ? Math.max(0, Math.min(1, writtenBytes / totalBytes))
+ : progress;
+
+ progressHandler?.(aggregateProgress, info);
+ };
+}
+
+function ensureDir(module: CEPhonemizerModule, path: string): void {
+ try {
+ module.FS.mkdir(path);
+ } catch {
+ // Emscripten throws if the directory already exists.
+ }
+}
+
+const TEXT_DECODER = new TextDecoder();
diff --git a/src/storage/AssetStorage.ts b/src/storage/AssetStorage.ts
new file mode 100644
index 0000000..6ba4c74
--- /dev/null
+++ b/src/storage/AssetStorage.ts
@@ -0,0 +1,138 @@
+export interface AssetStorage {
+ get(key: string): Promise;
+ set(key: string, data: Uint8Array): Promise;
+ delete(key: string): Promise;
+ has?(key: string): Promise;
+ pathForKey?(key: string): Promise;
+}
+
+export class MemoryAssetStorage implements AssetStorage {
+ private readonly entries = new Map();
+
+ async get(key: string): Promise {
+ const data = this.entries.get(key);
+ return data ? new Uint8Array(data) : null;
+ }
+
+ async set(key: string, data: Uint8Array): Promise {
+ this.entries.set(key, new Uint8Array(data));
+ }
+
+ async delete(key: string): Promise {
+ this.entries.delete(key);
+ }
+
+ async has(key: string): Promise {
+ return this.entries.has(key);
+ }
+}
+
+export class BrowserCacheAssetStorage implements AssetStorage {
+ constructor(private readonly cacheName = 'kittentts-web') {}
+
+ async get(key: string): Promise {
+ if (!hasCacheStorage()) return null;
+ const cache = await caches.open(this.cacheName);
+ const response = await cache.match(cacheRequest(key));
+ if (!response || !response.ok) return null;
+ return new Uint8Array(await response.arrayBuffer());
+ }
+
+ async set(key: string, data: Uint8Array): Promise {
+ if (!hasCacheStorage()) return;
+ const cache = await caches.open(this.cacheName);
+ await cache.put(cacheRequest(key), new Response(toArrayBuffer(data)));
+ }
+
+ async delete(key: string): Promise {
+ if (!hasCacheStorage()) return;
+ const cache = await caches.open(this.cacheName);
+ await cache.delete(cacheRequest(key));
+ }
+
+ async has(key: string): Promise {
+ return (await this.get(key)) !== null;
+ }
+}
+
+export class NodeFileAssetStorage implements AssetStorage {
+ constructor(private readonly rootDirectory?: string) {}
+
+ async get(key: string): Promise {
+ const filePath = await this.pathForKey(key);
+ try {
+ const fs = await import('node:fs/promises');
+ const data = await fs.readFile(filePath);
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
+ } catch {
+ return null;
+ }
+ }
+
+ async set(key: string, data: Uint8Array): Promise {
+ const filePath = await this.pathForKey(key);
+ const fs = await import('node:fs/promises');
+ const path = await import('node:path');
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
+ const tempPath = `${filePath}.download`;
+ await fs.writeFile(tempPath, data);
+ await fs.rename(tempPath, filePath);
+ }
+
+ async delete(key: string): Promise {
+ const filePath = await this.pathForKey(key);
+ const fs = await import('node:fs/promises');
+ await fs.unlink(filePath).catch(() => {});
+ await fs.unlink(`${filePath}.download`).catch(() => {});
+ }
+
+ async has(key: string): Promise {
+ const filePath = await this.pathForKey(key);
+ const fs = await import('node:fs/promises');
+ return fs.access(filePath).then(() => true, () => false);
+ }
+
+ async pathForKey(key: string): Promise {
+ const path = await import('node:path');
+ const root = this.rootDirectory ?? await defaultNodeCacheDirectory();
+ return path.join(root, ...key.split('/').map(safeSegment));
+ }
+}
+
+let memoryFallback: MemoryAssetStorage | null = null;
+
+export function defaultAssetStorage(storageDirectory?: string): AssetStorage {
+ if (isNodeRuntime()) return new NodeFileAssetStorage(storageDirectory);
+ if (hasCacheStorage()) return new BrowserCacheAssetStorage(storageDirectory || 'kittentts-web');
+ memoryFallback ??= new MemoryAssetStorage();
+ return memoryFallback;
+}
+
+export function isNodeRuntime(): boolean {
+ return typeof process !== 'undefined' && Boolean(process.versions?.node);
+}
+
+function hasCacheStorage(): boolean {
+ return typeof caches !== 'undefined' && typeof Response !== 'undefined';
+}
+
+function cacheRequest(key: string): Request {
+ return new Request(`https://kittentts.local/cache/${encodeURIComponent(key)}`);
+}
+
+async function defaultNodeCacheDirectory(): Promise {
+ const os = await import('node:os');
+ const path = await import('node:path');
+ return path.join(os.homedir(), '.cache', 'kittentts-web');
+}
+
+function safeSegment(segment: string): string {
+ return segment.replace(/[^a-zA-Z0-9._-]/g, '_') || '_';
+}
+
+function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
+ return bytes.buffer.slice(
+ bytes.byteOffset,
+ bytes.byteOffset + bytes.byteLength,
+ ) as ArrayBuffer;
+}
diff --git a/src/web-globals.d.ts b/src/web-globals.d.ts
new file mode 100644
index 0000000..f92474d
--- /dev/null
+++ b/src/web-globals.d.ts
@@ -0,0 +1,21 @@
+interface HTMLAudioElement {
+ src: string;
+ currentTime: number;
+ onended: (() => void) | null;
+ onerror: (() => void) | null;
+ onplaying: (() => void) | null;
+ pause(): void;
+ play(): Promise;
+}
+
+declare const Audio: {
+ new(src?: string): HTMLAudioElement;
+};
+
+declare const caches: {
+ open(cacheName: string): Promise<{
+ match(request: Request): Promise;
+ put(request: Request, response: Response): Promise;
+ delete(request: Request): Promise;
+ }>;
+};
From 63ce9938a8cefed0625c179d01f3a96b7fbdef3a Mon Sep 17 00:00:00 2001
From: Dewan Shakil
Date: Tue, 12 May 2026 22:50:29 +0530
Subject: [PATCH 2/6] Fix web runtime loading
---
src/audio/AudioOutput.web.ts | 20 +++++++
src/engine/TTSEngine.web.ts | 101 ++++++++++++++++++++++++++++++-----
src/index.web.ts | 6 ++-
3 files changed, 112 insertions(+), 15 deletions(-)
diff --git a/src/audio/AudioOutput.web.ts b/src/audio/AudioOutput.web.ts
index 140d693..80bf203 100644
--- a/src/audio/AudioOutput.web.ts
+++ b/src/audio/AudioOutput.web.ts
@@ -124,6 +124,26 @@ export function createBrowserAudioPlayer(): AudioPlayer {
};
}
+/**
+ * Compatibility helper for Expo web builds.
+ *
+ * Native builds use the `expo-audio` implementation. Web builds play the
+ * generated WAV through an HTML audio element, so the Expo module argument is
+ * accepted for shared app code but is not needed.
+ */
+export function createExpoAudioPlayer(_Audio?: unknown): AudioPlayer {
+ return createBrowserAudioPlayer();
+}
+
+/**
+ * Compatibility helper for shared imports in web builds.
+ *
+ * React Native Sound is native-only; web builds use browser audio playback.
+ */
+export function createRNSoundPlayer(_Sound?: unknown): AudioPlayer {
+ return createBrowserAudioPlayer();
+}
+
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(
bytes.byteOffset,
diff --git a/src/engine/TTSEngine.web.ts b/src/engine/TTSEngine.web.ts
index c9aa52b..aefe09f 100644
--- a/src/engine/TTSEngine.web.ts
+++ b/src/engine/TTSEngine.web.ts
@@ -1,4 +1,4 @@
-import * as ort from 'onnxruntime-web';
+import type * as Ort from 'onnxruntime-web';
import {
KittenTTSError,
errorMessage,
@@ -22,13 +22,32 @@ export interface TTSEngineOutput {
phonemes: string;
}
+type OrtRuntime = typeof Ort;
+
+type BrowserDocument = {
+ createElement(tagName: 'script'): {
+ async: boolean;
+ src: string;
+ onload: (() => void) | null;
+ onerror: (() => void) | null;
+ };
+ head: {
+ appendChild(element: unknown): void;
+ };
+};
+
+const DEFAULT_ORT_WEB_VERSION = '1.26.0';
+
+let browserOrtPromise: Promise | undefined;
+
/**
* Internal ONNX inference engine.
*
* Orchestrates: text -> TextPreprocessor -> Phonemizer -> TextCleaner -> ONNX -> Float32 PCM
*/
export class TTSEngine {
- private session: ort.InferenceSession;
+ private ort: OrtRuntime;
+ private session: Ort.InferenceSession;
private voices: VoiceEmbeddings;
private config: ResolvedKittenTTSConfig;
private waveformOutputName: string | undefined;
@@ -36,12 +55,14 @@ export class TTSEngine {
private disposed = false;
private constructor(
- session: ort.InferenceSession,
+ ortRuntime: OrtRuntime,
+ session: Ort.InferenceSession,
voices: VoiceEmbeddings,
config: ResolvedKittenTTSConfig,
waveformOutputName: string | undefined,
durationOutputName: string | undefined,
) {
+ this.ort = ortRuntime;
this.session = session;
this.voices = voices;
this.config = config;
@@ -58,7 +79,8 @@ export class TTSEngine {
config: ResolvedKittenTTSConfig,
): Promise {
try {
- await configureOnnxRuntime(config);
+ const ort = await loadOnnxRuntime(config);
+ await configureOnnxRuntime(ort, config);
const sessionOptions = {
executionProviders: ['wasm'],
graphOptimizationLevel: 'all',
@@ -76,6 +98,7 @@ export class TTSEngine {
? 'duration'
: undefined;
return new TTSEngine(
+ ort,
session,
voices,
config,
@@ -169,13 +192,13 @@ export class TTSEngine {
);
// Create tensors
- const inputIds = new ort.Tensor(
+ const inputIds = new this.ort.Tensor(
'int64',
BigInt64Array.from(tokens.map(t => BigInt(t))),
[1, tokens.length],
);
- const styleTensor = new ort.Tensor('float32', styleVec, [1, styleVec.length]);
- const speedTensor = new ort.Tensor('float32', Float32Array.of(speed), [1]);
+ const styleTensor = new this.ort.Tensor('float32', styleVec, [1, styleVec.length]);
+ const speedTensor = new this.ort.Tensor('float32', Float32Array.of(speed), [1]);
const feeds = {
input_ids: inputIds,
@@ -211,7 +234,7 @@ export class TTSEngine {
}
private resolveWaveformOutputKey(
- results: Awaited>,
+ results: Awaited>,
): string | undefined {
if (this.waveformOutputName && results[this.waveformOutputName]) {
return this.waveformOutputName;
@@ -226,7 +249,7 @@ export class TTSEngine {
}
private readDurations(
- results: Awaited>,
+ results: Awaited>,
waveformOutputKey: string,
): number[] {
const durationKey =
@@ -306,7 +329,51 @@ export class TTSEngine {
}
}
-async function configureOnnxRuntime(config: ResolvedKittenTTSConfig): Promise {
+async function loadOnnxRuntime(config: ResolvedKittenTTSConfig): Promise {
+ if (!isBrowserRuntime()) {
+ const importModule = new Function(
+ 'specifier',
+ 'return import(specifier)',
+ ) as (specifier: string) => Promise;
+ return importModule('onnxruntime-web/wasm');
+ }
+
+ const scope = globalThis as {
+ document?: BrowserDocument;
+ ort?: OrtRuntime;
+ };
+
+ if (scope.ort?.InferenceSession) return scope.ort;
+ if (!scope.document) {
+ throw new Error('Browser ONNX Runtime requires a document to load its script.');
+ }
+
+ if (!browserOrtPromise) {
+ browserOrtPromise = new Promise((resolve, reject) => {
+ const script = scope.document!.createElement('script');
+ script.async = true;
+ script.src = defaultOrtScriptURL(config);
+ script.onload = () => {
+ if (scope.ort?.InferenceSession) {
+ resolve(scope.ort);
+ } else {
+ reject(new Error('ONNX Runtime script loaded without exposing globalThis.ort.'));
+ }
+ };
+ script.onerror = () => {
+ reject(new Error(`Failed to load ONNX Runtime script: ${script.src}`));
+ };
+ scope.document!.head.appendChild(script);
+ });
+ }
+
+ return browserOrtPromise;
+}
+
+async function configureOnnxRuntime(
+ ort: OrtRuntime,
+ config: ResolvedKittenTTSConfig,
+): Promise {
if (config.ortWasmPath === false) return;
if (ort.env.wasm.wasmBinary || ort.env.wasm.wasmPaths) return;
@@ -321,7 +388,7 @@ async function configureOnnxRuntime(config: ResolvedKittenTTSConfig): Promise {
+async function configureNodeOnnxRuntime(ort: OrtRuntime): Promise {
const [{ readFile }, { createRequire }] = await Promise.all([
import('node:fs/promises'),
import('node:module'),
@@ -342,8 +409,14 @@ async function configureNodeOnnxRuntime(): Promise {
}
function defaultOrtWasmBaseURL(): string {
- const version = ort.env.versions?.web ?? '1.26.0';
- return `https://cdn.jsdelivr.net/npm/onnxruntime-web@${version}/dist/`;
+ return `https://cdn.jsdelivr.net/npm/onnxruntime-web@${DEFAULT_ORT_WEB_VERSION}/dist/`;
+}
+
+function defaultOrtScriptURL(config: ResolvedKittenTTSConfig): string {
+ if (typeof config.ortWasmPath === 'string') {
+ return `${normalizeWasmDirectory(config.ortWasmPath)}ort.wasm.min.js`;
+ }
+ return `${defaultOrtWasmBaseURL()}ort.wasm.min.js`;
}
function normalizeWasmDirectory(path: string): string {
diff --git a/src/index.web.ts b/src/index.web.ts
index 1805479..51467e2 100644
--- a/src/index.web.ts
+++ b/src/index.web.ts
@@ -32,5 +32,9 @@ export {
export type { KittenPhonemizerProtocol } from './phonemizer/types';
export { CEPhonemizer } from './phonemizer/CEPhonemizer.web';
export { WAVEncoder } from './audio/WAVEncoder';
-export { createBrowserAudioPlayer } from './audio/AudioOutput.web';
+export {
+ createBrowserAudioPlayer,
+ createExpoAudioPlayer,
+ createRNSoundPlayer,
+} from './audio/AudioOutput.web';
export type { AudioPlayer, AudioPlayOptions } from './audio/AudioOutput.web';
From 174cc51bd34dc74abe9d9148597161bff80b1c70 Mon Sep 17 00:00:00 2001
From: Dewan Shakil
Date: Wed, 13 May 2026 13:11:10 +0530
Subject: [PATCH 3/6] Address web review comments
---
README.md | 5 +++++
docs/getting-started.md | 5 +++++
src/audio/AudioOutput.ts | 1 +
src/engine/TTSEngine.web.ts | 3 +++
4 files changed, 14 insertions(+)
diff --git a/README.md b/README.md
index 94c793b..db54635 100644
--- a/README.md
+++ b/README.md
@@ -26,6 +26,11 @@
> need a development build or a prebuilt native project. Web builds use
> `onnxruntime-web` and browser storage instead.
+> React Native Web loads a pinned ONNX Runtime Web script and WASM assets from
+> jsDelivr by default. For production apps that need CDN independence or stricter
+> supply-chain controls, self-host those ONNX Runtime assets and set
+> `ortWasmPath`.
+
## See It In Action
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 88e9ccd..ed4ae80 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -84,6 +84,11 @@ The browser path also supports `generate()`, `wordTimings`, `wavData()`, and
`wavBase64()`. Pass `ortWasmPath` if your app needs to self-host ONNX Runtime
WASM assets instead of using the SDK defaults.
+By default, browser builds load the pinned ONNX Runtime Web script and WASM
+assets from jsDelivr. That keeps the SDK simple to drop into React Native Web,
+but production apps that require tighter supply-chain control or CDN outage
+isolation should self-host those files and set `ortWasmPath` to that directory.
+
## Generate Audio
Use `generate()` when you want audio data back without playing it immediately.
diff --git a/src/audio/AudioOutput.ts b/src/audio/AudioOutput.ts
index ece6eff..7fc54de 100644
--- a/src/audio/AudioOutput.ts
+++ b/src/audio/AudioOutput.ts
@@ -307,6 +307,7 @@ export function createRNSoundPlayer(Sound: RNSoundConstructor): AudioPlayer {
* the package's web entrypoint.
*/
export function createBrowserAudioPlayer(): AudioPlayer {
+ // Browser builds use AudioOutput.web.ts; this stub should never be reached.
throw KittenTTSError.playbackFailed(
'createBrowserAudioPlayer() is only available in React Native Web builds.',
);
diff --git a/src/engine/TTSEngine.web.ts b/src/engine/TTSEngine.web.ts
index aefe09f..dc1439a 100644
--- a/src/engine/TTSEngine.web.ts
+++ b/src/engine/TTSEngine.web.ts
@@ -331,6 +331,9 @@ export class TTSEngine {
async function loadOnnxRuntime(config: ResolvedKittenTTSConfig): Promise {
if (!isBrowserRuntime()) {
+ // Keep this import opaque to web bundlers. A plain dynamic import causes
+ // Metro web to parse ONNX Runtime's generated wasm loader, which fails on
+ // its dynamic import pattern before the SDK can configure the Node path.
const importModule = new Function(
'specifier',
'return import(specifier)',
From 1eb3c1e816f551db66465722701afbb0f59afbab Mon Sep 17 00:00:00 2001
From: Dewan Shakil
Date: Wed, 13 May 2026 13:32:41 +0530
Subject: [PATCH 4/6] Add web example to README
---
README.md | 7 ++++---
assets/web-example.gif | Bin 0 -> 3293081 bytes
2 files changed, 4 insertions(+), 3 deletions(-)
create mode 100644 assets/web-example.gif
diff --git a/README.md b/README.md
index db54635..311dcb8 100644
--- a/README.md
+++ b/README.md
@@ -34,12 +34,13 @@
## See It In Action
-
-
+
+
+
- Device: iOS · Expo example Device: Android · Word timings
+ Device: iOS · Expo example Device: Android · Word timings Web · Browser example
---
diff --git a/assets/web-example.gif b/assets/web-example.gif
new file mode 100644
index 0000000000000000000000000000000000000000..d8244b0b08f714a686d34e924f03f4a62f64010b
GIT binary patch
literal 3293081
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