A local command-line text-to-speech utility built on hexgrad/Kokoro-82M. Reads UTF-8 text (or IPA phonemes) from stdin, synthesizes speech on-device using ONNX Runtime with Apple's CoreML execution provider, and writes a WAV file with configurable sample rate and bit depth.
No Python or PyTorch is involved at runtime — they're used only as a one-shot
export step to convert the upstream .pth checkpoint into an ONNX graph plus
plain float32 voice tensors that the Rust CLI consumes.
- File or speaker output. With
-o PATHwrites a WAV file; without-oplays directly to the default output device using OS-native audio APIs (AudioToolbox's AudioQueue on macOS, ALSA on Linux) — no third-party audio crates. - Local & offline. Model, voices, and runtime all live on disk. No network.
- Hardware-accelerated. Runs on the Apple Neural Engine / GPU via CoreML
(
MLComputeUnits=All, fp16 GPU accumulation), with automatic CPU fallback for ops CoreML doesn't cover. The compiled CoreML model is cached between runs in~/Library/Caches/storytime/coreml, cutting cold-start by ~50% after the first invocation. - Two input modes.
- Text mode (default): stdin is raw text; the tool shells out to
espeak-ngfor grapheme-to-phoneme conversion. - IPA mode (
--ipa): stdin is IPA phonemes directly. Noespeak-ngdependency on this process — composes in a POSIX pipeline with any G2P.
- Text mode (default): stdin is raw text; the tool shells out to
- Automatic chunking. Inputs longer than the model's ~510-token style limit are automatically split at sentence boundaries (falling back to word, then character boundaries if a sentence or word is itself too long), synthesized chunk-by-chunk, and concatenated with a short silence gap between pieces.
- Structural pause handling. Paragraph, section, and chapter boundaries are preserved from the input and translated into longer silence gaps (configurable via flags). Each chunk's leading/trailing model-produced silence is trimmed and a short linear fade is applied at the seams so the resulting audio has clean, realistically-spaced transitions.
- 54 voices from the Kokoro-82M release (en-US, en-GB, Spanish, French, Italian, Hindi, Japanese, Brazilian Portuguese, Mandarin).
- Multilingual lines. Text is split by writing system and each run is phonemized in its own language, so one voice can switch languages mid-sentence — a Mandarin character says the English callsign in English. Same-script pairs are declared per character or per speech. See Multiple languages.
- Tonal Mandarin. Chinese is phonemized into Misaki's phoneme set, tone tokens included, rather than the toneless IPA espeak-ng emits for it. See Mandarin tones.
- Configurable output. 16/24/32-bit PCM or IEEE float32, any sample rate (resampled from the model's native 24 kHz via a high-quality sinc resampler).
- Adjustable speaking rate via
--speed, and pitch shifting via--pitch(semitones, tempo-preserving) — global, or per character in script mode. - Voice cloning.
storytime clonebuilds a new voicepack from a 10–20 s recording of a real speaker via offline style-space optimization — see Voice cloning.
storytime supports two backends, selectable via --backend. Both use the
same kokoro.onnx model and voices/*.bin assets — there is no separate set
of MLX weights.
| backend | flag | acceleration | notes |
|---|---|---|---|
| ONNX | --backend onnx |
CoreML EP (ANE/GPU/CPU) | available in every build |
| MLX | --backend mlx |
Metal GPU, else CPU | requires --features mlx; the default in that case |
The MLX backend interprets kokoro.onnx directly on Apple's
MLX (via the mlx-c
C API): it parses the ONNX graph natively in Rust and runs each operator as an
MLX op on the Metal GPU. It is verified numerically equivalent to ONNX Runtime
CPU, op-for-op, to float32 epsilon (the only residual is the inherent f32
conditioning of the vocoder's harmonic oscillator — see
docs/onnx-to-mlx-plan.md).
The same interpreter is reused for the GE2E speaker encoder in
voice cloning, so an --features mlx build runs the entire
clone loop — synthesis and embedding — on the GPU.
# Build with MLX support (requires Xcode + the Metal toolchain + macOS 14+).
# First build clones and compiles mlx-c (~5 min); cached afterwards.
cd cli
cargo build --release --features mlx
# (if Metal compilation fails: xcodebuild -downloadComponent MetalToolchain)
# Run — MLX is the default backend in an mlx build; GPU is auto-selected.
echo "Hello." | storytime -o hello.wav # backend=mlx, device=Gpu
echo "Hello." | storytime --backend onnx -o h.wav # force the ONNX backendWith --features mlx, the default backend is MLX on the Metal GPU when a
compatible GPU is present, falling back to MLX on CPU otherwise. Without the
feature, only the ONNX backend is compiled in and is the default. The MLX build
vendors nothing — build.rs fetches and builds mlx-c at build time.
┌──────────────────── one-shot (Python) ────────────────────┐
│ │
kokoro-v1_0.pth │ export.py │→ kokoro.onnx │
voices/*.pt ─┼─▶ (kokoro + │→ voices/*.bin (float32, [N,1,256]) │
config.json │ PyTorch) │→ tokens.json (IPA char → token-id) │
GE2E weights │ │→ spk_encoder.onnx (voice-cloning scorer) │
└────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────── runtime (Rust binary) ────────────────────────┐
│ │
│ stdin ──▶ [espeak-ng subprocess]* ──▶ IPA ──▶ [token ids] │
│ │ │
│ ▼ │
│ voice .bin ─▶ [style row select] ─────▶ [ONNX Runtime + CoreML EP] ─▶ f32│
│ │ │
│ ▼ │
│ [resample + WAV encode] ─▶ out.wav
│ │
│ * skipped when --ipa is passed │
└────────────────────────────────────────────────────────────────────────────┘
The original request mentioned XNNPACK or comparable runtimes. Of the viable options on Apple Silicon:
| runtime | export effort | CPU perf | ANE/GPU | notes |
|---|---|---|---|---|
| ONNX Runtime + CoreML EP | low (single torch.onnx.export) |
good (MLAS kernels, AVX/NEON) | ✅ via CoreML EP | chosen |
| ONNX Runtime + XNNPACK EP | low | good | ❌ CPU only | no reason to skip CoreML on this Mac |
| TFLite + XNNPACK | high (torch → onnx → tf → tflite, lossy) | good | partial via CoreML delegate | more fragile pipeline |
| ExecuTorch | high (custom export, immature for this model class) | good | ✅ MPS backend | not yet worth the setup cost |
ONNX Runtime's CoreML execution provider gives us ANE/GPU acceleration for
essentially zero additional work, with a graceful CPU fallback for ops CoreML
doesn't support. The Rust binding (ort) bundles a matched runtime, so there's
no separate shared library to install.
Kokoro is not trained on raw text — it consumes a fixed vocabulary of IPA
phoneme tokens (178 tokens, defined in the model's config.json). A
grapheme-to-phoneme (G2P) step is required before inference.
Kokoro was trained using espeak-ng's IPA output specifically, so
using a different G2P degrades pronunciation quality. The alternatives all
have significant tradeoffs:
- Bundle/link libespeak-ng statically. Possible, but espeak-ng is GPLv3 — statically linking makes the whole binary GPLv3.
- Port Misaki (the official Kokoro G2P) to Rust. Misaki is Python-only and large; a Rust port is a separate multi-week project, and for most non-English languages Misaki falls back to espeak-ng anyway. Mandarin is the exception — it has a real Misaki frontend, and its phoneme set is reached here by mapping espeak-ng's own output onto it (see Mandarin tones) rather than by porting pypinyin and a word segmenter.
- Shell out to
espeak-ngas a subprocess. Keeps the license boundary at the process boundary, works immediately, and is trivially replaceable with--ipafor users who already have IPA from another source.
This tool takes the subprocess approach and additionally exposes --ipa so
espeak-ng becomes optional when piping IPA from elsewhere.
- macOS on Apple Silicon (tested on macOS 15, arm64) or Debian/Ubuntu Linux. On macOS the CoreML EP is used; elsewhere it falls back to CPU.
- Rust toolchain (stable, ≥ 1.85) — see https://rustup.rs.
- A system package manager:
brewon macOS,apton Linux.setup.shinstalls everything else it needs (Python,espeak-ng).
./setup.shThis downloads the Kokoro-82M checkpoint and voices from HuggingFace
(hexgrad/Kokoro-82M, pinned to a known-good revision), installs the export
dependencies into export/.venv, and converts everything into assets/. It is
safe to re-run.
Manual export (if you already have Python set up)
cd export
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python export.py # downloads from HuggingFace, then exports
python export.py --snapshot /path/to/local/snapshot # or use a local snapshotEither path writes:
assets/
├── kokoro.onnx # ~325 MB, the full model
├── tokens.json # IPA char → token-id vocab
├── spk_encoder.onnx # ~6 MB GE2E speaker encoder (only used by `storytime clone`)
└── voices/
├── af_alloy.bin
├── af_bella.bin
└── ... (54 files)
Notes:
- The export uses the legacy TorchScript exporter (
dynamo=False) because the new TorchDynamo-based exporter fails on the transformers library's SDPA attention path. transformersis pinned to4.47.1. Newer versions introduce acreate_bidirectional_maskhelper that doesn't survive tracing.- Voice tensors are stored as raw little-endian float32 with shape
[N, 1, 256], whereNis 510 or 511 depending on the voice. The Rust loader handles either.
cd cli
cargo build --release
# binary: cli/target/release/storytimeThe ort crate is configured with download-binaries, so a matched
ONNX Runtime is fetched at build time. No brew install onnxruntime is
needed.
brew install espeak-ngSkip this if you'll always pipe pre-computed IPA via --ipa.
# Read from a file instead of stdin
storytime -i story.txt -o story.wav
# Play directly through the speakers (no -o)
echo "Hello, world." | storytime --voice af_bella
# Text in, WAV out (requires espeak-ng)
echo "Hello, world." | storytime --voice af_bella -o hello.wav
# IPA in, WAV out (no espeak-ng required on this process)
echo "həlˈoʊ wˈɜːld." | storytime --ipa -o hello.wav
# Compose with an external G2P
espeak-ng -q --ipa=3 -v en-us "Hello, world." \
| storytime --ipa -o hello.wavBefore anything else, each block's text is normalized to the forms Kokoro's vocab prefers:
- CJK / full-width punctuation. Chinese and Japanese text is written
with
,。!?「」, none of which are in Kokoro's vocab and none of which survive espeak-ng — so a Mandarin line would arrive with every pause and sentence boundary erased. Each is folded onto its ASCII equivalent (,→,,。→.,「」→ curly quotes), which Kokoro does have tokens for. Full-width letters and digits fold too. - Ellipses. Runs of three or more ASCII dots (
...,....) are collapsed to a single…(U+2026). Kokoro has a dedicated token for…; leaving it as three separate.tokens gives three clipped pauses instead of one sustained one. - Quote pairs. Straight
"characters are paired off into alternating curly"(open, U+201C) and"(close, U+201D). Kokoro has distinct open/close tokens with different learned prosody; mapping everything to the undifferentiated straight"token loses that distinction.
Input that already uses … or curly quotes passes through unchanged.
Kokoro's training vocab includes ; : , . ! ? — … " ( ) as first-class
tokens, and the model is trained to produce pauses and intonation
changes on them. espeak-ng in --ipa=3 mode, however, silently
strips every one of these from its output, so a naive text →
espeak → Kokoro pipeline loses all punctuation prosody.
To fix this without switching to Misaki (the upstream Python G2P,
which would mean adding a Python runtime dependency), storytime
splits each block on preserved-punctuation boundaries, feeds only the
text segments to espeak-ng (one per line), and interleaves the
punctuation back into the IPA output before tokenizing. One espeak
invocation per block, same as a naive pass-through — but now ?,
:, ;, —, …, commas, and quote marks all reach the model and
drive its prosody.
If you see unnaturally flat delivery on a specific passage, check the input punctuation is present; the model uses it heavily.
Kokoro produces natural prosody (short pauses on ./,/;/etc.) inside a
block of running text, but espeak-ng collapses all whitespace before the
model sees anything — so without help, paragraph and chapter boundaries
get the same pause as a comma.
storytime parses the input into structural blocks before phonemization
and inserts a typed silence gap at each boundary:
| boundary | how it's detected | default |
|---|---|---|
| paragraph | one blank line between non-empty lines | inline marker " — — — " |
| section | two or more blank lines, or a ## /### heading |
inline marker " — — — — — " |
| chapter | a # heading |
1200 ms silence |
| quote | entry to / exit from a "..." span |
rely on quote tokens |
| within-paragraph chunk split | forced by the 510-token limit | 120 ms silence |
Two mechanisms drive pauses: textual markers (inserted into the text before phonemization so Kokoro generates the pause itself from its trained prosody) and explicit silence (zero samples spliced in after synthesis). Markers are strictly faster because multiple blocks merge into a single inference call — Kokoro has a fixed per-call overhead that amortizes over longer inputs, so collapsing 7 short blocks into one ~500-token call is visibly faster than 7 short calls.
By default paragraph and section boundaries use markers (no inference
split), quote boundaries use neither (the " tokens alone drive
prosody), and chapter boundaries use explicit silence (the pause is
too long to express cleanly as inline punctuation).
Markdown heading markers (# , ## , ### ) are stripped from the spoken
text but their presence upgrades the boundary strength. So this input:
# Chapter One
The night was dark and stormy.
Suddenly, a shot rang out.
## A Pause
It was quiet again.
# Chapter Two
The end.
…produces "Chapter One" → 1200 ms → paragraph → 400 ms → paragraph →
700 ms ("A Pause") → 400 ms → paragraph → 1200 ms → "Chapter Two" → 400 ms
→ "The end." (and the # markers themselves are not spoken).
Within any paragraph, the text is also split at every entry/exit of a
double-quote span (straight "..." or curly "..."), so dialogue gets
a small pause before and after the character's line — e.g. She said, "Hello." Then she left. becomes three pieces with Quote gaps at the
transitions. Single quotes are not used as boundaries (they're
indistinguishable from apostrophes in plain text).
The flags --paragraph-gap-ms, --section-gap-ms, --chapter-gap-ms,
--chunk-gap-ms, --quote-gap-ms tune the durations. Set any of them
to 0 to disable that boundary type. Additionally, before inserting each
gap the synthesized chunk is:
- Trimmed of leading/trailing near-silence (
--trim-threshold, default 0.005), so the typed gap above is the only silence the listener hears at that boundary — no stacked model tail. - Fade in/out applied linearly over
--fade-ms(default 10 ms) at both ends, which removes the clicks you'd otherwise hear when non-zero-crossing samples sit next to inserted silence (particularly audible at long chapter gaps).
In --ipa mode, structural parsing still runs: preserve blank lines in
your piped IPA to get paragraph/section gaps, or use # / ##
prefixes to mark chapter/section boundaries.
Text input is treated as Markdown by default. Formatting markers are interpreted and then stripped, so they are never spoken, and emphasis is translated into Kokoro's phoneme-level stress:
| Markdown | Effect on speech |
|---|---|
*italic* / _italic_ |
Stressed — the word's primary-stressed vowel is lengthened (one ː). |
**bold** / __bold__ |
Emphasized — a stronger, more drawn-out lengthening (two ː). |
# / ## / ### headings |
Marker stripped; boundary strength upgraded (see above). |
[text](url), [text][ref],  |
Collapsed to their visible text (text / alt); the URL is dropped. |
`code` and fenced blocks |
Backticks/fences removed; the contents are kept and spoken as plain text. |
- / * / + / 1. list bullets |
Bullet removed; item text kept. |
> blockquotes, ---/***/___ rules, ~~strike~~ |
Markers removed. |
Emphasis works because Kokoro consumes IPA and is trained on the stress
(ˈ/ˌ) and length (ː) tokens that the upstream Misaki G2P produces.
storytime ensures the emphasized word carries a primary-stress mark and then
lengthens its stressed vowel — so She was **very** happy phonemizes the
emphasized word as vˈɛːːɹi rather than a flat vˈɛɹi. Underscores only
emphasize at word boundaries, so identifiers like do_a_thing are left alone.
Pass --no-markdown to take the input as literal text instead (no stripping,
no emphasis). --ipa mode implies --no-markdown, since the input is already
phonemes.
Passing -o - writes the WAV to stdout so the output can be piped
directly into another process without a temporary file:
echo "Streamed." | storytime -o - | ffplay -autoexit -nodisp -
echo "Streamed." | storytime -o - | ffmpeg -i - out.mp3Because stdout is not seekable, the RIFF/data size fields in the
header can't be back-patched after the fact and are set to the maximum
u32 value (0xFFFFFFFF). Streaming-aware decoders (ffmpeg, sox, VLC,
the majority of media players) either honor that sentinel or read
until EOF. A few strict parsers that validate the declared sizes may
reject these streams — save to a file with -o out.wav in that case.
If you don't pass -o, the synthesized audio is played through the default
output device using the OS-native audio API:
- macOS — AudioToolbox's AudioQueue (linked via the
AudioToolboxsystem framework). No extra install; works on any supported macOS. - Linux — ALSA (
libasound). Install the ALSA runtime if it isn't already present (sudo apt install libasound2on Debian/Ubuntu, etc.). Opening thedefaultPCM device works transparently through PipeWire and PulseAudio as well.
Both paths are pure FFI — no cpal, rodio, or other third-party audio
crates in the dependency tree. The --sample-rate and --bit-depth flags
still apply to file output; playback always uses the resampled float32
stream at the chosen --sample-rate.
echo "Test." | storytime \
--voice am_michael \
--sample-rate 48000 \
--bit-depth 24 \
--speed 1.1 \
-o test.wavstorytime --list-voicesWith --script, storytime reads a screenplay where different characters speak
in different voices. The format is the universal NAME: dialogue convention any
LLM (local or hosted) already produces reliably, plus a # Cast header that
assigns each character a voice. storytime segments the input by speaker,
synthesizes each speech in that character's voice, and mixes the result —
including genuine overlap when one character interrupts another.
storytime --script -i play.md -o play.wavA complete example:
# Cast
ALICE: female, american, young
BOB: male, british, gruff
NARRATOR: af_heart # an explicit voice id also works
GIANT: bm_george pitch=-5 # optional per-character pitch shift
---
NARRATOR: They stood at the door, neither willing to move first.
ALICE: Are you sure about this? I really think we should wait and--
BOB: Stop. We're going in, and that's final.
ALICE: *Fine.* But if this goes wrong, it's on you.The format (matching is case-insensitive and forgiving, so models don't have to be precise):
- Cast block — the lines under a
CastorDramatis Personaeheading, up to the next heading or---rule. Each entry isNAME: voice, wherevoiceis either an explicit voice id (af_bella) or a trait list. It is never spoken. - Traits —
gender(female/male) andaccent/language (american,british,spanish,french,hindi,italian,japanese,portuguese,mandarin) are honored exactly, since they are encoded in the voice catalog. Other traits (young,gruff,warm,deep, …) are best-effort: they keep each character's voice distinct and make the assignment reproducible, but the catalog can't guarantee a specific timbre. Each character gets a different voice. - Speech — a line beginning
NAME:(a declared character, or any name in screenplay all-caps) starts that character's turn; following non-speaker lines, up to a blank line or the next speaker, belong to it. - Pitch — a cast entry may add
pitch=<semitones>(e.g.pitch=-5to drop a giant's voice,pitch=+8to lift a mouse's). It shifts that character's pitch while preserving tempo. Characters without one use the global--pitch(default0), so--script --pitch 2lifts the whole scene except where overridden. - Languages — a character can speak more than one. Lines are split by writing
system and phonemized language by language automatically; a cast entry may also
declare them with
lang=(SPENCER: zm_yunyang lang=cmn+en-us), and a single speech may name its own with a parenthetical (MARIA (in Spanish):). See Multiple languages. - Narration — lines with no speaker are spoken by
NARRATOR(or--narrator/--voiceif no narrator is cast). - Interruptions — end a speech with
--or—and the next speech overlaps it: the interrupter begins--overlap-msbefore the first finishes, and the interrupted tail is ducked under it (--duck-gain). - Parentheticals —
(stage directions)are stripped, not spoken, whether they follow the colon (ALICE: (beat) Fine.) or sit on the speaker label (ALICE (whispering):). One that names a language sets the speech's language. - Markdown inside a speech (
*emphasis*, quotes, punctuation) works as usual.
Output is mono. The cast can also live in a separate file via --cast cast.md,
leaving the body as pure dialogue. --script is incompatible with --ipa.
A character is not limited to one language, and neither is a plain narrated story. Every line is split into runs of a single writing system, and each run is phonemized with the espeak-ng voice that actually speaks it — so a Mandarin character reads a Chinese sentence as Chinese and the English callsign in the middle of it as English, in the same breath:
# Cast
NARRATOR: bm_hugh
SPENCER: zm_yunyang
---
SPENCER: Houston, this is Astra-1. We are proceeding with manual re-entry.
NARRATOR: ...he said, then switched frequency.
SPENCER: 北京空间站,这里是 Astra-1。我们将进行手动返回。storytime: narrator -> bm_hugh (espeak en-gb)
storytime: spencer -> zm_yunyang (espeak cmn + en-us)
storytime: mixed-script text: phonemized as cmn + en-us
Nothing had to be annotated for that. Han, kana, Hangul, Devanagari, Cyrillic, Greek, Arabic, Hebrew and Thai each route to their majority language, and a speaker whose own language is not Latin-script reads Latin text as American English. The Kokoro voice never changes — only the phonemes do — which is what produces the natural effect of one person switching languages rather than two different speakers.
Two languages in the same script (English and Spanish, say) cannot be told
apart by shape, so name them. Both forms take a language name or an espeak-ng
voice code (spanish, es, mandarin, cmn, british, en-gb, …):
- Per character, in the cast:
MARIA: ef_dora lang=es+en-us. The first is the character's base language; each language after it claims the writing systems it is written in.lang=cmn+en-gbgives a Mandarin speaker a British accent in English. - Per speech, as a parenthetical:
MARIA (in English): We are ready.It works before or after the colon (MARIA: (switching to Spanish) Estamos listos.), is never spoken, and applies to that whole speech. Ordinary stage directions like(whispering)are unaffected.
--espeak-voice CODE sets the base voice for everything that has not declared
one, and --no-auto-language switches the per-script routing off entirely so a
single espeak-ng voice phonemizes every character of the input.
Mandarin takes an extra step on top of this — see Mandarin tones below.
Mandarin is the one language that does not go through espeak-ng's IPA
output, because that output has no usable tone in it: espeak-ng writes 妈 (mā)
and 骂 (mà) both as mˈɑ5, and the pitch digits it does emit are not in
Kokoro's vocabulary, so they were dropped before the model ever saw them. Every
syllable arrived toneless — which in Mandarin is not an accent, it is the wrong
word.
Kokoro's z* voices were trained on Misaki, whose Mandarin output
carries tone as four dedicated tokens (→ high level, ↗ rising, ↓ dipping,
↘ falling). storytime produces exactly those: Chinese text is phonemized with
espeak-ng's mnemonic output (-x), which does keep the tone as a pitch
contour, and mapped to Misaki's phoneme set:
系统 -> S; | i51 | th | 'ong21 -> ɕi↘tʰʊ↓ŋ
你好 -> n | i35 | X | 'Au214 -> ni↗xau↓ (third-tone sandhi and all)
The mapping was derived from, and checked against, Misaki itself: all 410
Mandarin syllables in all five tones, every character in the CJK block, and
connected sentences. It also patches the one real hole in espeak-ng's Mandarin
— it has no rule for the -iong final, so 兄/凶/穷/熊 and 80 other characters
were being read aloud as English words mid-sentence.
docs/mandarin-tones.md records the derivation, the
verification numbers, and the remaining limitations (espeak-ng picks the
readings, so it decides polyphones and word segmentation, and a wrong tone in
its dictionary stays wrong).
Nothing needs to be enabled: any Chinese text routed to Mandarin — a z*
voice, a lang=cmn character, or a Chinese phrase inside an English story —
takes this path.
| flag | default | description |
|---|---|---|
-i, --input PATH |
(stdin) | read input from file; - or omitted means stdin |
--voice NAME |
af_heart |
voice name from --list-voices |
--sample-rate HZ |
24000 |
output sample rate; model native is 24 kHz |
--bit-depth {16,24,32,float32} |
16 |
PCM bit depth |
--speed FLOAT |
1.0 |
speaking rate multiplier |
--pitch SEMITONES |
0 |
pitch shift in semitones (+ up / − down), tempo preserved; script-mode default (see below) |
--ipa |
off | treat stdin as IPA (skip espeak-ng) |
--espeak-voice CODE |
(auto) | force the espeak-ng voice for phonemization (e.g. en-gb); by default chosen per voice from its language prefix. Text in another writing system still routes to that script's language |
--no-auto-language |
off | disable per-script language switching; phonemize everything with the one resolved espeak-ng voice (see Multiple languages) |
--assets PATH |
../assets |
location of exported assets |
--list-voices |
— | list available voices and exit |
-o, --output PATH |
(unset) | write WAV here; - streams WAV to stdout; if omitted, play to default output device |
--chunk-gap-ms |
120 |
silence between chunker-forced splits inside a paragraph |
--quote-gap-ms |
0 |
silence at quote transitions; > 0 forces a quote-aware split |
--paragraph-gap-ms |
0 |
silence between paragraphs; > 0 forces a split (overrides marker) |
--section-gap-ms |
0 |
silence between sections; > 0 forces a split |
--chapter-gap-ms |
1200 |
silence between chapters (# heading) |
--paragraph-marker |
". … " |
inline marker between paragraphs (period = sentence-ending prosody, ellipsis = sustained pause) |
--section-marker |
". … … " |
inline marker between sections |
--fade-ms |
10 |
linear fade-in/out at every chunk seam (avoids clicks) |
--trim-threshold |
0.005 |
amplitude below which per-chunk leading/trailing silence is trimmed (0 disables) |
--coreml-cache PATH |
~/Library/Caches/storytime/coreml |
where CoreML stores its compiled model between runs |
--no-coreml-cache |
off | disable the cache (forces recompilation each run) |
--script |
off | screenplay mode: NAME: dialogue with a # Cast header (see Script / multi-voice) |
--cast PATH |
(unset) | read the cast from a separate file (script mode) |
--narrator NAME |
(= --voice) |
voice for unattributed narration (script mode) |
--overlap-ms |
250 |
overlap when one speech interrupts another (script mode) |
--duck-gain |
0.4 |
gain applied to an interrupted speech's tail under the interrupter (script mode) |
--line-gap-ms |
120 |
silence between consecutive non-overlapping speeches (script mode) |
Naming convention: {lang}{gender}_{name}.
af_*,am_*— American female / malebf_*,bm_*— British female / maleef_*,em_*— Spanishff_*— Frenchhf_*,hm_*— Hindiif_*,im_*— Italianjf_*,jm_*— Japanesepf_*,pm_*— Brazilian Portuguesezf_*,zm_*— Mandarin Chinese
Match your voice to your input language — pronunciation quality depends on both the voice embedding and the G2P output that produced the IPA.
storytime selects the espeak-ng voice automatically from each voice's
language prefix, so the phonemes match the accent: American voices (a*) use
en-us, British voices (b*) use en-gb (which gives the non-rhotic vowels
and trap-bath split British voices were trained on), and the non-English
prefixes map to their respective espeak languages. This is per-voice, so in
--script mode a British character and an American one are phonemized
differently in the same run. Pass --espeak-voice CODE to force a specific
espeak voice for everything (e.g. an accent the prefix can't express, or a
cloned voice whose name carries no language prefix).
A voice is not locked to that one language, though: text written in another script is phonemized in its language, so any voice can speak a foreign phrase and a non-English voice can speak English. See Multiple languages.
storytime clone creates a new voicepack from a short recording of a real
speaker — record yourself once, then narrate any story in (an approximation
of) your own voice, fully offline:
# 1. See the paragraph you'll need to read aloud.
storytime clone --print-script
# 2. Record yourself reading it (10–20 s, quiet room, any recorder), then
# convert to mono WAV:
ffmpeg -i raw.m4a -ar 24000 -ac 1 ref.wav
# 3. Optimize a new voicepack against the recording (hours; interruptible —
# audition mid-run and stop early, or extend later with --resume).
storytime clone --ref ref.wav --name myvoice --budget-min 60
# 4. Use it like any stock voice, forever, at zero extra runtime cost.
echo "Once upon a time..." | storytime --voice myvoice -o story.wavKokoro ships no reference/style encoder — hexgrad deliberately withheld it —
so a recording cannot be encoded into the model's 256-dim style space.
Instead, clone runs a gradient-free hill-climb over that space (after
KVoiceWalk): it starts from a blend
of the stock voices closest to your recording, then repeatedly perturbs the
style vector, synthesizes two fixed test utterances, and scores the audio
against your recording — a weighted harmonic mean of speaker-embedding
similarity (a GE2E encoder exported to assets/spk_encoder.onnx),
cross-text self-similarity (stability), and a low-weight acoustic
feature guard. Improvements are kept; the result is an ordinary
voices/<name>.bin. Details in docs/voice-cloning.md.
Set expectations accordingly: Kokoro is an 82 M-param model trained on a few
hundred hours — the clone will be recognizably you-ish, not a studio-grade
voice double. Results are stochastic; a different --seed can land a
noticeably better (or worse) voice, and runs are cheap to repeat. Each step
synthesizes one or two ~13 s test utterances, so throughput is backend-bound —
on the MLX GPU backend the whole loop (synthesis and the speaker encoder)
runs on the Metal GPU at ~2 steps/s, versus ~0.2 steps/s on ONNX/CPU. Even at
the faster rate the default --steps 2000 is a long walk, which is why
--budget-min, mid-run auditioning, and --resume exist. The two backends are
verified to score identically (the embedding agrees to cosine > 0.999), so the
choice is purely speed.
| flag | default | meaning |
|---|---|---|
--steps |
2000 |
maximum optimization steps |
--budget-min |
0 (off) |
wall-clock cap in minutes; stops at whichever of --steps/budget hits first |
--init |
auto | starting blend, e.g. --init af_bella,af_heart; default ranks all English voices against your recording and blends the top 3 |
--seed |
0 |
RNG seed (best-effort reproducibility: CoreML/Metal inference is not bit-deterministic; use --backend onnx for stricter runs) |
--resume |
continue an interrupted walk from its saved state (voices/<name>.bin.temp.json) |
|
--ref-text FILE |
built-in script | transcript, if you recorded something other than --print-script (needs espeak-ng) |
--backend |
mlx if built with --features mlx, else onnx |
runs both synthesis and the speaker encoder; mlx keeps the whole loop on the GPU |
Training is incremental and crash-safe. While it runs, the in-progress voice
lives in voices/<name>.bin.temp (a partial-download file), with its training
state in a voices/<name>.bin.temp.json sidecar; both are rewritten atomically
every ~50 steps or 60 seconds. The final voices/<name>.bin appears only when
training completes (the temp file is then renamed into place and the sidecar
removed). So you can:
# Start training. Press Ctrl-C any time to stop gracefully: it finishes the
# current step, writes a checkpoint, and exits (press Ctrl-C again to abort
# immediately). A kill/reboot loses at most the last checkpoint interval.
storytime clone --ref ref.wav --name myvoice --steps 2000
# Resume from exactly where it left off (continues toward the original --steps):
storytime clone --ref ref.wav --name myvoice --resume
# Preview the partial voice from ANOTHER terminal while it's still training —
# the bare name resolves to the in-progress .bin.temp until the final exists:
echo "Once upon a time..." | storytime --voice myvoice -o preview.wav--budget-min N is the same pause built in: it stops after N minutes leaving a
resumable temp, so --budget-min 5 then --resume (repeatedly) walks a long
clone in short sessions. --list-voices shows in-progress clones as
<name> (training). Recording tips: read the script naturally at your normal
pitch, use a quiet room, and avoid clipping; clean input matters more than
length. Cloning targets English voices (the speaker-similarity scorer is
English-trained).
One-time prerequisite: assets/spk_encoder.onnx, produced by ./setup.sh
(or python export/export.py --skip-model --skip-voices on an existing
setup). espeak-ng is not needed at clone time — the test utterances ship
pre-phonemized.
storytime/
├── export/ # one-shot Python export (not used at runtime)
│ ├── export.py
│ ├── verify_spk.py # speaker-encoder parity checks (voice cloning)
│ └── requirements.txt
├── cli/ # Rust CLI (runtime)
│ ├── Cargo.toml
│ ├── build.rs # fetches+builds mlx-c under --features mlx
│ └── src/
│ ├── main.rs
│ ├── clone.rs # `storytime clone` voice-cloning subcommand
│ ├── dsp.rs # audio analysis for cloning (spectrogram, YIN, ...)
│ ├── script.rs # screenplay / multi-voice mode
│ ├── zh.rs # Mandarin: espeak mnemonics -> Misaki phonemes
│ └── mlx/ # MLX backend: native ONNX-graph interpreter
├── docs/ # design + verification notes
├── assets/ # produced by export.py (gitignored)
│ ├── kokoro.onnx
│ ├── tokens.json
│ ├── spk_encoder.onnx # GE2E speaker encoder (voice cloning)
│ └── voices/*.bin
└── README.md
The assets/ directory is gitignored — it's large (~325 MB model plus ~27 MB
of voice tensors) and reproducible from the upstream snapshot via export.py.
espeak-ng: command not found — either install it (brew install espeak-ng)
or use --ipa and provide phonemes from another source.
could not locate assets/ directory — run export.py first, or pass
--assets /path/to/assets.
spk_encoder.onnx not found (voice cloning) — your assets/ predates the
cloning feature. Re-run ./setup.sh, or on an existing setup:
cd export && source .venv/bin/activate && python export.py --skip-model --skip-voices.
Context leak detected, msgtracer returned -1 — cosmetic noise from
macOS's CoreML stack. Inference still runs correctly. Ignore.
Ctrl-C doesn't stop it — storytime installs its own SIGINT/SIGTERM handler
(after the backend loads) and unblocks those signals, so Ctrl-C stops one-shot
synthesis immediately and clone after the current step (a second Ctrl-C force
-quits). If it still does nothing, the signal isn't reaching the process: some
IDE integrated terminals (older VS Code / JetBrains consoles) don't forward
Ctrl-C to the foreground job — run storytime in a real terminal, or send the
signal yourself with kill -INT <pid> (or kill <pid>).
Long inputs — the model's style tensor has a fixed maximum length
(510–511 phoneme tokens, depending on voice). The CLI handles this
automatically: long inputs are split at sentence boundaries (.!?;…),
then at word boundaries inside any sentence that's still too long, then
at character boundaries as a last resort. Each chunk is synthesized
independently and the results are concatenated with a ~150 ms silence gap.
Progress is printed per chunk on stderr.
Pronunciation is wrong — check that your --voice language matches the
input language, and that the IPA being fed to the model looks reasonable.
Run espeak-ng -q --ipa=3 -v en-us "your text" to inspect what the model
actually sees.
Chinese sounds toneless or reads a character in English — the tone tokens
come from a mapping over espeak-ng's mnemonic output; run
espeak-ng -q -x --sep='|' -v cmn 你好 to see what it produced (the tone is the
digits: n|i35|X|'Au214). A character espeak-ng cannot read at all is skipped
rather than mispronounced; see
docs/mandarin-tones.md for the known gaps.
A foreign phrase is phonemized in the wrong language — the run log names the
espeak-ng voice used for each speaker, and prints mixed-script text: phonemized as … whenever a line switched languages. If the phrase is in the same script as
the rest of the line (English inside Spanish, say), nothing can detect it — say
so with a cast lang= or a (in English) parenthetical, or turn the routing off
with --no-auto-language. See Multiple languages.
This repository's code is available under the Apache 2.0 license.
The Kokoro-82M model weights are distributed by hexgrad under Apache 2.0.
espeak-ng, when used, is GPLv3; this tool invokes it as a separate process,
so there is no linking relationship between storytime and espeak-ng.