A voice is a saved reference clip, 3–60 s of one speaker, with an id vo_.... A cloning model speaks any text in a
saved voice: pass the id as voice in POST /v1/audio/speech or in a speech job, with or without model. Citadel
stores the clip in its bucket and makes a short preview in the voice when the voice is saved. Built-in voices of a
model (af_heart for kokoro) appear in the same list. The page as markdown for an agent:
GET https://citadel.albastov.com/v1/docs/audio/voices.md; every page: GET https://citadel.albastov.com/v1/docs/llms.txt.
Quick start
Save a voice from a clip, speak a line in the voice, then delete the voice. The clip here is synthesized by kokoro;
a recording of your own voice works the same way (cloning guide).
curl
curl -sS --fail-with-body https://citadel.albastov.com/v1/audio/speech \
-H "Authorization: Bearer $CITADEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "kokoro", "voice": "bf_emma", "response_format": "wav",
"input": "The harbour lights came on one by one as the ferry crossed the bay. A clean recording of ten to twenty seconds makes the best voice."}' \
--output reference.wav
VOICE=$(curl -sS --fail-with-body https://citadel.albastov.com/v1/media/voices \
-H "Authorization: Bearer $CITADEL_API_KEY" \
-F [email protected] -F name="Harbour narrator" -F language=en \
-F consent="Synthesized by kokoro, not a person's voice." \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["id"])')
curl -sS --fail-with-body https://citadel.albastov.com/v1/audio/speech \
-H "Authorization: Bearer $CITADEL_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"voice\": \"$VOICE\", \"input\": \"Any text, spoken in the saved voice.\"}" \
--output spoken.mp3
curl -sS --fail-with-body -X DELETE "https://citadel.albastov.com/v1/media/voices/$VOICE" \
-H "Authorization: Bearer $CITADEL_API_KEY"Python
import json
import os
import time
import urllib.request
BASE = "https://citadel.albastov.com/v1"
KEY = os.environ["CITADEL_API_KEY"]
def call(method, path, body=None, content_type="application/json"):
"""One API call; JSON in and out, raw bytes for audio. An HTTP error raises."""
data = json.dumps(body).encode() if content_type == "application/json" and body is not None else body
req = urllib.request.Request(BASE + path, data=data, method=method,
headers={"Authorization": f"Bearer {KEY}", "Content-Type": content_type})
with urllib.request.urlopen(req, timeout=120) as res:
raw = res.read()
return json.loads(raw) if res.headers.get_content_type() == "application/json" else raw
clip = call("POST", "/audio/speech", {
"model": "kokoro", "voice": "bf_emma", "response_format": "wav",
"input": "The harbour lights came on one by one as the ferry crossed the bay. "
"A clean recording of ten to twenty seconds makes the best voice.",
})
# Upload the clip as a raw body, then save the upload as a voice.
upload = call("POST", "/media/uploads", clip, content_type="audio/wav")
voice = call("POST", "/media/voices", {"from": upload["id"], "name": "Harbour narrator", "language": "en",
"consent": "Synthesized by kokoro, not a person's voice."})
print(voice["id"], voice["status"], voice["default_model"])
# The preview is made in the background; preview_url is null until then.
for _ in range(30):
voice = call("GET", f"/media/voices/{voice['id']}")
if voice["preview_url"]:
break
time.sleep(2)
print("preview:", voice["preview_url"])
spoken = call("POST", "/audio/speech", {"voice": voice["id"], "input": "Any text, spoken in the saved voice."})
open("spoken.mp3", "wb").write(spoken)
call("DELETE", f"/media/voices/{voice['id']}")JavaScript
const base = "https://citadel.albastov.com/v1";
const auth = { Authorization: `Bearer ${process.env.CITADEL_API_KEY}` };
const ok = async (res) => {
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res;
};
const clip = await ok(await fetch(`${base}/audio/speech`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({
model: "kokoro", voice: "bf_emma", response_format: "wav",
input: "The harbour lights came on one by one as the ferry crossed the bay. A clean recording of ten to twenty seconds makes the best voice.",
}),
}));
const form = new FormData();
form.append("file", await clip.blob(), "reference.wav");
form.append("name", "Harbour narrator");
form.append("consent", "Synthesized by kokoro, not a person's voice.");
const voice = await (await ok(await fetch(`${base}/media/voices`, { method: "POST", headers: auth, body: form }))).json();
console.log(voice.id, voice.status, voice.default_model);
const speech = await ok(await fetch(`${base}/audio/speech`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify({ voice: voice.id, input: "Any text, spoken in the saved voice." }),
}));
console.log((await speech.arrayBuffer()).byteLength, "bytes of", speech.headers.get("content-type"));
await ok(await fetch(`${base}/media/voices/${voice.id}`, { method: "DELETE", headers: auth }));{
"id": "vo_FLUWnoRjtFXyrBE8uWoTxL",
"object": "voice",
"name": "Harbour narrator",
"description": "",
"category": "cloned",
"visibility": "private",
"owner": "you",
"status": "ready",
"error": null,
"language": "en",
"gender": "",
"accent": "",
"labels": {},
"duration_s": 8.8,
"transcript": "",
"preview_url": null,
"preview_job": "mj_uyeqTjDrzLxNLPpBKuibEi",
"audio_url": "https://citadel.albastov.com/v1/media/voices/vo_FLUWnoRjtFXyrBE8uWoTxL/audio",
"default_model": "omnivoice",
"models": ["omnivoice", "breeze2", "higgs3", "qwen3-tts", "fireredtts3", "moss-local", "cosyvoice3", "indextts2.5",
"fish-s2pro", "miotts", "voxcpm2", "chatterbox"],
"usage_count": 0,
"created_at": "2026-11-02T04:03:16.434Z",
"updated_at": "2026-11-02T04:03:16.434Z",
"consent": "Synthesized by kokoro, not a person's voice."
}The key needs media:generate for writes and media:read for reads, and an account with media.
Reference
| Endpoint | Returns |
|---|---|
GET /v1/media/voices | A list of voices; filters and pages below |
POST /v1/media/voices | 201, a cloned voice from a clip (multipart file, or JSON from) |
POST /v1/media/voices/design | 202, a designed voice made from a text description, status pending |
GET /v1/media/voices/{id} | The voice |
PATCH /v1/media/voices/{id} | The changed voice (your own voices) |
DELETE /v1/media/voices/{id} | 204; the clip and preview are deleted (your own voices) |
GET /v1/media/voices/{id}/audio | The reference clip as saved |
GET /v1/media/voices/{id}/preview | The preview clip; 404 preview_not_ready until made |
POST /v1/media/voices/{id}/copy | 201, your own private copy of a voice you can see |
The voice object
| Field | Type | Value |
|---|---|---|
id | string | vo_ and 22 random characters; a built-in voice keeps its name (af_heart) |
object | string | voice |
name, description | string | 1–100 and 0–500 characters |
category | string | cloned (from a clip), designed (from a description), builtin (a model's own voice) |
visibility | string | private, members or public (sharing) |
owner | string | you for a voice of the key's account, albastov for every other voice |
status | string | ready; pending while a designed voice is made; failed with error |
error | string or null | Why a designed voice failed |
language, gender, accent | string | Free text set by the voice's account; language as a code (en, en-gb) |
labels | object | Up to 10 string keys and string values ({"use": "narration"}) |
duration_s | number or null | Length of the clip; null for a WebM clip |
transcript | string | What the clip says; the worker writes one on first use when empty |
preview_url | string or null | A short line in the voice, made on the default model; null until made |
preview_job | string or null | The job making the preview (or the design) |
audio_url | string or null | The reference clip; null for a built-in or pending voice |
default_model | string | The model a request without model uses (omnivoice today) |
models | array | Every model that speaks the voice, recommended first |
usage_count | integer | Jobs submitted with the voice |
created_at, updated_at | string or null | RFC 3339 times; null for a built-in voice |
consent | string | Present on your own voices only: the consent note |
Create from a clip
POST /v1/media/voices takes multipart: file (WAV, MP3, M4A, Ogg, WebM or FLAC; 3–60 s; 20 MB at most), name,
and optional description, language, gender, accent, labels (a JSON object as text), transcript and
consent. Citadel reads the length from the file's header and refuses a file outside 3–60 s or without a length in
its header; WebM is accepted with duration_s null, and the worker uses the first 60 s.
curl reads ; in a -F value as a field separator: a consent, description or transcript with ; goes in
quotes inside the value (-F 'consent="a; b"').
A clip already on Citadel needs no second upload: send JSON with from instead of file. from is an upload of
yours (up_..., from POST /v1/media/uploads) or a result of your own job (job:<id>/<i>).
curl
JOB=$(curl -sS --fail-with-body "https://citadel.albastov.com/v1/media/jobs?wait=60" \
-H "Authorization: Bearer $CITADEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"kind": "speech", "model": "kokoro", "params": {"voice": "am_michael", "format": "wav",
"text": "Every station on the line has a clock, a bench and a map of the city in faded blue."}}' \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["id"])')
VOICE=$(curl -sS --fail-with-body https://citadel.albastov.com/v1/media/voices \
-H "Authorization: Bearer $CITADEL_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"from\": \"job:$JOB/0\", \"name\": \"Station announcer\", \"labels\": {\"use\": \"announcements\"}}" \
| python3 -c 'import json, sys; v = json.load(sys.stdin); print(v["id"])')
curl -sS --fail-with-body -X DELETE "https://citadel.albastov.com/v1/media/voices/$VOICE" \
-H "Authorization: Bearer $CITADEL_API_KEY"A browser uploads a recording with POST /v1/media/uploads (raw body, Content-Type: audio/wav) and then creates
the voice with from. A recording encoded as WAV carries its length; a WebM recording does not.
Design from a description
POST /v1/media/voices/design with {name, description, model?, text?, language?} answers 202 with a voice of
category designed and status pending. omnivoice (the default model) speaks text in a voice made from
description; without text, a fixed line of about 15 s. When the job is done, the clip becomes the voice's
reference clip and preview and status turns ready; a failed job sets status failed and error. Poll
GET /v1/media/voices/{id} until status is not pending. A designed voice is synthetic: no real speaker.
curl
VOICE=$(curl -sS --fail-with-body https://citadel.albastov.com/v1/media/voices/design \
-H "Authorization: Bearer $CITADEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Night radio", "description": "male, middle-aged, low pitch, slow and warm"}' \
| python3 -c 'import json, sys; print(json.load(sys.stdin)["id"])')
for i in $(seq 60); do
STATUS=$(curl -sS --fail-with-body "https://citadel.albastov.com/v1/media/voices/$VOICE" \
-H "Authorization: Bearer $CITADEL_API_KEY" | python3 -c 'import json, sys; print(json.load(sys.stdin)["status"])')
[ "$STATUS" != pending ] && break
sleep 2
done
echo "$VOICE $STATUS"
curl -sS --fail-with-body -X DELETE "https://citadel.albastov.com/v1/media/voices/$VOICE" \
-H "Authorization: Bearer $CITADEL_API_KEY"vo_s683xywhmvVR5pbv2tJNtn readyList
GET /v1/media/voices answers {"object": "list", "data": [...], "has_more", "next_cursor"}, newest first.
| Parameter | Value |
|---|---|
scope | all (default): your voices and every shared one; mine; shared: other accounts' members and public voices |
model | Only voices the model speaks; the model's built-in voices come first on the first page and do not count toward limit |
category | cloned, designed or builtin (builtin needs model) |
language | en matches en and en-* |
q | Text in the name or description, any case |
limit | 1–100, default 20 |
cursor | next_cursor of the page before |
One call fills a voice picker for a model: GET /v1/media/voices?model=omnivoice lists every saved voice the key may
use with omnivoice, and ?model=kokoro lists the 54 built-in voices of kokoro.
curl
curl -sS --fail-with-body "https://citadel.albastov.com/v1/media/voices?model=kokoro&language=en-gb&limit=5" \
-H "Authorization: Bearer $CITADEL_API_KEY"{
"object": "list",
"data": [
{"id": "bf_alice", "object": "voice", "name": "bf_alice", "category": "builtin", "visibility": "public",
"owner": "albastov", "status": "ready", "language": "en-gb", "gender": "female", "models": ["kokoro"],
"default_model": "kokoro", "preview_url": null, "audio_url": null, "usage_count": 0, "created_at": null}
],
"has_more": false,
"next_cursor": null
}The response above is shortened to one of the eight voices and leaves out the empty fields.
Python
import json
import os
import urllib.parse
import urllib.request
BASE = "https://citadel.albastov.com/v1"
KEY = os.environ["CITADEL_API_KEY"]
params = {"scope": "all", "limit": 100}
while True:
req = urllib.request.Request(f"{BASE}/media/voices?{urllib.parse.urlencode(params)}",
headers={"Authorization": f"Bearer {KEY}"})
with urllib.request.urlopen(req, timeout=30) as res:
page = json.load(res)
for v in page["data"]:
print(v["id"], v["category"], v["owner"], v["visibility"], v["default_model"])
if not page["has_more"]:
break
params["cursor"] = page["next_cursor"]Speak in a voice
voice in POST /v1/audio/speech and in a speech job takes a saved voice vo_..., a built-in voice name, an upload
up_..., a job result job:<id>/<i> or an https:// URL. With vo_..., model is optional: the voice's
default_model runs. Every model in the voice's models speaks the voice.
model | voice |
|---|---|
| left out | vo_... required; runs the voice's default_model |
omnivoice, kokoro | optional; the model's default voice when left out |
qwen3-tts and the other clone-only models | required (422 voice_required without) |
kokoro | a built-in name only (422 voice_not_supported for vo_...) |
curl
VOICE=$(curl -sS --fail-with-body "https://citadel.albastov.com/v1/media/voices?category=designed&limit=1" \
-H "Authorization: Bearer $CITADEL_API_KEY" | python3 -c 'import json, sys; print(json.load(sys.stdin)["data"][0]["id"])')
curl -sS --fail-with-body "https://citadel.albastov.com/v1/media/jobs?wait=60" \
-H "Authorization: Bearer $CITADEL_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"kind\": \"speech\", \"model\": \"higgs3\", \"params\": {\"text\": \"The same voice on another model.\", \"voice\": \"$VOICE\"}}"Sharing
visibility | Who sees and uses the voice |
|---|---|
private | The voice's own account (its keys and session) |
members | Every albastov.com account and key with media |
public | Members, and the public text-to-speech API once it opens |
Only the owner account of albastov.com sets members or public: every other account gets 403 visibility_owner_only. A cloned voice is a person's voice: sharing a cloned voice needs a non-empty consent note
(422 consent_required), and the note stays on the voice's own account. A designed voice shares without a note.
Visitors on genai.albastov.com never use saved voices or cloning models.
curl
curl -sS --fail-with-body -X PATCH "https://citadel.albastov.com/v1/media/voices/$VOICE" \
-H "Authorization: Bearer $CITADEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"visibility": "members", "consent": "Recorded by the speaker for albastov.com members, 2026-11-02."}'The example above needs a key of the owner account; the example checker skips it.
Copy and delete
POST /v1/media/voices/{id}/copy with an optional {"name"} makes your own private copy of a voice you can see,
for example a shared one: a new id, the clip and preview copied, the category kept, the consent note left behind.
The copy counts toward your voice quota and is yours to rename and delete. DELETE /v1/media/voices/{id} answers
204 and deletes the clip and preview; a job already queued with the voice keeps its own copy of the clip.
curl
SOURCE=$(curl -sS --fail-with-body "https://citadel.albastov.com/v1/media/voices?category=designed&limit=1" \
-H "Authorization: Bearer $CITADEL_API_KEY" | python3 -c 'import json, sys; print(json.load(sys.stdin)["data"][0]["id"])')
COPY=$(curl -sS --fail-with-body -X POST "https://citadel.albastov.com/v1/media/voices/$SOURCE/copy" \
-H "Authorization: Bearer $CITADEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "My copy"}' | python3 -c 'import json, sys; v = json.load(sys.stdin); print(v["id"])')
curl -sS --fail-with-body -X PATCH "https://citadel.albastov.com/v1/media/voices/$COPY" \
-H "Authorization: Bearer $CITADEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{"description": "Renamed copy for chapter readings", "labels": {"book": "harbour"}}'
curl -sS --fail-with-body -X DELETE "https://citadel.albastov.com/v1/media/voices/$COPY" \
-H "Authorization: Bearer $CITADEL_API_KEY"Errors
The media routes answer {"ok": false, "error": "voice-not-found", "code": "voice_not_found", "message", "param"};
POST /v1/audio/speech answers the OpenAI shape {"error": {"code", "message", "type", "param"}} with the same code.
Show message to a person; branch on code.
| Status | code | When |
|---|---|---|
| 400 | bad-param | A field fails validation; param names the field (file, name, labels, limit, cursor) |
| 403 | visibility_owner_only | An account other than the owner's sets members or public |
| 404 | voice_not_found | No such voice, or a voice the key cannot see; a private voice of another account is never revealed |
| 404 | preview_not_ready | GET /v1/media/voices/{id}/preview before the preview is made |
| 409 | voice_not_ready | A designed voice still pending, or failed; status says which |
| 413 | too-large | A clip over the size limit |
| 415 | not-audio, multipart-or-json | The file is not WAV, MP3, M4A, Ogg, WebM or FLAC; the body is neither multipart nor JSON |
| 422 | voice_required | A clone-only model without voice |
| 422 | voice_not_supported | The model cannot speak a saved voice; models lists the models that can |
| 422 | consent_required | Sharing a cloned voice without a consent note; param is consent |
| 422 | voice_quota | The account already keeps quota voices |
curl
VOICE=$(curl -sS --fail-with-body "https://citadel.albastov.com/v1/media/voices?category=designed&limit=1" \
-H "Authorization: Bearer $CITADEL_API_KEY" | python3 -c 'import json, sys; print(json.load(sys.stdin)["data"][0]["id"])')
curl -sS --fail-with-body https://citadel.albastov.com/v1/media/jobs \
-H "Authorization: Bearer $CITADEL_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"kind\": \"speech\", \"model\": \"kokoro\", \"params\": {\"text\": \"Hello.\", \"voice\": \"$VOICE\"}}"{
"ok": false,
"error": "voice-not-supported",
"code": "voice_not_supported",
"param": "voice",
"message": "kokoro cannot speak a saved voice; use one of: omnivoice, breeze2, higgs3, qwen3-tts, fireredtts3, moss-local, cosyvoice3, indextts2.5, fish-s2pro, miotts, voxcpm2, chatterbox.",
"models": ["omnivoice", "breeze2", "higgs3", "qwen3-tts", "fireredtts3", "moss-local", "cosyvoice3", "indextts2.5", "fish-s2pro", "miotts", "voxcpm2", "chatterbox"]
}Limits
- Clip: 3–60 s, 20 MB at most (an owner setting, never above 20 MB).
- Voices per account: 30 (an owner setting); the owner account has no limit. A copy and a designed voice count.
name100 characters,description500,transcript5,000,consent2,000,labels10 keys.textof a design: 1,000 characters.limit: 1–100.- The preview runs on the voice's account and is never charged. A design job is a speech job on your account.
usage_countcounts submitted jobs, the preview excluded, and never goes down.
Examples
- Save a voice and speak with it: curl, Python, JavaScript
- A voice from a job result
- Design a voice from a description
- Every voice, page by page
- The same voice on another model
- Copy, rename and delete
Changelog
- 2026-11-02: Voices v2. The voice object (
object,category,owner,status,models,default_model,preview_url,usage_count, metadata and labels); list as{object, data, has_more, next_cursor}withscope,model,category,language,q,limit,cursor; visibilitymembersandpublicreplacepublished; design, copy, preview andfrom;modeloptional withvo_...; codesvoice_not_found,voice_not_supported,voice_required,voice_not_ready,visibility_owner_only,consent_required,voice_quota.