Text-to-Speech
Atelico synthesizes speech entirely on-device with Kokoro 82M — a compact, high-quality TTS model with 54 voices across 9 languages. There are no API keys, no network round-trips, and no per-character billing: you ship the model with your game and call it in-process or over the local HTTP server.
TTS is wired into the in-memory:: backend and exposed over the OpenAI-compatible
route POST /v1/audio/speech, as well as through every native SDK.
:::tip Lip-sync TTS output pairs naturally with Audio-to-Face (A2F), which turns a PCM waveform into 52-channel ARKit blendshape weights for driving a talking-head rig. Synthesize a line, then feed the same audio to A2F to animate the speaker's mouth — no animator in the loop. :::
Quick start
curl http://localhost:11434/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "in-memory::tts",
"input": "Hello from Atelico.",
"voice": "af_heart"
}' \
--output hello.wav
The response is a WAV file (PCM, 24 kHz). The model id in-memory::tts resolves
to Kokoro 82M; kokoro and kokoro-82m are accepted aliases. Unrecognised ids
return a 5xx with a clear error string.
The request
The request body deserializes into the AudioSpeechRequest struct in
atelico-audio/src/audio_types.rs. The real serde field names are:
| Field | Type | Default | Description |
|---|---|---|---|
model | string | required | Model id, e.g. in-memory::tts |
input | string | required | Text to synthesize |
voice | string | af_heart | Voice identifier (see Voices) |
response_format | string | wav | Output format — wav, pcm, flac, or mp3 |
speed | f32 | 1.0 | Speech-rate multiplier |
stream | bool | false | If true, emit one chunk per sentence |
Any additional JSON keys are captured in a flattened extra map and ignored by
models that don't recognise them, so forward-compatible clients won't break on
unknown fields.
Voices
Kokoro voice IDs encode language and gender as <lang><gender>_<name>. For
example af_heart is en-US female "Heart", bm_lewis is en-GB male "Lewis",
and jf_alpha is Japanese female "Alpha". The default voice is af_heart.
| Language | Female | Male |
|---|---|---|
en-US (a) | af_alloy, af_aoede, af_bella, af_heart, af_jessica, af_kore, af_nicole, af_nova, af_river, af_sarah, af_sky | am_adam, am_echo, am_eric, am_fenrir, am_liam, am_michael, am_onyx, am_puck, am_santa |
en-GB (b) | bf_alice, bf_emma, bf_isabella, bf_lily | bm_daniel, bm_fable, bm_george, bm_lewis |
Spanish (e) | ef_dora | em_alex, em_santa |
French (f) | ff_siwis | — |
Hindi (h) | hf_alpha, hf_beta | hm_omega, hm_psi |
Italian (i) | if_sara | im_nicola |
Japanese (j) | jf_alpha, jf_gongitsune, jf_nezumi, jf_tebukuro | jm_kumo |
Portuguese-BR (p) | pf_dora | pm_alex, pm_santa |
Chinese (z) | zf_xiaobei, zf_xiaoni, zf_xiaoxiao, zf_xiaoyi | zm_yunjian, zm_yunxi, zm_yunxia, zm_yunyang |
That is 54 voices across 9 language families. You can also enumerate the
available voices at runtime via GET /v1/models (the engine emits one model id
per available voice when probed) or by browsing the voices/ directory inside
the cached hexgrad/Kokoro-82M asset.
Output formats
The response_format field accepts wav (default), pcm, flac, and mp3.
WAV is the most broadly compatible: every native SDK decodes the RIFF header to
recover the sample rate, so callers don't need to pass a separate sample_rate
field back into their audio backend.
Streaming synthesis
Set "stream": true to receive one chunk per sentence as it is synthesized,
rather than waiting for the whole clip. This drops the time-to-first-audio for
long passages dramatically — the first sentence starts playing while the rest is
still being generated.
curl -N http://localhost:11434/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{
"model": "in-memory::tts",
"input": "First sentence. Second one comes right after.",
"voice": "af_heart",
"stream": true
}'
Each chunk is an AudioSpeechChunk:
{
"sequence": 0,
"audio": "<base64 WAV bytes>",
"duration_seconds": 1.42,
"text": "First sentence.",
"format": "wav",
"sample_rate": 24000
}
sequence lets you reassemble chunks in order if your transport reorders them;
text is the source sentence that produced the chunk, handy for caption sync.
SDK usage (in-process)
Audio bytes cross the FFI boundary as base64-encoded WAV files. Each binding
decodes the WAV header to recover the sample rate, so callers don't pass a
separate sample_rate field.
Rust SDK
use atelico_sdk::{Engine, AudioSpeechRequest};
use futures::StreamExt; // for the async stream example
let engine = Engine::new()?;
// Blocking synthesis — full clip in one call.
let request = AudioSpeechRequest {
model: "in-memory::tts".into(),
input: "Hello from Atelico.".into(),
voice: "af_heart".into(),
..Default::default()
};
let response = engine.audio().synthesize_sync(request)?;
// response.audio_data: Vec<u8> (WAV bytes), response.duration_seconds,
// response.format, response.sample_rate
std::fs::write("hello.wav", &response.audio_data)?;
// Streaming synthesis — async, one AudioSpeechChunk per sentence.
let stream_request = AudioSpeechRequest {
model: "in-memory::tts".into(),
input: "First sentence. Second one comes right after.".into(),
voice: "af_heart".into(),
stream: true,
..Default::default()
};
let mut stream = engine.audio().synthesize_stream(stream_request).await?;
while let Some(chunk) = stream.next().await? {
println!("[{}] {}", chunk.sequence, chunk.text);
// chunk.audio is a base64 WAV string — decode and play it immediately.
}
synthesize_stream returns a StreamHandle<AudioSpeechChunk> you can drive via
next() (async), next_blocking() (sync), or poll() (game-loop frame
integration — returns immediately whether or not a chunk is ready). The blocking
synthesize_sync also has an async sibling synthesize and a one-shot
synthesize_async that hands back a StreamHandle<AudioSpeechResponse>.
Python
import base64, json
from atelico import Engine
engine = Engine()
# Blocking synthesis — returns the JSON wrapper as a string.
resp = json.loads(engine.audio_synthesize(json.dumps({
"model": "in-memory::tts",
"input": "Hello from Atelico.",
"voice": "af_heart",
})))
# resp: {"audio_b64": "...", "duration_seconds": .., "format": "wav", "sample_rate": 24000}
open("hello.wav", "wb").write(base64.b64decode(resp["audio_b64"]))
# Streaming synthesis — yields one AudioSpeechChunk JSON per sentence.
stream = engine.audio_synthesize_stream(json.dumps({
"model": "in-memory::tts",
"input": "First sentence. Second one comes right after.",
"voice": "af_heart",
}))
for chunk_json in stream:
chunk = json.loads(chunk_json)
wav_bytes = base64.b64decode(chunk["audio"])
# play wav_bytes through your audio backend immediately
C / FFI
// Blocking TTS — the response_json wrapper carries audio_b64.
const char* request =
"{\"model\":\"in-memory::tts\",\"input\":\"Hello.\",\"voice\":\"af_heart\"}";
const char* response_json = NULL;
atelico_audio_synthesize(engine, request, &response_json);
// response_json: {"audio_b64":"UklGRn...","duration_seconds":1.42,
// "format":"wav","sample_rate":24000}
// Streaming TTS — start the stream, then poll each frame.
uint64_t stream = 0;
atelico_audio_synthesize_stream(engine,
"{\"model\":\"in-memory::tts\",\"input\":\"First. Second.\",\"voice\":\"af_heart\"}",
&stream);
const char* chunk_json = NULL;
while (1) {
int rc = atelico_stream_poll(engine, stream, &chunk_json);
if (rc == ATELICO_OK) {
// chunk_json: {"sequence":N,"audio":"<b64 WAV>","duration_seconds":..,
// "text":"..","format":"wav","sample_rate":24000}
} else if (rc == ATELICO_ERR_STREAM_DONE) { // -6
break;
}
// ATELICO_ERR_STREAM_EMPTY (-7): no chunk this frame, try again next tick.
}
atelico_stream_destroy(engine, stream);
The output pointer is owned by the FFI layer's thread-local return buffer — copy
it before the next API call on the same thread. The streaming API reuses the same
atelico_stream_poll / atelico_stream_destroy infrastructure as chat
completions.
Example: voice-acted NPC dialogue
A typical game pipeline chains chat → TTS — generate a line of dialogue, then speak it with a voice matched to the character:
# 1. Generate dialogue from a chat completion.
RESPONSE=$(curl -s http://localhost:11434/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "in-memory::meta-llama/Llama-3.2-3B-Instruct-Q4_K_M",
"messages": [
{"role": "system", "content": "You are a gruff dwarven blacksmith."},
{"role": "user", "content": "Greet the player."}
]
}' | jq -r '.choices[0].message.content')
# 2. Speak it, streaming so the first sentence plays immediately.
curl -sN http://localhost:11434/v1/audio/speech \
-H "Content-Type: application/json" \
-d "$(jq -n --arg input "$RESPONSE" '{
model: "in-memory::tts",
input: $input,
voice: "bm_lewis",
stream: true
}')"
For voice variety, pin one Kokoro voice per character so each NPC has a consistent identity across sessions.
See also
- Speech-to-Text — Whisper transcription, the inverse path
- Models — full supported-model catalogue
- Server Configuration — TTS-related env vars
- NPC Dialogue — end-to-end character pipeline