The Kokoro TTS service (kokoro-service.cpp) is a C++ implementation of the Kokoro German text-to-speech engine, optimized for Apple Silicon via CoreML (ANE). It replaces the original Python kokoro_service.py with a native binary.
Pipeline role. As of the 2026-04 TTS redesign, Kokoro is not an interconnect pipeline node. It is a dock client that connects to the generic TTS stage (bin/tts-service) via a small TCP protocol on 127.0.0.1:13143. The TTS stage owns the LLaMA↔OAP pipeline slot; Kokoro only produces audio in response to text frames the dock forwards to it. A sibling engine (e.g. bin/neutts-service) can dock at any time and will hot-swap Kokoro out.
On startup Kokoro:
- Connects to
127.0.0.1:13143. - Sends one line of JSON HELLO, terminated with
\n:{"name":"kokoro","sample_rate":24000,"channels":1,"format":"f32le"} - Reads one line back.
OK\n→ the slot is ours, proceed.ERR <reason>\n→ close and retry after 200 ms. - After
OK, every frame on the socket is tag-prefixed:0x01for a serializedPacket(text in from LLaMA, audio out to OAP),0x02for a mgmt frame (MgmtMsgTypebyte + optional length-prefixed payload). PING/PONG keepalive runs on the same socket at 200 ms cadence.
The dock sends CUSTOM SHUTDOWN (MgmtMsgType::CUSTOM with payload "SHUTDOWN") when another engine has won the slot. Kokoro's handler calls shutdown_all_calls() (joins per-call synthesis workers, releases CoreML model handles) and then std::_Exit(0). The dock waits up to 2 s for the TCP close before force-closing. A replacement engine may restart Kokoro later via the frontend; it will simply re-dock.
Engine-local diagnostics live on cmd port 13144 (moved from 13142, which now belongs to the dock). TEST_SYNTH, BENCHMARK, SYNTH_WAV, SET_SPEED, SET_LOG_LEVEL, PING, STATUS behave as before.
Architecture: CoreML split pipeline with HAR source on CPU
| Component | Technology | Device | Latency |
|---|---|---|---|
| Phonemization (espeak-ng) | libespeak-ng (cached) | CPU | ~5ms |
| Phonemization (neural G2P) | DeepPhonemizer CoreML | ANE | ~8ms |
| Duration model | CoreML (BERT + prosody) | ANE | ~65ms |
| Alignment | repeat_interleave (C++) | CPU | <1ms |
| HAR source | TorchScript (SineGen+STFT) | CPU | ~5ms |
| Split decoder | CoreML vocoder (3 buckets) | ANE | ~70ms |
| Total | ~145ms |
- macOS with Apple Silicon (M1/M2/M3/M4)
- conda (miniconda or miniforge)
- espeak-ng:
brew install espeak-ng - PyTorch (system Python, for libtorch C++ headers):
pip install torch
The unified export script downloads the Kokoro German model, creates a conda environment with compatible dependency versions, and exports all CoreML artifacts.
python3 scripts/export_kokoro_models.pyThis will:
- Create/reuse conda env
kokoro_coreml(Python 3.11, torch==2.5.0, coremltools==8.3.0) - Download the Kokoro German model from HuggingFace (~312 MB)
- Download voice embeddings (df_eva, dm_bernd)
- Export CoreML duration model →
bin/models/kokoro-german/coreml/kokoro_duration.mlmodelc - Export CoreML split decoder (3 buckets) →
bin/models/kokoro-german/decoder_variants/kokoro_decoder_split_{3s,5s,10s}.mlmodelc - Export HAR TorchScript models →
bin/models/kokoro-german/decoder_variants/kokoro_har_{3s,5s,10s}.pt - Export voice packs →
bin/models/kokoro-german/{df_eva,dm_bernd}_voice.bin - Export vocabulary →
bin/models/kokoro-german/vocab.json
# Skip dependency installation (already set up)
python3 scripts/export_kokoro_models.py --no-install
# Skip model download (already downloaded)
python3 scripts/export_kokoro_models.py --no-download
# Export specific components only
python3 scripts/export_kokoro_models.py --duration-only
python3 scripts/export_kokoro_models.py --decoder-only
python3 scripts/export_kokoro_models.py --voices-only- torch==2.5.0: coremltools 8.3 is incompatible with PyTorch 2.10+ (
AttributeError: 'torch._C.Node' object has no attribute 'cs') - coremltools==8.3.0: Last version to support
torch.jit.trace→ CoreML conversion without errors - numpy==1.26.4: Required for coremltools 8.3 compatibility (numpy 2.x breaks it)
cd build && cmake .. -DKOKORO_COREML=ON && make -j4The build auto-detects:
- libtorch: Via
python3 -c "import torch; print(torch.utils.cmake_prefix_path)" - espeak-ng: Searches
/opt/homebrew/lib,/usr/local/lib - espeak-ng data: Searches
/opt/homebrew/share/espeak-ng-data,/usr/local/share/espeak-ng-data
- CMake 3.22+
- C++17 compiler with Objective-C++ support
- macOS Frameworks: CoreML, Foundation
./bin/kokoro-service [--voice df_eva|dm_bernd] [--g2p auto|neural|espeak]The service:
- Loads CoreML duration model, split decoder, HAR models, voice pack, and vocab
- Initializes espeak-ng for German phonemization
- Opens a TCP connection to the TTS dock on
127.0.0.1:13143, sends the HELLO line described above, and waits forOK\n - Reads text packets the dock forwards from LLaMA
- Synthesizes speech and sends audio packets back to the dock, which forwards them to the Outbound Audio Processor
- On
CUSTOM SHUTDOWNfrom the dock, joins worker threads and exits
| Variable | Description | Default |
|---|---|---|
WHISPERTALK_MODELS_DIR |
Path to models directory | Compile-time default or models/ |
ESPEAK_NG_DATA |
Path to espeak-ng-data directory | Auto-detected |
Three decoder backends were benchmarked:
| Backend | Avg Latency | Model Size | Device |
|---|---|---|---|
| TorchScript (CPU) | 365ms | 2296 MB | CPU |
| ONNX Runtime | 301ms | 1450 MB | CPU |
| CoreML Split (ANE) | 70ms | 321 MB | ANE |
CoreML Split is 5x faster and 7x smaller than TorchScript.
The Kokoro vocoder uses hn-nsf (harmonic-noise source filter) which requires complex number operations (torch.stft with complex output). CoreML does not support complex tensor operations. The solution splits the pipeline:
- HAR source (SineGen + STFT): Runs on CPU via TorchScript (~20KB models). Computes harmonic source from F0 predictions.
- Decoder-only (vocoder without source): Runs on ANE via CoreML. Takes pre-computed HAR source as input.
Three fixed-size decoder models handle different utterance lengths:
| Bucket | ASR Frames | F0 Frames | HAR Time | Max Duration |
|---|---|---|---|---|
| 3s | 72 | 144 | 8641 | ~3 seconds |
| 5s | 120 | 240 | 14401 | ~5 seconds |
| 10s | 240 | 480 | 28801 | ~10 seconds |
Inputs shorter than the bucket size are zero-padded; the waveform is trimmed to actual length.
Fixed 512-token input (padded with zeros, masked via attention_mask). Outputs:
pred_dur: Duration prediction per tokend: Duration encoder hidden states (for alignment)t_en: Text encoder outputs: Style vector (prosody)ref_s_out: Reference style passthrough
The repeat_interleave operation (mapping token durations to frame-level features) is data-dependent and cannot be compiled into CoreML. It runs on CPU in C++ using a simple loop that repeats each text encoder column by its predicted duration.
Two backends are available, selected by --g2p:
espeak(default fallback): espeak-ng C API with German voice (de). Thread-safe via mutex. Results cached (up to 10,000 entries with clear-all eviction).neural: DeepPhonemizer German G2P model (de_g2p.mlmodelc) loaded from$WHISPERTALK_MODELS_DIR/g2p/. Produces more accurate IPA for German compound words, medical terms, and loanwords. Falls back to espeak-ng for non-German text.auto: uses neural G2P for German text (detected viadetect_german()), espeak-ng otherwise. If the neural G2P model is absent, silently falls back to espeak-ng.
The G2PBackend enum is defined in neural-g2p.h and shared across all engine services.
For multi-chunk responses, the ref_s_out tensor (256-dim float32 style vector) emitted by the duration model for chunk N is stored in CallContext::last_ref_s and injected as ref_s input to the duration model for chunk N+1. This ensures that the prosody context of a synthesized clause carries over to the next one, avoiding the "flat intonation reset" that occurred when each chunk was synthesized independently with a fresh voice-pack embedding lookup. No CoreML model re-export is required — the duration model already accepts ref_s as an external input tensor.
Each incoming call gets its own CallContext with a dedicated worker thread. Text packets are dispatched to the correct thread by call_id. Worker threads process text from a queue, synthesize audio, and send it downstream.
CALL_END signals terminate the worker thread and clean up resources for that call.
- If the dock disconnects (TCP close on port 13143), Kokoro retries the HELLO every 200 ms until accepted again.
- If LLaMA or OAP are down, the dock drops/queues frames — Kokoro is unaware and keeps its socket open.
- If the dock sends
CUSTOM SHUTDOWN, Kokoro exits cleanly viastd::_Exit(0)after joining workers. The frontend may restart it.
bin/models/kokoro-german/
├── coreml/
│ ├── kokoro_duration.mlmodelc # CoreML duration model (ANE)
│ └── coreml_config.json
├── decoder_variants/
│ ├── kokoro_decoder_split_3s.mlmodelc # CoreML decoder bucket (ANE)
│ ├── kokoro_decoder_split_5s.mlmodelc
│ ├── kokoro_decoder_split_10s.mlmodelc
│ ├── kokoro_har_3s.pt # HAR TorchScript (CPU)
│ ├── kokoro_har_5s.pt
│ ├── kokoro_har_10s.pt
│ └── split_config.json
├── df_eva_voice.bin # Voice embedding (raw float32)
├── dm_bernd_voice.bin
└── vocab.json # Phoneme-to-ID mapping
./bin/test_kokoro_cpp7 tests:
- espeak-ng initialization and German phonemization
- Vocab loading (114 entries)
- Phoneme encoding with UTF-8 support
- Voice pack loading (bin format, 512×256)
- CoreML duration model load and inference (65ms on ANE)
- CoreML split decoder benchmark (avg 71ms on ANE)
- Model size inventory
Measured on Apple Silicon (M-series):
- CoreML duration: 65ms (ANE)
- CoreML split decoder: avg 70ms, min 70ms (ANE)
- Phonemization: ~5ms (CPU, cached <1ms)
- HAR source: ~5ms (CPU, TorchScript)
- End-to-end per sentence: ~145ms
- macOS-only (CoreML requires Apple frameworks)
- Requires libtorch dynamic library at runtime (for HAR models and tensor operations)
- espeak-ng dynamic library at runtime
- Maximum utterance length is capped at 120 seconds of PCM buffer (dock-side limit). Individual decoder inference still uses the 3s/5s/10s buckets internally; longer utterances are concatenated chunk-by-chunk.
- Phoneme cache uses simple clear-all eviction (not LRU)