speech-tts
Tier 1 · engines. One dependency, @titan-design/app-paths, for the default model cache. The Kokoro provider needs two optional peers and a model download, and only when you use it.
npm install @titan-design/speech-ttsThe problem it solves
Speech code tends to assume the machine it was written on: a GPU, a Python venv, a vendor key. When it moves to a laptop, a GPU server or a hosted-only deployment, the call sites must change.
This package puts every engine behind one TtsProvider contract. Each provider declares its capabilities, and a registry chooses one from the deployment's profile file and environment. The calling code does not change between deployments.
When to reach for it
You synthesize speech, and you cannot know in advance which engine a deployment has. Use commandTts for any local or ssh-reached program, httpTts for any HTTP endpoint, and fakeTts in tests. Use kokoroProvider for local, in-process speech with word timings. Speech recognition and alignment belong to speech-stt, not this one.
Example
Verified against the 0.0.0 workspace build.
import { selectTts } from "@titan-design/speech-tts";
// profile.json declares the providers; TITAN_SPEECH_TTS=gpu would force one.
const { provider, skipped } = await selectTts({
profilePath: "./profile.json",
requirements: { wordTimestamps: true, voice: "narrator" },
});
const { audio, durationS, words } = await provider.synthesize({ text: "Hello there." }, "narrator", { speed: 1.1 });
audio.sampleRate; // from the engine's WAV header
skipped; // [{ id: "laptop", reason: "lacks word timestamps", missing: ["word timestamps"] }]The package README has three example profiles (laptop CPU, GPU server, hosted only), the stdio contract for commands, and the HTTP mapping.
Kokoro, in process
kokoroProvider runs Kokoro-82M in your process. It returns word timings taken from the model's own durations, matching Python's KPipeline.join_timestamps.
npm install @huggingface/transformers kokoro-jsimport { defaultTtsRegistry, kokoroFactory, selectTts } from "@titan-design/speech-tts";
// g2p is any { tokens(text) } that returns { text, phonemes, whitespace } items.
const registry = defaultTtsRegistry().register("kokoro", kokoroFactory({ g2p }));
const { provider } = await selectTts({ registry, profilePath, requirements: { wordTimestamps: true } });- fp32 only. Other precisions shift the durations, so a profile
dtypeother thanfp32is refused. - Cache. Give the profile entry a
cacheDir, or letresolveSpeechRuntime()choose one (see "Device and cache"). The model is about 325 MB and downloads on the first call, so warm the provider at startup. The defaulttimeoutMsis 300000. - Offline. With
TITAN_SPEECH_OFFLINEset, a model missing fromcacheDiris refused withTtsUnavailableErrorbefore any network call. - Unknown words. A word Kokoro cannot phonemize is not spoken. By default it gets a zero-width timing and a warning, and the words after it are timed late, as in Python. Set
"unknownWords": "fail"on the profile entry to reject the call instead, withTtsUnknownWordsErrorlisting every such word and its token index, before the model loads. - Timings. They follow Python's heuristic, which offsets the start by a fixed guess. Parity with Python is the bar, not acoustic truth.
- Licence. The weights are Apache-2.0, and so are
kokoro-jsand transformers.js. - Voices. The US
af_*andam_*voices, from thekokoro-jspeer. - Smoke test. An opt-in test runs the real model. See the package README.
Device and cache
One call picks the device and model cache for Kokoro here and for Whisper and wav2vec2 in speech-stt:
import { resolveSpeechRuntime, runtimeOptions } from "@titan-design/speech-tts";
const runtime = await resolveSpeechRuntime();
const tts = kokoroProvider({ g2p, ...runtimeOptions(runtime, "tts") });- Order. Per model:
TITAN_SPEECH_DEVICE_<TTS|STT|PHONES>, thenTITAN_SPEECH_DEVICE, then the profile'sruntime.device, thencpu. The cache isTITAN_SPEECH_CACHE, thenruntime.cacheDir, then the per-user cache directory fortitan-speech. - Absolute paths only. A
TITAN_SPEECH_CACHEstarting with~is refused. A space in the path is kept as is. - CUDA is listed, not used.
runtime.availableshowscudaon linux-x64 when the ONNX Runtime CUDA library and the NVIDIA driver exist. Choosing it throwsunverified-deviceuntil spike E9 has verified a real session. - Refused.
coremlandwebgpuneed transformers.js 4.x. An x64 Node on macOS throwsunsupported-platform: run an arm64 Node.
What it deliberately does not do
- No vendor SDK. The in-process Kokoro adapter is the only engine it ships, and it plugs in through
registry.register("kokoro", kokoroFactory({ g2p })). - It does not decide where the profile lives. The caller passes a path, or sets
TITAN_SPEECH_PROFILE. It does pick a default model cache and a device. - It does not resample.
audio.sampleRateis whatever the engine produced.
Gotchas
TITAN_SPEECH_TTSrestricts selection to the providers it names. The profile'spreferonly reorders them.- Word timings an engine returns are dropped unless the provider declares
wordTimestamps. Selection is what promises timings to the caller. TITAN_SPEECH_DEVICE_STTbeatsTITAN_SPEECH_DEVICE, which beats the profile. An empty variable counts as unset.- A Kokoro entry's
deviceloses to an env-chosen device only when the runtime is passed tokokoroFactory. - A speed of exactly 1 needs no
speedControl. Any other speed does. - A command times out by killing its whole process group, so wrap engines in a program that exits when killed, not a daemon that detaches.
- A command sees only
PATH,HOME,LANG,TMPDIRand its configuredenv. An ssh argv that needs an agent must listSSH_AUTH_SOCKthere. - A misconfigured provider stops selection with
TtsConfigError; it does not fall through to the next one unless the profile setstts.fallback: true. An unregisteredkindstops it even then (unknown-kind), so a typo never hands the text to the next engine. httpTtsrefuses redirects and refuses plain http to non-loopback hosts unlessallowInsecureHttpis set. Output pastmaxOutputBytes(64 MB) fails the call.
Where it came from
New. It is the first slice of the speech packages (TP-2956). It follows the owner's requirement that the TTS layer be swappable per deployment rather than assume one machine's setup.