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 1,000 UTF-8 bytes
languagestringYesCode returned by /v1/tts/languages
voice_idstringYesOpaque voice_... ID from /v1/voices
speednumberNo0.5 to 2.0, default 1

Send a unique Idempotency-Key with each new generation. Reuse that key only when retrying the same body.

bash
curl -fS '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 hello.wav
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()
open("hello.wav", "wb").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());
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());
Files.write(Path.of("hello.wav"), response.body());

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.

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 replay returns the stored WAV with 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());
  }
}

Add ?download=1 to download the audio. Signed audio URLs expire after about 10 minutes and must not be exposed publicly.

json
{
  "id": "gen_01J...",
  "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}

Delete one generation and its private audio.

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.

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"}

Connect to wss://api.voicelab.uz/v1/tts/stream?ticket=<short-lived-ticket>. Tickets expire after about two minutes. Do not store them or include them in logs.

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