Text to speech
Turn text into audio — Persian on one engine, twenty-three other languages on another, chosen for you automatically.
POST /v1/audio/speech takes text and returns audio bytes, in the same shape OpenAI's endpoint uses. Behind it are two different engines picked by language, not one model that speaks everything.
/v1/audio/speechThe voice catalogue
GET /v1/audio/voices lists every voice id this deployment accepts — 243 of them: default, male and female (language-agnostic, routed to whichever engine the request's language picks), plus, for Persian and each of the 23 Chatterbox languages, five numbered voices per gender — <lang>-male-1 through <lang>-male-5 and <lang>-female-1 through <lang>-female-5. Persian speaks through Aava; the other 23 languages speak through Chatterbox. The same voice id produces the same speaker every time — this is for picking distinct, stable speakers for something like a multi-party transcript read aloud, not a creative-control knob.
/v1/audio/voicescurl -s https://api.data.larsima.com/v1/audio/voices \
-H "Authorization: Bearer $DATA_API_KEY"
# {"voices": [
# {"id": "default", "language": "any", "gender": null, ...},
# {"id": "male", "language": "any", "gender": "male", ...},
# {"id": "fa-female-1", "language": "fa", "gender": "female", ...},
# {"id": "es-male-3", "language": "es", "gender": "male", ...},
# ... 243 total
# ]}
import os, httpx
r = httpx.get(
"https://api.data.larsima.com/v1/audio/voices",
headers={"Authorization": f"Bearer {os.environ['DATA_API_KEY']}"},
)
voices = r.json()["voices"]
print(len(voices), "voices")
| Parameter | Type | Description |
|---|---|---|
| input Required | string | The text to speak. 8000 characters max. |
| model Required | string | The only deployment name, data-tts. Required — the gateway uses it to route the request, and a call that omits it fails.Default: data-tts |
| voice Required | string | Required. One of the ids Listing voices above returns — default, male, female, or a <lang>-male-N / <lang>-female-N id for the language you're speaking. An OpenAI voice name like alloy is not one of them: it is rejected, not silently accepted. Omitting the field entirely fails the same way omitting model does — see the note below.Default: default |
| language Optional | string | Which engine, and how the text is pronounced — see below. Auto-detected from the text when omitted, which is reliable for Persian and risky for everything else. |
| response_format Optional | string | mp3 · opus · aac · flac · wav.Default: mp3 |
| guard Optional | boolean | Synthesise, transcribe the result back, and regenerate if the words don't match closely enough — see below. Costs a full extra round trip through speech-to-text. Default: false |
| max_retries Optional | integer | Only matters with guard: true — the best of up to max_retries + 1 attempts is returned.Default: 2 |
model and voice must both be present in the JSON body — not just input. Leave either one out and the response is {"error": {"message": "Internal server error"}}, 76 bytes, with nothing to say which field was missing. There is no way to work it out from the response itself; every sample below sends both, every time. A voice that *is* present but not one of the catalogue — alloy, say — fails the same opaque way through this gateway, even though the TTS service itself answers that case with a proper 400 and the list of ids it would have accepted; the gateway's own response normalisation does not pass that detail through.curl -s https://api.data.larsima.com/v1/audio/speech \
-H "Authorization: Bearer $DATA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "data-tts",
"voice": "default",
"input": "Se dictará sentencia en un plazo máximo de veinte días.",
"language": "es",
"response_format": "mp3"
}' --output speech.mp3
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.data.larsima.com/v1",
api_key=os.environ["DATA_API_KEY"])
resp = client.audio.speech.create(
model="data-tts",
voice="default", # required; the value itself has no effect -- see below
input="Se dictará sentencia en un plazo máximo de veinte días.",
response_format="mp3",
extra_body={"language": "es"},
)
resp.stream_to_file("speech.mp3")
import fs from "node:fs";
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.data.larsima.com/v1",
apiKey: process.env.DATA_API_KEY,
});
const resp = await client.audio.speech.create({
model: "data-tts",
voice: "default", // required; the value itself has no effect -- see below
input: "Se dictará sentencia en un plazo máximo de veinte días.",
response_format: "mp3",
language: "es",
});
fs.writeFileSync("speech.mp3", Buffer.from(await resp.arrayBuffer()));
import fs from "node:fs";
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://api.data.larsima.com/v1",
apiKey: process.env.DATA_API_KEY,
});
const resp = await client.audio.speech.create({
model: "data-tts",
voice: "default" as any, // required; the value itself has no effect -- see below
input: "Se dictará sentencia en un plazo máximo de veinte días.",
response_format: "mp3",
// @ts-expect-error -- gateway-specific field, not in the SDK's types yet
language: "es",
});
fs.writeFileSync("speech.mp3", Buffer.from(await resp.arrayBuffer()));
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
)
func main() {
body, _ := json.Marshal(map[string]any{
"model": "data-tts",
"voice": "default",
"input": "Se dictará sentencia en un plazo máximo de veinte días.",
"language": "es",
"response_format": "mp3",
})
req, _ := http.NewRequest("POST",
"https://api.data.larsima.com/v1/audio/speech", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("DATA_API_KEY"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
out, _ := os.Create("speech.mp3")
defer out.Close()
io.Copy(out, resp.Body)
}
// Cargo.toml: reqwest = { version = "0.12", features = ["json"] }
// serde_json = "1", tokio = { version = "1", features = ["full"] }
use serde_json::json;
use std::{env, fs};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let key = env::var("DATA_API_KEY")?;
let bytes = reqwest::Client::new()
.post("https://api.data.larsima.com/v1/audio/speech")
.bearer_auth(key)
.json(&json!({
"model": "data-tts",
"voice": "default",
"input": "Se dictará sentencia en un plazo máximo de veinte días.",
"language": "es",
"response_format": "mp3"
}))
.send()
.await?
.bytes()
.await?;
fs::write("speech.mp3", bytes)?;
Ok(())
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
public class TextToSpeech {
public static void main(String[] args) throws Exception {
String body = "{\"model\":\"data-tts\",\"voice\":\"default\","
+ "\"input\":\"Se dictar\u00e1 sentencia en un plazo m\u00e1ximo de veinte d\u00edas.\","
+ "\"language\":\"es\",\"response_format\":\"mp3\"}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://api.data.larsima.com/v1/audio/speech"))
.header("Authorization", "Bearer " + System.getenv("DATA_API_KEY"))
.header("Content-Type", "application/json; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
HttpResponse<byte[]> response = HttpClient.newHttpClient()
.send(request, HttpResponse.BodyHandlers.ofByteArray());
Files.write(Path.of("speech.mp3"), response.body());
}
}
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var payload = new {
model = "data-tts",
voice = "default",
input = "Se dictará sentencia en un plazo máximo de veinte días.",
language = "es",
response_format = "mp3",
};
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("DATA_API_KEY"));
var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
var response = await client.PostAsync("https://api.data.larsima.com/v1/audio/speech", content);
await File.WriteAllBytesAsync("speech.mp3", await response.Content.ReadAsByteArrayAsync());
<?php
$payload = [
"model" => "data-tts",
"voice" => "default",
"input" => "Se dictará sentencia en un plazo máximo de veinte días.",
"language" => "es",
"response_format" => "mp3",
];
$ch = curl_init("https://api.data.larsima.com/v1/audio/speech");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . getenv("DATA_API_KEY"),
"Content-Type: application/json",
],
CURLOPT_POSTFIELDS => json_encode($payload, JSON_UNESCAPED_UNICODE),
]);
file_put_contents("speech.mp3", curl_exec($ch));
require "net/http"
require "json"
require "uri"
uri = URI("https://api.data.larsima.com/v1/audio/speech")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('DATA_API_KEY')}"
request["Content-Type"] = "application/json"
request.body = {
model: "data-tts",
voice: "default",
input: "Se dictará sentencia en un plazo máximo de veinte días.",
language: "es",
response_format: "mp3",
}.to_json
response = http.request(request)
File.binwrite("speech.mp3", response.body)
Two engines, chosen by language
No open engine covers Persian and the European languages at usable quality together, so this deployment doesn't pretend one does — it routes.
| Language | Engine | Note |
|---|---|---|
| Persian | Aava (Orpheus 3B, Apache-2.0) | The only engine used for Persian. |
| 23 other languages | Chatterbox Multilingual (MIT) | Arabic, Chinese, Danish, Dutch, English, Finnish, French, German, Greek, Hebrew, Hindi, Italian, Japanese, Korean, Malay, Norwegian, Polish, Portuguese, Russian, Spanish, Swahili, Swedish, Turkish. Not Persian. |
Say the language — don't rely on guessing
Without language, the engine is picked by counting Persian characters: more than a quarter of the text and it goes to Aava; otherwise it goes to Chatterbox pronouncing English — regardless of what language the text is actually in. Spanish text sent with no language field is read aloud with English phonemes. Set language to the two-letter code (es, fr, de, …) for anything that isn't Persian or English, every time.
Long input is chunked, not cut off
Both engines stop generating on their own well before a long input finishes — around 15.5 seconds of audio per call. Rather than hand back a clipped sentence, input is split on sentence boundaries into pieces of up to 170 characters (falling back to a hard cut only for a single run-on sentence longer than that), each piece is synthesised separately with the same voice and seed, and the results are joined with a short silence between them. This is automatic — there is no parameter for it — and it's why a long input takes noticeably longer than a short one: it's several generations back to back, not one.
Response headers
| Header | Meaning |
|---|---|
X-Lardad-Engine | aava or chatterbox. |
X-Lardad-Language | The language actually used to pick the engine. |
X-Lardad-Voice | The voice id you sent, echoed back. |
X-Lardad-Duration | Length of the audio, in seconds. |
X-Lardad-Elapsed | Wall-clock time the request took, in seconds. |
X-Lardad-Guard-Score | Only present with guard: true — the fraction of input words the readback recognised, 0 to 1. |
X-Lardad-TTS-Chunks | How many pieces input was split into before synthesis — see Long input is chunked below. |
X-Lardad-TTS-Truncated | true if any chunk hit the model's own generation ceiling and was cut short. Always present; false the rest of the time. |
X-Lardad-TTS-Audio-Tokens | Persian (Aava) only — total audio tokens generated across all chunks. Absent for the other 23 languages. |
Measured quality, and the failure both engines share
Quality here means round-trip word error rate: synthesise a sentence, transcribe the audio back with the speech-to-text service, and compare to the original. Aava scored 22.0% on the speaker's own recordings — clearer than the human reading the same text, which scored 41.5%. Chatterbox on Spanish scored 2.3%. On French it was inconsistent: 0.0% on one sentence and 113% on another, where it read the sentence correctly and then kept going, inventing words that were never in the input.
guard: true is the check: it listens to what it just generated, and regenerates when the words don't come back close enough. It costs a second or two more; for anything a person hears unattended, it's worth every millisecond.curl -s https://api.data.larsima.com/v1/audio/speech \
-H "Authorization: Bearer $DATA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "data-tts",
"voice": "default",
"input": "مهلت تجدیدنظرخواهی بیست روز از تاریخ ابلاغ است.",
"guard": true,
"max_retries": 2
}' --output speech.mp3 -D -
# X-Lardad-Engine: aava
# X-Lardad-Language: fa
# X-Lardad-Guard-Score: 0.94