Evom Labs

Developer platform

Loly 3.5 Text-to-Speech API

Public API contract for voice enrollment, durable batch output, binary bytes, Server-Sent Events, WebSocket streaming and usage.

On this page

Endpoints

POST/api/v1/voices

Creates a custom voice, and issues a new voice key where appropriate.

POST/api/v1/tts/generate

Generates one finished audio file from text.

POST/api/v1/tts/bytes

Generates one finished audio file and returns its binary bytes.

POST/api/v1/tts/sse

Streams PCM16 audio events over a POST response.

POST/api/v1/tts/stream-token

Issues a short-lived token that opens the streaming WebSocket.

WS/ws/tts/stream?token=<stream_token>

Delivers PCM16 audio as binary frames.

GET/api/v1/usage

Reads the allowance of the account behind the voice key.

Needs verification: public exposure

The voice enrollment route and the public WebSocket origin have to be checked against the deployment configuration before either is treated as stable. The stream-token response returns the socket URL to use; prefer it over any hard-coded origin.

Voice enrollment

POST/api/v1/voices

Creates a voice from an audio sample and returns its voice key. The account key authorises the call; the new voice key is returned once, in plaintext.

Form fields

FieldTypeRequiredDefaultDescription
filefileYes-Audio file, up to 10 MiB.
namestringYes-Voice name, not empty, up to 64 characters.
consentstringYes-Must be true. Confirms the speaker authorised the clone.
descriptionstringNo-Free-text note stored with the voice.
genderstringNofemaleOne of male, female or other.

Accepted when the MIME type starts with audio/, or when the extension is one of:

m4amp3wavwaveaacoggogaopusflacwebaaiffaifaifccafwmaamrmp4m4b

Consent is required

Enrollment is only for a voice whose speaker authorised the clone. The consent field records that authorisation and the request fails without it.

Examples

curl
curl -X POST https://studio.evomlabs.com/api/v1/voices \
  -H "Authorization: Bearer vc_ak_live_YOUR_KEY" \
  -F "file=@sample.wav" \
  -F "name=Authorized sample voice" \
  -F "gender=female" \
  -F "consent=true"

Response

JSON
{
  "ok": true,
  "data": {
    "voice_id": "voice_id",
    "name": "Authorized sample voice",
    "status": "ready",
    "deduped": false,
    "gender": "female",
    "description": "",
    "file_name": "sample.wav",
    "file_size": 482310,
    "created_at": "2026-08-03T00:00:00.000Z",
    "key_status": "created",
    "api_key": {
      "key": "vc_sk_live_NEW_KEY",
      "last_four": "ABCD",
      "scopes": ["tts.generate", "tts.stream", "usage.read"]
    }
  }
}

Duplicates and retries

Audio is deduplicated within the account. Re-uploading the same content returns 200 with deduped set to true. If the existing voice already has an active key, key_status is exists and api_key is null, because the endpoint never returns the secret of a key that already exists.

A regular account may enroll up to 20 voices through the public route, at up to 6 enrollments per minute. Exceeding the rate returns 429 with a Retry-After header.

Batch generation

POST/api/v1/tts/generate

Waits until the audio is ready, then returns a URL to download it. The voice comes from the key, so the payload carries no voice identifier.

Request

FieldTypeRequiredDefaultDescription
textstringYes-Text to speak, not empty, up to 5,000 characters.
languagestringNoautoauto, or one code or full name from the 646-language catalog.
formatstringNomp3Exactly mp3 or wav. The returned container and MIME match this value.
speednumberNo1.0Playback rate, clamped to 0.5 to 1.5.
cfg_valuenumberNo2.0How closely to follow the reference voice. A finite number from 0.0 to 4.0, including 0.
dit_stepsintegerNo10Diffusion steps. An integer from 0 to 64, including 0.

The allowance is deducted up front by text.length, including any whitespace in the string you send.

Advanced parameters

These fields are part of the public contract and are accepted by the endpoint. Leave them at their defaults unless you have a reason to change them.

FieldTypeDefaultDescription
do_normalizebooleanfalseText normalisation before synthesis.
denoisebooleanfalseDenoising, on the public batch route.
control_instructionstring""Not used to select a voice other than the one bound to the key.
use_prompt_textbooleanfalseAdvanced compatibility field.
prompt_textstring""Advanced compatibility field.

