Last updated: 2026-09-09
POST /dev/tune takes a short reference clip and returns speech in a voice tuned toward it, or the voice pack itself. Backed by inno-kokoro and its weights.
It is a tuner, not a cloner. The stock model is steered toward the reference's timbre, pitch, pitch range, and speaking rate. Expect a voice in the same neighbourhood, not a match. English only. Only tune voices you have permission to use.
Off by default, ENABLE_INNO_TUNER=true turns it on.
The web player has a Tune tab with the same controls.
Multipart form. audio is the only required field.
| Field | Default | |
|---|---|---|
audio |
required | Reference clip, any format libsndfile reads (wav, flac, ogg, mp3). 3 to 30 s, one speaker, mono or stereo, 8 to 96 kHz, 10 MB cap. Only the first 30 s are decoded |
request |
unset | JSON with the same fields as the /v1/audio/speech body, minus voice (input, response_format, speed, stream, lang_code, normalization_options, return_download_link, etc). When set, the clip's voice speaks it |
prosody_head |
true |
Apply the prosody head. false uses the plain stock blend, closer to a stock voice |
fmax |
auto | Pitch tracking ceiling in Hz, 60 to 1000. Unset, the tuner picks one from the clip's harmonics. Set it if a band-limited clip reads an octave high |
return_voice_pack |
false |
Return the tuned .pt instead of audio |
save_voice |
unset | Keep the pack as VOICES_DIR/<name>_tuned.pt. af_, am_, bf_, or bm_ plus lowercase letters, digits, single underscores. Needs ALLOW_LOCAL_VOICE_SAVING=true |
allow_voice_tags and ssml inside request are ignored. The reference clip is the voice.
| Call | Response |
|---|---|
request set |
Audio, same shape and headers as /v1/audio/speech (streamed by default, X-Download-Path with return_download_link) |
return_voice_pack=true |
The [510, 1, 256] pack as <name>_tuned.pt (a_tune.pt unnamed), wins over request |
save_voice only |
{"voice": "<name>_tuned"} |
Packs live in the OS temp dir for the length of the response and are removed after, along with their cached tensor. Nothing is kept unless save_voice is set.
curl -s http://localhost:8880/dev/tune -F audio=@ref.wav -F 'request={"input":"Hello there."}' -o out.mp3
curl -s http://localhost:8880/dev/tune -F audio=@ref.wav -F return_voice_pack=true -o ref.pt
curl -s http://localhost:8880/dev/tune -F audio=@ref.wav -F save_voice=am_ref # saves am_ref_tunedEvery voice is a flat <accent><gender>_<name> file in VOICES_DIR, a suffix says where it came from:
| Example | ||
|---|---|---|
| stock | af_heart |
from the model card |
| shipped tuned | am_price_inno |
four tuned voices bundled with the server: af_amelia_inno, af_goodall_inno, am_price_inno, bm_atten_inno |
| saved by you | am_ref_tuned |
save_voice=am_ref, the suffix is added |
| in flight | a_tune_<hex> |
the transient pack while a /dev/tune request runs, never listed |
save_voice must match ^[ab][a-z]?_[a-z0-9]+(_[a-z0-9]+)*$ (uppercase is lowered first; the first letter picks US or UK English, the optional second letter is naming convention only, the web player uses x) and gets _tuned appended, else 400. Hyphens are out because - is a combine operator. An existing name is a 409, delete the file to replace it.
A saved voice is a plain voice. It works anywhere a voice name does (voice, combine syntax, [voice:] tags, SSML) and shows up in /v1/audio/voices. The language code comes from the first letter, a American, b British.
Off by default. With ALLOW_LOCAL_VOICE_SAVING=true any caller can write into VOICES_DIR, so keep it off on a shared host.
| Variable | Default | |
|---|---|---|
ENABLE_INNO_TUNER |
false |
Load the tuner at startup and expose the route, otherwise 403 |
ALLOW_LOCAL_VOICE_SAVING |
false |
Allow save_voice |
VOICES_DIR |
/app/api/src/voices/v1_0 |
Where saved packs go |
HF_HUB_OFFLINE / HF_HUB_DISABLE_TELEMETRY |
unset | Either one skips the startup version check below |
docker/scripts/download_model.py fetches the pinned tuner weights into MODEL_DIR/v1_0/inno_tuner/ next to the Kokoro weights. The Docker images ship with them.
On load the package does one GET of the weights' config.json on Hugging Face and logs a line if newer weights exist. HF_HUB_OFFLINE=1 skips it.
If the weights are missing or fail to load, the server still starts, logs Inno voice tuner not loaded, and the route answers 503.
| Status | |
|---|---|
| 400 | Clip under 3 s, over 2 channels, outside 8 to 96 kHz, unreadable audio, bad request JSON, save_voice off the naming rule, nothing to do (no request, return_voice_pack, or save_voice) |
| 403 | ENABLE_INNO_TUNER=false, or save_voice without ALLOW_LOCAL_VOICE_SAVING |
| 409 | <name> already exists |
| 413 | Clip over 10 MB |
| 503 | Tuner not loaded, or busy with another clip |
A Kokoro voice pack is 510 rows of 256 floats: the first 128 drive the decoder (timbre), the last 128 drive the predictor (prosody).
- Timbre: a speaker embedding of the clip goes through a learned style head, plus a shift along a learned direction for the clip's spectral tilt.
- Prosody: the stock packs' predictor halves are blended by least squares to the clip's pitch mean, pitch spread, and syllable rate. The prosody head then nudges the blend from the same measurements.
Enrollment takes well under a second on a GPU, a few seconds on CPU. Clips shorter than 5 s, or unusually dark or bright ones (archival, heavy filtering), tune less reliably.