API Reference
API Reference
The Gandr API serves Spex-TTS, our own speech model, over three streaming surfaces: raw WAV bytes, server-sent events, and a live WebSocket built for real-time calls. One request schema across all three.
Base URL
tts.gandr.ai
Audio
24 kHz PCM16 native, any rate via resample
Cloning
instant, from 5–10 s of reference audio
Overview
Every endpoint accepts JSON, authenticates with your API key, and starts streaming audio in roughly a hundred milliseconds of server time. Voices are either cloned inline from a short reference clip or referenced by a stable id. Delivery is tunable per request — six expression controls and four transcript controls, documented below, ride every surface.
- One-shot synthesis →
POST /v1/tts/bytesreturns a complete WAV. - Streaming over HTTP →
POST /v1/tts/ssestreams base64 PCM chunks as they render. - Live conversation →
WS /wsholds one connection per call; utterances stream in, audio frames stream out.
Authentication
Send your key on every request. Keys look like gnd_…. Production line keys are unmetered; trial keys carry a monthly character quota until a pilot starts.
# either header works
x-api-key: gnd_your_key
Authorization: Bearer gnd_your_key
Invalid or missing keys return 401 {"error":"invalid_api_key"}.
Voices & cloning
The voice object selects the speaker. Two modes:
| Mode | Shape | Behavior |
|---|---|---|
| clone | {"mode":"clone","wav_b64":"<base64 wav>"} | Instant clone from 5–10 s of clean reference speech (WAV, base64, ≤ ~1.5 MB / ~30 s). References are fingerprinted and cached — after the first call, re-sending the same clip skips re-cloning. |
| id | {"mode":"id","id":"gandr-…"} | A pre-registered voice: one of the stock voices below, or an id registered from your clone. |
Stock voices
Six ready-made voices ship with the API — no reference clip needed, and every one of them is multilingual (set language / lang per request).
| ID | Character |
|---|---|
| gandr-jenny | warm, natural (f) |
| gandr-ava | warm, friendly (f) |
| gandr-mia | warm, natural (f) |
| gandr-dane | smooth, measured (m) |
| gandr-leo | clear, professional (m) |
| gandr-lewis | warm, even (m) |
List the roster programmatically — new voices appear here the moment they publish:
curl -s https://tts.gandr.ai/v1/voices -H "x-api-key: $GANDR_KEY"
# {"voices":[{"id":"gandr-ava","name":"Ava","language":"multilingual"}, …]}
The roster is curated — retired ids stop being listed, so pin your integration to GET /v1/voices rather than a hardcoded list. gandr-jenny is built from the “Jenny (Dioco)” speech corpus, used with attribution.
Clones keep the speaker's identity across languages — synthesize Spanish, German, or Japanese from an English reference and it still sounds like the same person. That cross-language identity is a measured result, not a marketed one: it held up in blind listening panels on speech in languages the reference never spoke.
Expression controls
Six optional fields shape the delivery on every endpoint — REST and WebSocket alike. All of them condition the voice directly, so none of them adds latency.
| Field | Range · default | What it shapes |
|---|---|---|
| expressiveness | 0.25 – 2.0 · 0.5 | Emotional intensity: 0.25 reads flat and neutral, 2.0 lively and animated. The 0.5 default matches the stock-voice tuning. |
| temperature | 0.1 – 1.2 · 0.8 cloned | Prosodic variation — pitch range and melody. 0.1 is locked and monotone; set it low for strict, repeatable IVR lines. Cloned voices default to 0.8, tuned for conversational prosody; stock voices carry hand-tuned defaults (below). |
| cfg_weight | 0.2 – 1.0 · 0.5 | Guidance strength, which also sets pacing: 0.2 slower and spacious, 1.0 tight and brisk. |
| speed | 0.6 – 1.5 · 1.0 | Playback rate without pitch change. Applied after synthesis, so wording and voice are untouched. |
| volume | 0.5 – 2.0 · 1.0 | Output gain without changing the voice. Applied after mastering with a soft ceiling, so it never hard-clips. |
| seed | integer · unset | Reproducibility: the same seed, text, voice, and parameters return the same audio (per serving region). Omit for a fresh natural take each time. |
Stock-voice temperature defaults, hand-tuned per voice: jenny, ava, mia, and lewis at 0.5 · dane at 0.65 · leo at 0.8.
# for emphatic, emotional reads, raise expressiveness and
# lower cfg_weight together, so the bigger delivery stays
# unhurried
{"expressiveness": 0.9, "cfg_weight": 0.3, "temperature": 0.8}
Transcript controls
Four inline controls steer the read itself — available on every endpoint, REST and WebSocket.
| Control | Behavior |
|---|---|
| <spell>…</spell> | Reads the wrapped text character by character, with letter and digit groups paced naturally. Use it for confirmation codes, order IDs, and serial numbers. |
| emotion | A body field naming the emotional read — it sets tuned expressiveness and cfg_weight presets under the hood, and explicit fields always win. Works best when the emotion matches the text. Supported: neutral, calm, content, happy, excited, surprised, curious, confident, angry, frustrated, sad, apologetic, anxious, scared, bored, tired — and more. |
| pronunciation_dict | Per-request sounds-like replacements for hard words — proper nouns, domain terms. A lowercase entry also matches its sentence-start capitalized form. |
| <break time="800ms"/> | Inserts a pause at that point, rendered as a natural beat — the tag is never read aloud, and the exact duration is advisory. Well-placed punctuation is still the best pacing tool. |
{
"transcript": "Your code is <spell>TKT4829XB</spell>.
<break time=\"600ms\"/> Read it back to me.",
"emotion": "calm",
"pronunciation_dict": [
{"text": "tchoupitoulas",
"pronunciation": "chop-uh-TOO-liss"}
],
"voice": {"mode": "id", "id": "gandr-jenny"}
}
POST/v1/tts/bytes
Synthesizes the full utterance and returns a complete audio/wav body. Simplest integration; latency equals total render time.
| Field | Type | Notes |
|---|---|---|
| transcript | string | Required (alias: text). ≤ 2,000 characters per request; <spell> and <break> tags ride inline. |
| language | string | ISO code, default en. 23 languages. |
| voice | object | Required — see Voices & cloning. |
| expressiveness … seed | float · int | The six expression controls — ranges and defaults above. |
| emotion · pronunciation_dict | string · list | Transcript controls — see above. |
| output_format.sample_rate | int | Default 24000. Any rate; resampled server-side. |
curl -s https://tts.gandr.ai/v1/tts/bytes \
-H "x-api-key: $GANDR_KEY" -H "content-type: application/json" \
-d '{
"transcript": "Welcome aboard. Your onboarding call starts now.",
"language": "en",
"voice": {"mode": "clone", "wav_b64": "'"$(base64 < ref.wav)"'"},
"output_format": {"sample_rate": 24000}
}' -o out.wav
POST/v1/tts/sse
Same request body as /v1/tts/bytes; the response is a text/event-stream that delivers audio while it renders. Each event carries a base64 chunk of raw PCM16LE at your requested sample rate (no WAV header — it's a continuous stream). A final {"done": true} event closes.
data: {"data": "<base64 pcm16le chunk>"}
data: {"data": "<base64 pcm16le chunk>"}
data: {"done": true}
import base64, json, requests
r = requests.post("https://tts.gandr.ai/v1/tts/sse",
headers={"x-api-key": KEY},
json={"transcript": text, "voice": {"mode": "id", "id": voice_id}},
stream=True)
for line in r.iter_lines():
if line.startswith(b"data: "):
evt = json.loads(line[6:])
if evt.get("done"): break
pcm = base64.b64decode(evt["data"]) # feed your player
WS/ws
The live-call surface — what Gandr itself runs in production for live voice applications. Open one socket per call, keep it for the whole conversation, and send one JSON message per utterance. Audio returns as binary PCM16LE frames, followed by one JSON stats message per utterance.
wss://tts.gandr.ai/ws
# headers (either):
x-api-key: gnd_your_key
Authorization: Bearer gnd_your_key
| Field | Type | Notes |
|---|---|---|
| text | string | The utterance to speak. |
| lang | string | Default en. |
| voice_id | string | Your name for the call's voice — any stable string. |
| voice_wav_b64 | string | Reference audio, first utterance only. The voice stays registered for the connection. Omit it to use a stock voice id. |
| expressiveness … | floats | All six expression controls ride each message; emotion, pronunciation_dict, and the inline tags work here too. |
| output_sample_rate | int | Default 24000. |
Send text of any length — a word or a full paragraph. Multi-sentence input streams as one continuous, gap-free response: the pipeline's stated budget for that path is first audio in ~130 ms and no mid-stream stalls, however long the input. The published benchmark figure remains what the harness measures on a single stream — 107 ms p50 · 108 ms p95 to first audio.
Server → client
- Binary frames: PCM16LE audio, streamed as rendered.
- End of utterance:
{"ttfa_ms": …, "audio_ms": …}— the server's own latency accounting, sent with every utterance. {"error": "need_voice"}— resend withvoice_wav_b64(e.g. after reconnecting to a different node).{"error": "busy"}— soft backpressure under burst load; the socket survives, retry the utterance after ~0.5 s.
const ws = new WebSocket("wss://tts.gandr.ai/ws", { headers: { "x-api-key": KEY } });
ws.onopen = () => ws.send(JSON.stringify({
text: "Hola, gracias por llamar hoy.", lang: "es",
voice_id: "caller-42", voice_wav_b64: refB64,
expressiveness: 0.5, output_sample_rate: 24000
}));
ws.onmessage = (m) => {
if (m.data instanceof ArrayBuffer) player.feed(m.data); // audio
else console.log("utterance stats", JSON.parse(m.data)); // {ttfa_ms, audio_ms}
};
GET/v1/prewarm
Workers scale to zero when idle. Fire a prewarm the moment a call starts — the SIP invite is the natural trigger. It returns immediately and boots a worker in the background, so the pipeline is warm by the time audio is needed.
curl https://tts.gandr.ai/v1/prewarm \
-H "Authorization: Bearer $GANDR_KEY"
# {"status":"warming"} — answers in ~0.3 s; the boot
# continues behind it
Skip it and a request that lands on a cold engine rides out the boot itself — a cold request measured ~23 s (July 2026). Both SDKs below fire the prewarm for you.
GET/v1/usage
Returns the calling key's current-month character usage — and, on trial keys, the remaining quota. Authenticate the same way as every other endpoint.
curl -s https://tts.gandr.ai/v1/usage -H "x-api-key: $GANDR_KEY"
# {"key_id":"…","month":"2026-07","monthly_char_quota":…,"pod":"…",
# "chars":3433,"audio_s":207.2,"requests":46}
POST/speaker_match
Same-speaker verification for clone enrollment. Send two reference clips and get back the cosine similarity of their speaker embeddings (CAMPPlus x-vectors — the same encoder the clone path conditions on). Use it to check that a new reference clip belongs to the speaker you already enrolled before accepting it as a clone source.
curl -s https://tts.gandr.ai/speaker_match \
-H "Authorization: Bearer $GANDR_KEY" -H "content-type: application/json" \
-d '{"wav_a_b64":"<base64 wav>","wav_b_b64":"<base64 wav>"}'
# {"cosine": 0.91} — same speaker scores near 1.0
Both clips follow the same limits as clone references (WAV, base64, ≤ ~1.5 MB / ~30 s). Unrelated speakers land far lower than same-speaker pairs; pick the threshold that fits your enrollment policy.
SDKs
Python (gandr_tts) and Node (gandr-tts) wrap the three calls that matter — prewarm, say, and stream. Both auto-prewarm and retry through a cold wake, so a first call after idle just works. They are not on the public registries yet — ask for the package with your key at contact@gandr.ai — and the wire formats above work from any HTTP client.
from gandr_tts import GandrTTS
tts = GandrTTS("gnd_your_key")
tts.prewarm()
wav = tts.say("Hello there.", voice_wav="ref.wav") # WAV bytes
for pcm in tts.stream("Live call speech.", voice_wav="ref.wav"):
play(pcm) # PCM16LE @ 24 kHz
const { GandrTTS } = require("gandr-tts");
const tts = new GandrTTS("gnd_your_key");
await tts.prewarm();
const wav = await tts.say("Hello there.", { voiceWav: "ref.wav" });
for await (const pcm of tts.stream("Live call speech.", { voiceWav: "ref.wav" }))
play(pcm);
Audio output
- Native render is 24 kHz mono PCM. Request any
sample_rate— 8000 for telephony trunks — and resampling happens server-side, out of your hot path. /v1/tts/bytesreturns a WAV container./v1/tts/sseand/wsstream raw PCM16LE.- All audio is watermarked at generation time for provenance.
- No training on your text or reference audio. Ever.
Limits
| Limit | Value |
|---|---|
| Characters per request | 2,000 |
| Clone reference size | ~1.5 MB base64 (~30 s WAV; 5–10 s is plenty) |
| Request rate | 120 requests / minute / key |
| Monthly characters | Unmetered on production lines; trial keys check /v1/usage |
| Request body | 4 MB at the edge |
Errors
| Status | Body | Meaning |
|---|---|---|
| 400 | {"error":"bad_json"} | Body isn't valid JSON. |
| 400 | {"error":"transcript required, <= 2000 chars"} | Missing or oversized transcript. |
| 400 | {"error":"voice required: …"} | No valid voice object (bad mode, unknown id, or oversized reference). |
| 401 | {"error":"invalid_api_key"} | Missing or unrecognized key. |
| 429 | {"error":"rate_limited"} | Over 120 requests/min — back off briefly. |
| 429 | {"error":"quota_exceeded","monthly_chars":…} | Trial-key monthly quota reached — production lines never see this. |
| 503 | {"error":"at_capacity"} | Node is saturated; retry with backoff — the fleet scales within seconds. |
On the WebSocket, overload is softer: {"error":"busy"} arrives in-band, the connection survives, and an immediate retry succeeds as capacity frees.
Questions, higher limits, or a pilot key: contact@gandr.ai.