Examples

curl
curl -X POST https://studio.evomlabs.com/api/v1/tts/generate \
  -H "Authorization: Bearer vc_sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Xin chào, đây là bài test giọng nói.",
    "language": "vi",
    "format": "wav",
    "cfg_value": 2.0,
    "dit_steps": 10
  }'
Python
import requests

BASE_URL = "https://studio.evomlabs.com"
API_KEY = "vc_sk_live_YOUR_KEY"

res = requests.post(
    f"{BASE_URL}/api/v1/tts/generate",
    headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"},
    json={
        "text": "Xin chào, đây là bài test giọng nói.",
        "language": "vi",
        "format": "wav",
    },
)

data = res.json()
if data.get("ok"):
    print("Audio URL:", data["data"]["audio_url"])
else:
    print("Error:", data["error"]["message"])
JavaScript
const BASE_URL = "https://studio.evomlabs.com";
const API_KEY = "vc_sk_live_YOUR_KEY";

const res = await fetch(`${BASE_URL}/api/v1/tts/generate`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    text: "Xin chào, đây là bài test giọng nói.",
    language: "vi",
    format: "wav",
  }),
});

const { ok, data, error } = await res.json();
if (ok) console.log(data.audio_url);
else console.error(error.message);

Response

JSON
{
  "ok": true,
  "data": {
    "success": true,
    "request_id": "cmp3sih7p0002v9o670rd59xc",
    "voice_id": "clx8abc123def",
    "audio_url": "https://cdn.example.com/v1_tts/user123/audio.wav",
    "text": "Xin chào, đây là bài test giọng nói.",
    "duration": null,
    "status": "completed",
    "chars_deducted": 34
  }
}

Complete Loly 3.5 language catalog — `language` accepts `auto`, one catalog code, or its full name.

Audio bytes

POST/api/v1/tts/bytes

Uses the same JSON fields and permissions as batch generation, but returns the completed file directly.

The server finishes synthesis, returns audio bytes without a JSON success envelope, and does not persist a second R2 copy. Errors still use the REST error envelope. Save the response body using the selected format.

curl
curl -X POST https://studio.evomlabs.com/api/v1/tts/bytes \
  -H "Authorization: Bearer vc_sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text":"Xin chĂ o","language":"vi","format":"wav"}' \
  --output hello.wav
HeaderValueMeaning
Content-Typeaudio/wav | audio/mpegWAV for wav, MPEG audio for mp3.
Content-Dispositionattachment filenameSuggested oriagent.wav or oriagent.mp3 filename.
X-OriAgent-Formatwav | mp3The selected output format.
X-OriAgent-Sample-Rateinteger HzPresent only when the backend provides it.
X-OriAgent-Chars-DeductedintegerExactly text.length charged for the request.

SSE audio stream

POST/api/v1/tts/sse

Uses the same JSON fields as batch generation and returns progressive text/event-stream events.

SSE is text-only, so every audio event contains base64 PCM16 little-endian mono. format is still validated as wav or mp3 for a shared request contract, but it does not change SSE frames. Use fetch plus a ReadableStream because EventSource only supports GET.

curl
curl -N -X POST https://studio.evomlabs.com/api/v1/tts/sse \
  -H "Authorization: Bearer vc_sk_live_YOUR_KEY" \
  -H "Accept: text/event-stream" \
  -H "Content-Type: application/json" \
  -d '{"text":"Xin chĂ o","language":"vi"}'

Server events

text/event-stream
event: start
data: {"sample_rate":24000,"channels":1,"format":"pcm_s16le","encoding":"base64"}

event: audio
data: {"sequence":1,"audio":"<base64 PCM16 little-endian>"}

event: done
data: {"chunks":12,"duration_ms":5230,"elapsed_ms":6100}

Important

Exactly one start event comes first, then one or more audio events in increasing sequence order, then one done event. An error event has code GENERATION_FAILED and replaces done if generation fails after the stream begins.

Stream token

POST/api/v1/tts/stream-token

Exchanges the voice key for a short-lived token that opens the WebSocket. The allowance is deducted here.

text_length must be greater than 0 and no larger than 10,000. Send the length of the text you actually intend to stream.

