Theme
Text to Speech
Generate mono, 16-bit PCM WAV audio at 24 kHz. Use a server-side API key with tts:write permission.
Generate speech
Generate a complete WAV file from text.
| Field | Type | Required | Notes |
|---|---|---|---|
text | string | Yes | 1 to 5,000 Unicode characters; complete JSON body at most 64 KiB |
language | string | Yes | Code returned by /v1/tts/languages |
voice_id | string | Yes | Opaque voice_... ID from /v1/voices |
speed | number | No | 0.5 to 2.0, default 1 |
Omitted speed defaults to 1. Explicit 0, null, and strings are invalid.
Send a unique Idempotency-Key with each new generation. Reuse that key only when retrying the same body.
Returns 200 audio/wav if generation finishes within the request's wait budget. Otherwise, returns 202 JSON with id and status_url. Save only 200 audio as a WAV file. For 202, poll the job. The examples handle both responses.
bash
tts_http_status=$(curl -fS -w '%{http_code}' 'https://api.voicelab.uz/v1/tts' \
-H "Authorization: Bearer $VOICELAB_API_KEY" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: hello-world-001' \
-d '{
"text": "Hello from VoiceLab.",
"language": "en",
"voice_id": "voice_01J9NEUTRAL0000000000000001",
"speed": 1
}' \
-o tts-response)
case "$tts_http_status" in
200) mv tts-response hello.wav ;;
202) cat tts-response ;; # JSON job metadata; poll status_url
*) cat tts-response >&2; exit 1 ;;
esacpython
import os
import requests
response = requests.post(
"https://api.voicelab.uz/v1/tts",
headers={
"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}",
"Idempotency-Key": "hello-world-001",
},
json={
"text": "Hello from VoiceLab.",
"language": "en",
"voice_id": "voice_01J9NEUTRAL0000000000000001",
"speed": 1,
},
timeout=60,
)
response.raise_for_status()
if response.status_code == 202:
print(response.json()) # Poll status_url; this is not audio yet.
else:
with open("hello.wav", "wb") as audio:
audio.write(response.content)ts
import { writeFile } from "node:fs/promises";
const response = await fetch("https://api.voicelab.uz/v1/tts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VOICELAB_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": "hello-world-001",
},
body: JSON.stringify({
text: "Hello from VoiceLab.",
language: "en",
voice_id: "voice_01J9NEUTRAL0000000000000001",
speed: 1,
}),
});
if (!response.ok) throw new Error(await response.text());
if (response.status === 202) {
console.log(await response.json()); // Poll status_url; this is not audio yet.
} else {
await writeFile("hello.wav", Buffer.from(await response.arrayBuffer()));
}java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
var json = """
{"text":"Hello from VoiceLab.","language":"en",
"voice_id":"voice_01J9NEUTRAL0000000000000001","speed":1}
""";
var request = HttpRequest.newBuilder(URI.create("https://api.voicelab.uz/v1/tts"))
.header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY"))
.header("Content-Type", "application/json")
.header("Idempotency-Key", "hello-world-001")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
var response = HttpClient.newHttpClient().send(
request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 200) {
Files.write(Path.of("hello.wav"), response.body());
} else if (response.statusCode() == 202) {
System.out.println(new String(response.body(), java.nio.charset.StandardCharsets.UTF_8));
} else {
throw new RuntimeException("TTS failed: " + response.statusCode());
}200 OK returns the audio bytes, not JSON.
http
Content-Type: audio/wav
X-VoiceLab-Characters-Used: 24
X-VoiceLab-Audio-Duration-Ms: 1180
X-VoiceLab-Sample-Rate: 24000One Unicode character uses one credit. VoiceLab releases reserved credits if generation fails.
Generate speech in the background
Queue speech generation and return a status URL. Requires tts:write.
Uses the same request fields, 5,000-character text limit and idempotency rules as /v1/tts. Grant tts:read as well to poll. The response is 202 Accepted, with Location, Retry-After: 1 and X-VoiceLab-Generation-ID headers:
json
{
"id": "gen_EXAMPLE",
"status": "queued",
"status_url": "/v1/tts/generations/gen_EXAMPLE",
"request_id": "req_EXAMPLE"
}Poll GET /v1/tts/generations/{id} using your API key and bounded backoff. Polling returns 200 JSON even while work is pending. Inspect status:
| Status | Next step |
|---|---|
queued, processing | Wait, then poll again |
completed | Download audio_url; the result includes credits_used and credits_status: "charged" |
failed | Read error; credits_used is 0 and credits_status is released |
Download the signed audio URL without an API key or Authorization header. Refresh the generation to obtain another URL after expiry. Repeating an async submission returns 202 for the existing job, even when its status is already terminal; poll it to retrieve the result. Keep the job ID after a timeout and resume polling. Disconnecting or deleting does not cancel pending work. Use /v1/tts/generations/{id}; /v1/tts/jobs is retired.
Available languages and models
Return supported languages, models, formats, and sample rates.
Requires tts:read. Fetch this catalog instead of hardcoding language or model names.
json
{
"provider": "Lison-VoiceLab",
"sample_rate": 24000,
"data": [
{"code": "en", "name": "English", "native_name": "English"},
{"code": "uz", "name": "Uzbek", "native_name": "O'zbek"}
],
"models": [
{
"id": "lison",
"languages": ["en", "uz", "ru"],
"outputs": [{"format": "wav", "sample_rate": 24000, "streaming": false}]
}
]
}Idempotency
Keys must contain 8 to 128 letters, numbers, ., _, or - characters.
- Retry a timed-out request with the same key and the same JSON body.
- Use a new key when any input changes.
- A synchronous replay returns the stored WAV if completed, or a
202job acknowledgement while pending. Async replays return202job metadata. Both setIdempotent-Replayed: true. - Reusing a key with different input returns
409 idempotency_key_reused.
List generations
List generated audio with cursor pagination.
bash
curl -fS 'https://api.voicelab.uz/v1/tts/generations?limit=30' \
-H "Authorization: Bearer $VOICELAB_API_KEY"python
import os
import requests
response = requests.get(
"https://api.voicelab.uz/v1/tts/generations",
headers={"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}"},
params={"limit": 30},
timeout=30,
)
response.raise_for_status()
print(response.json())ts
const response = await fetch(
"https://api.voicelab.uz/v1/tts/generations?limit=30",
{ headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}` } },
);
if (!response.ok) throw new Error(await response.text());
console.log(await response.json());java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public final class Main {
public static void main(String[] args) throws Exception {
var request = HttpRequest.newBuilder(
URI.create("https://api.voicelab.uz/v1/tts/generations?limit=30"))
.header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY"))
.GET()
.build();
var response = HttpClient.newHttpClient().send(
request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());
System.out.println(response.body());
}
}Requires tts:read. limit defaults to 30 and accepts 1 to 100.
json
{
"data": [
{
"id": "gen_01J...",
"text_preview": "Your order is ready.",
"voice": {"id": "voice_01J...", "name": "Lison Neutral", "language": "en"},
"duration_ms": 1180,
"created_at": "2026-08-16T12:00:00Z",
"audio_available": true
}
],
"next_cursor": null,
"request_id": "req_01J..."
}Get a generation
Read one generation and its short-lived audio URL.
bash
curl -fS 'https://api.voicelab.uz/v1/tts/generations/gen_01J...' \
-H "Authorization: Bearer $VOICELAB_API_KEY"python
import os
import requests
generation_id = "gen_01J..."
response = requests.get(
f"https://api.voicelab.uz/v1/tts/generations/{generation_id}",
headers={"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}"},
timeout=30,
)
response.raise_for_status()
print(response.json())ts
const generationId = "gen_01J...";
const response = await fetch(
`https://api.voicelab.uz/v1/tts/generations/${generationId}`,
{ headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}` } },
);
if (!response.ok) throw new Error(await response.text());
console.log(await response.json());java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public final class Main {
public static void main(String[] args) throws Exception {
var generationId = "gen_01J...";
var request = HttpRequest.newBuilder(URI.create(
"https://api.voicelab.uz/v1/tts/generations/" + generationId))
.header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY"))
.GET()
.build();
var response = HttpClient.newHttpClient().send(
request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 400) throw new RuntimeException(response.body());
System.out.println(response.body());
}
}Pending generations return status: "queued" or "processing"; a 200 poll does not itself mean completion. See background generation. For completed generations, ?download=1 requests a signed URL configured for attachment download; the API response remains JSON. Signed audio URLs expire after about 10 minutes and must not be exposed publicly.
json
{
"id": "gen_01J...",
"status": "completed",
"text": "Your order is ready.",
"voice": {"id": "voice_01J...", "name": "Lison Neutral", "language": "en"},
"speed": 1,
"duration_ms": 1180,
"sample_rate": 24000,
"audio_url": "https://storage.example/signed-url...",
"audio_url_expires_at": "2026-08-16T12:10:00Z",
"request_id": "req_01J..."
}Delete a generation
Hide a completed generation from history. Requires tts:write.
bash
curl -fS -X DELETE \
'https://api.voicelab.uz/v1/tts/generations/gen_01J...' \
-H "Authorization: Bearer $VOICELAB_API_KEY"python
import os
import requests
generation_id = "gen_01J..."
response = requests.delete(
f"https://api.voicelab.uz/v1/tts/generations/{generation_id}",
headers={"Authorization": f"Bearer {os.environ['VOICELAB_API_KEY']}"},
timeout=30,
)
response.raise_for_status()
assert response.status_code == 204ts
const generationId = "gen_01J...";
const response = await fetch(
`https://api.voicelab.uz/v1/tts/generations/${generationId}`,
{
method: "DELETE",
headers: { Authorization: `Bearer ${process.env.VOICELAB_API_KEY}` },
},
);
if (!response.ok) throw new Error(await response.text());
if (response.status !== 204) throw new Error(`Unexpected status: ${response.status}`);java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public final class Main {
public static void main(String[] args) throws Exception {
var generationId = "gen_01J...";
var request = HttpRequest.newBuilder(URI.create(
"https://api.voicelab.uz/v1/tts/generations/" + generationId))
.header("Authorization", "Bearer " + System.getenv("VOICELAB_API_KEY"))
.DELETE()
.build();
var response = HttpClient.newHttpClient().send(
request, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() != 204) {
throw new RuntimeException("Unexpected status: " + response.statusCode());
}
}
}Requires tts:write and returns 204 No Content. Deletion hides a completed generation from history; retained audio and database records follow the service's retention policy. It does not cancel pending work. Deleting a queued, processing or failed current-pipeline generation returns 409 generation_not_completed.
Realtime speech
Stream raw 24 kHz PCM16 audio over one WebSocket.
Server clients authenticate during the WebSocket upgrade with xvl-api-key. The key needs tts:realtime permission.
python
import json
import os
from pathlib import Path
import websocket
ws = websocket.create_connection(
"wss://api.voicelab.uz/v1/tts/stream",
header=[f"xvl-api-key: {os.environ['VOICELAB_API_KEY']}"],
timeout=30,
)
print(json.loads(ws.recv())) # ready
ws.send(json.dumps({
"text": "Hello from realtime VoiceLab.",
"language": "en",
"voice_id": "voice_01J9NEUTRAL0000000000000001",
"speed": 1,
}))
with Path("speech.pcm").open("wb") as output:
while True:
frame = ws.recv()
if isinstance(frame, bytes):
output.write(frame)
continue
event = json.loads(frame)
if event.get("event") in {"done", "error"}:
break
ws.close()ts
import { writeFile } from "node:fs/promises";
import WebSocket from "ws";
const apiKey = process.env.VOICELAB_API_KEY;
if (!apiKey) throw new Error("VOICELAB_API_KEY is not set");
const ws = new WebSocket("wss://api.voicelab.uz/v1/tts/stream", {
headers: { "xvl-api-key": apiKey },
});
const chunks: Buffer[] = [];
ws.on("message", async (data, isBinary) => {
if (isBinary) {
chunks.push(Buffer.from(data));
return;
}
const event = JSON.parse(data.toString());
if (event.event === "ready") {
ws.send(JSON.stringify({
text: "Hello from realtime VoiceLab.",
language: "en",
voice_id: "voice_01J9NEUTRAL0000000000000001",
speed: 1,
}));
}
if (event.event === "done") {
await writeFile("speech.pcm", Buffer.concat(chunks));
ws.close();
}
if (event.event === "error") throw new Error(event.message);
});java
import java.io.ByteArrayOutputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
public final class RealtimeTts {
public static void main(String[] args) throws Exception {
var audio = new ByteArrayOutputStream();
var finished = new CountDownLatch(1);
WebSocket.Listener listener = new WebSocket.Listener() {
private final StringBuilder text = new StringBuilder();
@Override public void onOpen(WebSocket socket) { socket.request(1); }
@Override public CompletionStage<?> onText(
WebSocket socket, CharSequence data, boolean last) {
text.append(data);
if (last) {
String event = text.toString();
text.setLength(0);
if (event.contains("\"ready\"")) {
socket.sendText("""
{"text":"Hello from realtime VoiceLab.","language":"en",
"voice_id":"voice_01J9NEUTRAL0000000000000001","speed":1}
""", true);
}
if (event.contains("\"done\"") || event.contains("\"error\"")) {
finished.countDown();
}
}
socket.request(1);
return null;
}
@Override public CompletionStage<?> onBinary(
WebSocket socket, ByteBuffer data, boolean last) {
byte[] chunk = new byte[data.remaining()];
data.get(chunk);
audio.writeBytes(chunk);
socket.request(1);
return null;
}
};
WebSocket socket = HttpClient.newHttpClient().newWebSocketBuilder()
.header("xvl-api-key", System.getenv("VOICELAB_API_KEY"))
.buildAsync(URI.create("wss://api.voicelab.uz/v1/tts/stream"), listener)
.join();
finished.await();
Files.write(Path.of("speech.pcm"), audio.toByteArray());
socket.sendClose(WebSocket.NORMAL_CLOSURE, "done").join();
}
}bash
wscat -c 'wss://api.voicelab.uz/v1/tts/stream' \
-H "xvl-api-key: $VOICELAB_API_KEY"
# Send after the ready event:
{"text":"Hello from realtime VoiceLab.","language":"en","voice_id":"voice_01J9NEUTRAL0000000000000001","speed":1}The server sends ready first:
json
{"event":"ready","format":"pcm_s16le","sample_rate":24000,"channels":1}Send one text request per connection. text accepts up to 8 KiB, and speed accepts 0.5 to 2.0. Binary frames contain signed 16-bit little-endian mono PCM. They are not WAV files. The final event is {"event":"done","duration_ms":1420}.
Send {"action":"interrupt"} to stop active synthesis. The server returns {"event":"interrupted"}.
Browser authentication
Browsers cannot set xvl-api-key on a WebSocket. Your backend must mint a short-lived ticket:
Mint a ticket for one browser WebSocket connection.
json
{"transport":"websocket","service":"tts"}Both transport and service are required. Returns 201 Created. Connect to wss://api.voicelab.uz/v1/tts/stream?ticket=<short-lived-ticket>. Both TTS and STT accept tickets or server-side xvl-api-key, but never both on one handshake. A ticket minted with service=stt cannot authorize TTS; create a fresh service=tts ticket. Tickets expire after about two minutes. Do not store them or include them in logs. Keep the long-lived key on your backend.
Realtime errors include invalid_message, validation_error, busy, not_busy, insufficient_credits, overloaded, timeout, service_unavailable, and generation_failed.
Errors
| Status | Code | Fix |
|---|---|---|
400 | invalid_json, invalid_idempotency_key | Fix the body or key |
401 | invalid_api_key | Send a valid API key |
402 | insufficient_credits | Add credits |
403 | insufficient_scope | Update key permissions or allowed IPs |
404 | voice_unavailable, generation_not_found | Refresh the voice or generation ID |
409 | idempotency_key_reused | Use a new key for changed input |
422 | validation_error | Correct fields listed in error.fields |
429 | rate_limited, overloaded | Wait for Retry-After, then retry |
500, 503 | internal_error, service_unavailable | Retry with the same idempotency key |