Skip to content

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/bytes returns a complete WAV.
  • Streaming over HTTP → POST /v1/tts/sse streams base64 PCM chunks as they render.
  • Live conversation → WS /ws holds 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:

ModeShapeBehavior
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).

IDCharacter
gandr-jennywarm, natural (f)
gandr-avawarm, friendly (f)
gandr-miawarm, natural (f)
gandr-danesmooth, measured (m)
gandr-leoclear, professional (m)
gandr-lewiswarm, even (m)

List the roster programmatically — new voices appear here the moment they publish:

shell
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.

FieldRange · defaultWhat it shapes
expressiveness0.25 – 2.0 · 0.5Emotional intensity: 0.25 reads flat and neutral, 2.0 lively and animated. The 0.5 default matches the stock-voice tuning.
temperature0.1 – 1.2 · 0.8 clonedProsodic 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_weight0.2 – 1.0 · 0.5Guidance strength, which also sets pacing: 0.2 slower and spacious, 1.0 tight and brisk.
speed0.6 – 1.5 · 1.0Playback rate without pitch change. Applied after synthesis, so wording and voice are untouched.
volume0.5 – 2.0 · 1.0Output gain without changing the voice. Applied after mastering with a soft ceiling, so it never hard-clips.
seedinteger · unsetReproducibility: 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.

the expressive recipe
# 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.

ControlBehavior
<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.
emotionA 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_dictPer-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.
all four, in one request body
{
  "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.

FieldTypeNotes
transcriptstringRequired (alias: text). ≤ 2,000 characters per request; <spell> and <break> tags ride inline.
languagestringISO code, default en. 23 languages.
voiceobjectRequired — see Voices & cloning.
expressiveness … seedfloat · intThe six expression controls — ranges and defaults above.
emotion · pronunciation_dictstring · listTranscript controls — see above.
output_format.sample_rateintDefault 24000. Any rate; resampled server-side.
curl — clone once, get a WAV back
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}
python — play as it arrives
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.

connect
wss://tts.gandr.ai/ws
# headers (either):
x-api-key: gnd_your_key
Authorization: Bearer gnd_your_key
FieldTypeNotes
textstringThe utterance to speak.
langstringDefault en.
voice_idstringYour name for the call's voice — any stable string.
voice_wav_b64stringReference audio, first utterance only. The voice stays registered for the connection. Omit it to use a stock voice id.
expressiveness …floatsAll six expression controls ride each message; emotion, pronunciation_dict, and the inline tags work here too.
output_sample_rateintDefault 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 with voice_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.
javascript — one live call
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 — score two clips
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.

python
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
node
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/bytes returns a WAV container. /v1/tts/sse and /ws stream raw PCM16LE.
  • All audio is watermarked at generation time for provenance.
  • No training on your text or reference audio. Ever.

Limits

LimitValue
Characters per request2,000
Clone reference size~1.5 MB base64 (~30 s WAV; 5–10 s is plenty)
Request rate120 requests / minute / key
Monthly charactersUnmetered on production lines; trial keys check /v1/usage
Request body4 MB at the edge

Errors

StatusBodyMeaning
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.