Skip to content

Voices

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 }));
Response (POST /v1/media/voices)
{
  "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

EndpointReturns
GET /v1/media/voicesA list of voices; filters and pages below
POST /v1/media/voices201, a cloned voice from a clip (multipart file, or JSON from)
POST /v1/media/voices/design202, 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}/audioThe reference clip as saved
GET /v1/media/voices/{id}/previewThe preview clip; 404 preview_not_ready until made
POST /v1/media/voices/{id}/copy201, your own private copy of a voice you can see

The voice object

FieldTypeValue
idstringvo_ and 22 random characters; a built-in voice keeps its name (af_heart)
objectstringvoice
name, descriptionstring1–100 and 0–500 characters
categorystringcloned (from a clip), designed (from a description), builtin (a model's own voice)
visibilitystringprivate, members or public (sharing)
ownerstringyou for a voice of the key's account, albastov for every other voice
statusstringready; pending while a designed voice is made; failed with error
errorstring or nullWhy a designed voice failed
language, gender, accentstringFree text set by the voice's account; language as a code (en, en-gb)
labelsobjectUp to 10 string keys and string values ({"use": "narration"})
duration_snumber or nullLength of the clip; null for a WebM clip
transcriptstringWhat the clip says; the worker writes one on first use when empty
preview_urlstring or nullA short line in the voice, made on the default model; null until made
preview_jobstring or nullThe job making the preview (or the design)
audio_urlstring or nullThe reference clip; null for a built-in or pending voice
default_modelstringThe model a request without model uses (omnivoice today)
modelsarrayEvery model that speaks the voice, recommended first
usage_countintegerJobs submitted with the voice
created_at, updated_atstring or nullRFC 3339 times; null for a built-in voice
consentstringPresent 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"
Output
vo_s683xywhmvVR5pbv2tJNtn ready

List

GET /v1/media/voices answers {"object": "list", "data": [...], "has_more", "next_cursor"}, newest first.

ParameterValue
scopeall (default): your voices and every shared one; mine; shared: other accounts' members and public voices
modelOnly voices the model speaks; the model's built-in voices come first on the first page and do not count toward limit
categorycloned, designed or builtin (builtin needs model)
languageen matches en and en-*
qText in the name or description, any case
limit1–100, default 20
cursornext_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"
Response
{
  "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.

modelvoice
left outvo_... required; runs the voice's default_model
omnivoice, kokorooptional; the model's default voice when left out
qwen3-tts and the other clone-only modelsrequired (422 voice_required without)
kokoroa 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

visibilityWho sees and uses the voice
privateThe voice's own account (its keys and session)
membersEvery albastov.com account and key with media
publicMembers, 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.

StatuscodeWhen
400bad-paramA field fails validation; param names the field (file, name, labels, limit, cursor)
403visibility_owner_onlyAn account other than the owner's sets members or public
404voice_not_foundNo such voice, or a voice the key cannot see; a private voice of another account is never revealed
404preview_not_readyGET /v1/media/voices/{id}/preview before the preview is made
409voice_not_readyA designed voice still pending, or failed; status says which
413too-largeA clip over the size limit
415not-audio, multipart-or-jsonThe file is not WAV, MP3, M4A, Ogg, WebM or FLAC; the body is neither multipart nor JSON
422voice_requiredA clone-only model without voice
422voice_not_supportedThe model cannot speak a saved voice; models lists the models that can
422consent_requiredSharing a cloned voice without a consent note; param is consent
422voice_quotaThe 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\"}}"
422
{
  "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.
  • name 100 characters, description 500, transcript 5,000, consent 2,000, labels 10 keys.
  • text of 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_count counts submitted jobs, the preview excluded, and never goes down.

Examples

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} with scope, model, category, language, q, limit, cursor; visibility members and public replace published; design, copy, preview and from; model optional with vo_...; codes voice_not_found, voice_not_supported, voice_required, voice_not_ready, visibility_owner_only, consent_required, voice_quota.