Skip to main content
Version: 0.11

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:

FieldTypeDefaultDescription
modelstringrequiredModel id, e.g. in-memory::tts
inputstringrequiredText to synthesize
voicestringaf_heartVoice identifier (see Voices)
response_formatstringwavOutput format — wav, pcm, flac, or mp3
speedf321.0Speech-rate multiplier
streamboolfalseIf 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.

LanguageFemaleMale
en-US (a)af_alloy, af_aoede, af_bella, af_heart, af_jessica, af_kore, af_nicole, af_nova, af_river, af_sarah, af_skyam_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_lilybm_daniel, bm_fable, bm_george, bm_lewis
Spanish (e)ef_doraem_alex, em_santa
French (f)ff_siwis
Hindi (h)hf_alpha, hf_betahm_omega, hm_psi
Italian (i)if_saraim_nicola
Japanese (j)jf_alpha, jf_gongitsune, jf_nezumi, jf_tebukurojm_kumo
Portuguese-BR (p)pf_dorapm_alex, pm_santa
Chinese (z)zf_xiaobei, zf_xiaoni, zf_xiaoxiao, zf_xiaoyizm_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