Skip to content

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 ​

POSThttps://api.voicelab.uz/v1/tts

Generate a complete WAV file from text.

FieldTypeRequiredNotes
textstringYes1 to 5,000 Unicode characters; complete JSON body at most 64 KiB
languagestringYesCode returned by /v1/tts/languages
voice_idstringYesOpaque voice_... ID from /v1/voices
speednumberNo0.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 ;;
esac
python
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: 24000

One Unicode character uses one credit. VoiceLab releases reserved credits if generation fails.

Generate speech in the background ​

POSThttps://api.voicelab.uz/v1/tts/async

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:

StatusNext step
queued, processingWait, then poll again
completedDownload audio_url; the result includes credits_used and credits_status: "charged"
failedRead 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 ​

GEThttps://api.voicelab.uz/v1/tts/languages

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 202 job acknowledgement while pending. Async replays return 202 job metadata. Both set Idempotent-Replayed: true.
  • Reusing a key with different input returns 409 idempotency_key_reused.

List generations ​

GEThttps://api.voicelab.uz/v1/tts/generations?limit=30&cursor=<opaque>

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 ​

GEThttps://api.voicelab.uz/v1/tts/generations/{generation_id}

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 ​

DELETEhttps://api.voicelab.uz/v1/tts/generations/{generation_id}

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 == 204
ts
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 ​

WSSwss://api.voicelab.uz/v1/tts/stream

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:

POSThttps://api.voicelab.uz/v1/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 ​

StatusCodeFix
400invalid_json, invalid_idempotency_keyFix the body or key
401invalid_api_keySend a valid API key
402insufficient_creditsAdd credits
403insufficient_scopeUpdate key permissions or allowed IPs
404voice_unavailable, generation_not_foundRefresh the voice or generation ID
409idempotency_key_reusedUse a new key for changed input
422validation_errorCorrect fields listed in error.fields
429rate_limited, overloadedWait for Retry-After, then retry
500, 503internal_error, service_unavailableRetry with the same idempotency key