Listing TTS voices and getting an MP3 back, and why voice ids don't travel
One GET lists every servable voice, one POST returns audio bytes — but a voice id is pinned to one model, and the container you ask for isn't always what arrives.
Two calls. GET /v1/ai/tts/voices returns the catalogue of voices Infrai can currently serve, and POST /v1/audio/speech returns raw audio bytes you redirect into a file. On the second question — no, voice ids are not universal. Each one is tagged with the vendor and the specific model it works with, and crossing those wires returns a 400 rather than a substitute voice.
That’s stricter than the OpenAI-compatible shape suggests, and it’s deliberate: a voice is a vendor asset, not a portable identifier. Below is the catalogue we read on 2026-07-26, the exact mismatch error, and the container gotcha that will bite anyone who trusts response_format blindly.
Read the catalogue first
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/ai/tts/voices" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "vendor": "cosyvoice", "voice_id": "Cherry", "is_default": true, "name": "Cherry", "language": "zh-CN", "languages": ["Chinese (Mandarin)", "English", "French", "German", "Japanese", "Korean"], "model": "qwen-tts" },
{ "vendor": "cosyvoice", "voice_id": "Ethan", "is_default": false, "name": "Ethan", "languages": ["Chinese (Mandarin)", "English"], "model": "qwen-tts" },
{ "vendor": "tencent_tts", "voice_id": "502007", "is_default": false, "name": "智小虎", "languages": ["Chinese", "English"], "model": "1" },
{ "vendor": "elevenlabs", "voice_id": "21m00Tcm4TlvDq8ikWAM", "is_default": true, "name": "Rachel", "model": "eleven_multilingual_v2" }
],
"next_cursor": null,
"count": 97
}
}
Ninety-seven voices across three vendors that day — 48 from cosyvoice, 48 from tencent_tts and a single ElevenLabs voice. Look at the shape of the ids and the answer to the portability question is already visible: Ethan, 502007 and 21m00Tcm4TlvDq8ikWAM are three vendors’ naming schemes, not three entries in one namespace.
Filter when you only care about one vendor.
curl -sS "https://api.infrai.cc/v1/ai/tts/voices?vendor=elevenlabs" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The read is free and rate-limited only, so cache it for an hour rather than shipping a hardcoded voice list that goes stale when a vendor key rotates.
Generate the audio
Pass the voice’s own model straight through from the catalogue entry. That single rule prevents almost every error in this API.
curl -sS -X POST "https://api.infrai.cc/v1/audio/speech" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"vendor": "elevenlabs",
"model": "eleven_multilingual_v2",
"voice": "21m00Tcm4TlvDq8ikWAM",
"input": "Your invoice for July is ready to download.",
"response_format": "mp3"
}' \
--output invoice-notice.mp3
file invoice-notice.mp3
That returns Content-Type: audio/mpeg and a file starting with an ID3 tag — a genuine MP3, about 22 KB for that sentence. Omit vendor and voice entirely and you get the platform default voice instead, which is the right call when you don’t care who speaks.
The mismatch, in full
Here’s the answer to the portability question as the API states it. Ask cosyvoice to use an ElevenLabs voice:
{
"error": {
"message": "voice '21m00Tcm4TlvDq8ikWAM' does not belong to vendor 'cosyvoice' (known vendor(s): elevenlabs)",
"type": "invalid_request_error",
"code": "VOICE_MODEL_MISMATCH",
"param": null
}
}
It’s tighter than vendor-level, too. Ethan is a cosyvoice voice, and cosyvoice serves two models — qwen-tts and the cheaper qwen3-tts-flash — but asking for Ethan on the flash model fails:
{
"error": {
"message": "voice 'Ethan' (vendor 'cosyvoice') is not compatible with model 'qwen3-tts-flash' (compatible model(s): qwen-tts)",
"type": "invalid_request_error",
"code": "VOICE_MODEL_MISMATCH",
"param": null
}
}
So the mental model is: a voice belongs to one model, that model belongs to one vendor, and the catalogue tells you both. One more consequence — don’t pair model: "auto" with a named voice. Automatic routing may land on a vendor that has never heard of it.
What it costs, and the container caveat
curl -sS "https://api.infrai.cc/v1/ai/models?capability=tts&available=true" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"object": "list",
"capability": "tts",
"count": 2,
"data": [
{ "id": "qwen-tts", "owned_by": "cosyvoice", "price_usd": 0.009, "unit": "per_1k_char" },
{ "id": "qwen3-tts-flash", "owned_by": "cosyvoice", "price_usd": 0.006, "unit": "per_1k_char" }
]
}
Verified 2026-07-26: $0.009 per 1,000 characters on qwen-tts and $0.006 on qwen3-tts-flash, billed per character rather than per call — a 2,000-word article is roughly 12,000 characters, so call it eleven cents at the higher rate. Speech rates have been falling and vendors discount aggressively, so read that endpoint rather than this paragraph.
Now the caveat we promised. response_format: "mp3" is a request, not a guarantee. On cosyvoice the same call came back with Content-Type: audio/wav and a RIFF header, while ElevenLabs honoured the MP3 request. If your player, CDN or podcast pipeline needs a specific container, check what actually arrived:
import { writeFile } from "node:fs/promises";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
async function speak({ input, voiceName = "Rachel", format = "mp3" }) {
const list = await fetch("https://api.infrai.cc/v1/ai/tts/voices", {
method: "GET",
headers: { Authorization: `Bearer ${KEY}` },
});
if (!list.ok) throw new Error(`voice catalogue failed: ${list.status}`);
const { data } = await list.json();
const voice = data.items.find((v) => v.name === voiceName) ?? data.items.find((v) => v.is_default);
if (!voice) throw new Error("no servable voice found");
const res = await fetch("https://api.infrai.cc/v1/audio/speech", {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
vendor: voice.vendor,
model: voice.model,
voice: voice.voice_id,
input,
response_format: format,
}),
});
if (!res.ok) throw new Error(`speech failed: ${res.status} ${await res.text()}`);
const type = res.headers.get("content-type") ?? "";
const bytes = Buffer.from(await res.arrayBuffer());
const ext = type.includes("mpeg") ? "mp3" : type.includes("wav") ? "wav" : "bin";
const file = `speech.${ext}`;
await writeFile(file, bytes);
console.log(`${voice.vendor}/${voice.model}/${voice.voice_id} -> ${file} (${type}, ${bytes.length} bytes)`);
return { file, type };
}
await speak({ input: "Release notes for version 2.4 are ready.", voiceName: "Rachel" });
Deriving the extension from Content-Type rather than from what you asked for is three lines and removes a whole class of “the file won’t play” bug reports.
| Infrai TTS | OpenAI speech API | ElevenLabs direct | |
|---|---|---|---|
| Voice namespace | per vendor and model | one set across models | one library, plus cloning |
| Catalogue endpoint | GET /v1/ai/tts/voices | documented list, no endpoint | full REST catalogue |
| Container honoured | vendor-dependent | as requested | as requested |
| Same key covers | storage, email, queue, chat | inference only | speech only |
Where this falls short
If voice cloning, fine-grained SSML control or emotion tags are the product, you’d be better off going to ElevenLabs directly — that catalogue is one voice here, and the pass-through doesn’t expose their full parameter surface. If you need OpenAI’s specific voices in a data-residency region, Azure OpenAI hosts them. And TTS availability rides on vendor keys: when a key isn’t hydrated the call returns VENDOR_NOT_CONFIGURED rather than falling back, which is honest but means you should check the catalogue count before a launch rather than after.
What you get here instead is the boring part solved: the audio you just generated can go straight into object storage on the same key, get emailed as an attachment, or be queued for a batch of a thousand release notes overnight — one credential, one invoice, one usage view. For a feature inside a product, that tends to matter more than having the largest voice library.