Projects / 04
ottis
Text to speech with word timings, in Odin. Kyutai's Pocket TTS on the CPU, and a time for every word it says.
- Odin
- MIT
- Pocket TTS · 100M
- CPU only
- Linux · macOS · Windows
By the numbers
- faster than real time
- ~4×
- 4.96 s of speech in 1.19 s on a Ryzen 9 9900X3D, 4 threads
- from word cue to the voice
- ~30 ms
- median +10 ms against the onset after a pause
- binary, nothing to install
- 678 KB
- the runtime, model and aligner download on first use
- of real time to align
- 2%
- 0.47 s for 21.4 s of audio
Speed from ottis say (2026-10-12, voice george, seed 12); alignment numbers from PERFORMANCE_TRADEOFFS.md, measured against pause onsets and Whisper medium.en word timestamps.
What's in it
01Pocket TTS, no Python
Kyutai's 100M-parameter Pocket TTS runs through ONNX Runtime's C API, loaded at run time, with a SentencePiece tokenizer and the chunking and generation loop written in Odin.
02A time for every word
Each chunk's text is force-aligned to its audio by a wav2vec2 CTC recognizer at 20 ms frames, so cues land where the word is actually spoken, not where the model's attention puts it.
03Honest about numbers
Words the aligner can't spell, like "$3.50" said as "three fifty", share the span between their neighbours and are marked estimated.
04Same seed, same samples
The only randomness is the flow head's noise from ottis's own seeded generator, and ONNX Runtime's thread count is fixed, so a seed reproduces its audio bit for bit; say prints the SHA-256.
05Downloads, pinned
ONNX Runtime 1.30.0, the english_2026-04 bundle, the voice and the aligner are fetched on first use and checked against SHA-256 pins, over libcurl on Linux and macOS and WinHTTP on Windows.
06Whisper-style JSON
The -words output is the shape Whisper writes, so caption tools read it as is; onimite's captions{} does.
07A library too
After engine_init the engine is read-only: synthesize runs on several threads at once and gives the same samples as alone. pocket.VERSION changes whenever the output would.
08Ottis, who lip-syncs
The mascot is an SVG drawn by tools/mascot with eyes, four mouths and sound waves as layers a script switches by id, so Ottis can mouth ottis's own word cues.
Credit and licences
ottis is MIT-licensed. The model is Pocket TTS by Kyutai (CC-BY-4.0), so audio made with ottis should credit Pocket TTS by Kyutai (CC-BY-4.0). Two voices, cosette and jean, come from recordings licensed for non-commercial use only.
Next on the list (PLAN.md): a native Odin port of the model, which would drop ONNX Runtime.
Use it
odin build . -o:speed -out:build/ottis
build/ottis say "The lamp cost $3.50." -o lamp.wav -words lamp.json
build/ottis say -f script.txt -voice marius -seed 3 -o scene.wav -words scene.json
build/ottis voices # the 27 voices
build/ottis fetch -verify # download up front, or re-hash what is there{"text": "The lamp cost $3.50.", "duration": 1.840, "segments": [
{"id": 0, "start": 0.000, "end": 1.840, "text": "The lamp cost $3.50.", "words": [
{"word": "The", "start": 0.162, "end": 0.222},
{"word": "lamp", "start": 0.283, "end": 0.485},
{"word": "cost", "start": 0.586, "end": 0.950},
{"word": "3", "start": 1.132, "end": 1.220, "estimated": true},
{"word": "50", "start": 1.220, "end": 1.395, "estimated": true}]}
]}import "deps:ottis/assets" // with -collection:deps=.. from a sibling project
import "deps:ottis/pocket"
root, _ := assets.data_dir()
library, err := assets.fetch(root, "alba") // runtime, bundle, aligner, voice
engine: pocket.Engine
pocket.engine_init(&engine, root, library) // once per process
voice, _ := pocket.voice_load(&engine, root, "alba") // once per voice
result: pocket.Result
pocket.synthesize(&engine, &voice, "Hello there.", pocket.DEFAULT_OPTIONS, &result)
// result.audio: mono f32 at result.sample_rate (24 kHz)
// result.words: { text, index, start, end, estimated } for every source word