curl
curl -X POST https://studio.evomlabs.com/api/v1/tts/stream-token \
  -H "Authorization: Bearer vc_sk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"text_length": 200}'

Response

JSON
{
  "ok": true,
  "data": {
    "stream_token": "SHORT_LIVED_TOKEN",
    "ws_url": "wss://studio.evomlabs.com/ws/tts/stream",
    "max_length": 200,
    "chars_deducted": 200,
    "expires_in": 60
  }
}

The token lasts 60 seconds

Open the socket right away. The text sent in the start message must not exceed the max_length returned with the token, and anything longer is rejected. The allowance is deducted when the token is issued and is not currently refunded if the token goes unused or the stream is cancelled.

Always connect to ws_url

The stream-token response carries the socket URL. Use that value rather than a hard-coded origin, and confirm with the operator that it resolves to a public wss endpoint before you ship an integration.

WebSocket

WS/ws/tts/stream?token=<stream_token>

Streams PCM16 mono at 24 kHz, in frames of roughly 200 ms.

Start message

JSON
{
  "type": "start",
  "text": "Xin chào, đây là bài test giọng nói.",
  "language": "vi",
  "speed": 1.0,
  "control_instruction": "",
  "cfg_value": 2.0,
  "dit_steps": 8,
  "use_prompt_text": false,
  "prompt_text": "",
  "do_normalize": false,
  "denoise": true,
  "preprocess_prompt": true,
  "postprocess_output": true
}
FieldTypeRequiredDefaultDescription
typestringYes-Must be start.
textstringYes-Must not exceed the max_length carried by the token.
languagestringNoautoauto, or one code or full name from the 646-language catalog.
speednumberNo1.0Clamped to 0.5 to 1.5.
cfg_valuenumberNo2.0Range 0.0 to 4.0.
dit_stepsintegerNo8Range 0 to 64.
control_instructionstringNo""Ignored while the stream uses a cloned-voice prompt.
use_prompt_textbooleanNofalseAdvanced compatibility field.
prompt_textstringNo""Advanced compatibility field.
do_normalizebooleanNofalseText normalisation before synthesis.
denoisebooleanNotrueDenoising, on the public batch route.
preprocess_promptbooleanNotrueReference prompt preprocessing.
postprocess_outputbooleanNotrueOutput postprocessing.

Important

The voice is locked into the stream token. The start message cannot change it.

Server events

JSON
// Server to client

{ "type": "start", "mode": "fast", "sample_rate": 24000,
  "format": "pcm16", "channels": 1 }

<binary frame>          // PCM16 mono, about 200 ms per frame

{ "type": "done", "mode": "fast", "audio_url": "/api/tts/file/output.wav",
  "chunks": 12, "ttfb_ms": 480, "duration_ms": 5230 }

{ "type": "error", "message": "Error description" }
{ "type": "cancelled" }

Binary frames and JSON frames

A client must tell the two apart: binary frames are raw PCM16 with no WAV header, JSON text frames carry the events. To write a .wav you have to add the header yourself, or fetch the audio_url from the done event, which may be a relative path. Do not hard-code the byte size of a frame.

A missing, invalid or expired token produces an error event, after which the server closes the socket with code 4401. Close the socket to cancel a generation.

Examples

Python
import json, requests, websocket

BASE_URL = "https://studio.evomlabs.com"
API_KEY = "vc_sk_live_YOUR_KEY"
text = "Xin chào, đây là bài test giọng nói."

# 1. Ask for a short-lived token. It expires in 60 seconds, so open the
#    socket immediately afterwards.
token = requests.post(
    f"{BASE_URL}/api/v1/tts/stream-token",
    headers={"Authorization": f"Bearer {API_KEY}"},
    json={"text_length": len(text)},
).json()["data"]

# 2. Always connect to the ws_url the response returned.
ws = websocket.create_connection(f"{token['ws_url']}?token={token['stream_token']}")
ws.send(json.dumps({"type": "start", "text": text, "language": "vi"}))

pcm = bytearray()
while True:
    frame = ws.recv()
    if isinstance(frame, bytes):
        pcm.extend(frame)          # PCM16 mono at 24 kHz
        continue
    event = json.loads(frame)
    if event["type"] in ("done", "error", "cancelled"):
        break

ws.close()
print("received", len(pcm), "bytes of audio")

Error codes