# Gandr TTS: complete API documentation (machine-readable)
# API: https://tts.gandr.ai · docs: https://gandr.ai/docs · contact@gandr.ai
# This one file is the full developer documentation, written to be consumed by
# AI coding assistants. Paste it into Claude, ChatGPT, Cursor or Copilot and
# say "integrate Gandr TTS into this project".
# OpenAPI: https://gandr.ai/openapi.yml · AsyncAPI: https://gandr.ai/asyncapi.yml
# LiveKit guide: https://gandr.ai/integrations/livekit
# Single-file SDKs: https://gandr.ai/sdk/gandr_tts.py · /sdk/gandr-tts.js
#                   LiveKit plugin: /integrations/livekit/gandr_tts.py
# Last verified against the production API: 2026-10-15

## Overview
Real-time text-to-speech with instant voice cloning, gap-free streaming at any
length, multilingual, with automatic nearest-region routing. Three synthesis
endpoints share one request shape.

Gandr is backed by Y Combinator.

First-audio latency: first audio byte in 146 ms over the open internet,
116 ms p50 first audio, server side warm. What also holds and is freshly
measured: gap-free streaming. Production outruns playback, so audio starts
before the render finishes, the buffer grows as the utterance runs, and
longer text is safer, not riskier. Every utterance also returns its own
ttfa_ms, so your integration can measure itself on your own path.

## The publishable numbers (canonical, mirrors gandr.ai/pricing)
These are the only numbers Gandr publishes; quote them as written, with the
vantage attached. Source of truth: https://gandr.ai/pricing, same-day.
No client-side latency figure is published at present; the server-side
basis in the Overview above is the only latency statement, and it never
travels without that basis.
- Word error rate 1.98% against a 2.17% human reference, same scorer.
- 23 languages, one engine, every voice.
- Every render watermarked, everywhere.
- No user data in any training set.
- Prices: Free $0 for 50,000 tokens. $10/mo for 1M tokens, resets
  monthly. $50/mo for 5M. $150/mo per unlimited, unmetered stream ($180
  month to month). Enterprise from 500 lines. The unit is tokens; one token
  is one character.

Time to first audio BY REQUESTED RATE: 24000, 22050 and 16000 Hz all land close
together. 8000 Hz is materially slower to first audio than every other rate
(measured 2026-07-31, five runs per rate per voice on warm workers). Request
24000 (the default) even for telephony and let your SIP stack downsample
locally, where it is free. If you need a narrowband source, 16000 costs nothing.

## Base URL and auth
Base URL: https://tts.gandr.ai
Auth (every request): header `x-api-key: gnd_...` OR `Authorization: Bearer gnd_...`

## Endpoints
1) WebSocket  wss://tts.gandr.ai/ws  , live calls/agents.
2) POST /v1/tts/sse                  , HTTP streaming (Server-Sent Events).
3) POST /v1/tts/bytes                , one-shot audio/wav render (quality lane).
4) POST /v1/vapi                     , Vapi custom-voice contract (see Integrations).
5) GET  /v1/voices                   , list stock voices.
6) GET  /v1/usage                    , month-to-date characters + requests for your key.
7) POST /v1/key/usage                , the ledger read: quota_chars, chars_used, remaining.
8) GET  /v1/prewarm                  , boots a worker in the background; returns instantly.
9) POST /v1/audio/speech             , OpenAI-compatible speech: point any OpenAI SDK's
                                       base_url at https://tts.gandr.ai/v1. Formats mp3,
                                       wav, pcm; the default is mp3, exactly like OpenAI.

## 60-second quickstart (curl)
curl -X POST https://tts.gandr.ai/v1/tts/bytes \
  -H "x-api-key: gnd_your_key" -H "content-type: application/json" \
  -d '{"transcript":"Hello from Gandr.","language":"en",
       "voice":{"mode":"id","id":"gandr-mia"},
       "output_format":{"sample_rate":24000}}' \
  --output hello.wav

## Quickstart (Python, no SDK)
import requests
r = requests.post("https://tts.gandr.ai/v1/tts/bytes",
    headers={"x-api-key": "gnd_your_key"},
    json={"transcript": "Hello from Gandr.", "language": "en",
          "voice": {"mode": "id", "id": "gandr-mia"},
          "output_format": {"sample_rate": 24000}})
open("hello.wav", "wb").write(r.content)

## Integrations (pick your stack)
MCP server (sets Gandr up from these docs, and speaks, inside any MCP agent: Claude Code, Cursor, etc.)
  pip install gandr-mcp
  # Claude Code:
  claude mcp add gandr -e GANDR_API_KEY=gnd_... -- gandr-mcp
  # other MCP clients, add to the config:
  {"mcpServers":{"gandr":{"command":"gandr-mcp","env":{"GANDR_API_KEY":"gnd_..."}}}}
  Setup tools (no key needed): get_setup_guide (working setup code for a stack),
         list_integrations, search_docs, read_docs (these docs, live).
  Speech tools: synthesize (text to WAV base64, voice/language/sample-rate/pitch/pacing),
         list_voices, list_languages (23), get_usage (tokens used by the key).
  Repo: https://github.com/Gandr-AI/gandr-mcp
Official SDKs (published packages)
  Python: pip install gandr   (Python 3.9+, zero dependencies)
  Node:   npm install gandr   (Node 18+, zero dependencies)
  from gandr import Gandr; g = Gandr("gnd_..."); wav = g.say("Hello.")   # WAV bytes
  Node is ESM: import { Gandr } from "gandr"; await new Gandr("gnd_...").say("Hello.")
  Both take the key as an argument. Both return whole WAV files; to stream while
  rendering, use /v1/tts/sse, the WebSocket, or the single-file SDKs below.
Framework plugins
  LangChain:  pip install gandr-langchain  -> GandrText2SpeechTool()
  CrewAI:     pip install gandr-crewai     -> GandrTTSTool()
  Haystack:   pip install gandr-haystack   -> GandrTTS() pipeline component
  AI SDK (JS): npm install gandr-ai-sdk ai -> import { generateSpeech } from "ai"; const { audio } = await generateSpeech({ model: createGandr().speech("gandr-tts"), text })   (key from GANDR_API_KEY)
  n8n:        Settings -> Community nodes -> n8n-nodes-gandr
  Dify:       plugin at github.com/Gandr-AI/dify-plugin (tool node; gnd_ key credential)
  Repos: https://github.com/Gandr-AI (gandr-mcp, gandr-langchain, gandr-crewai,
         gandr-haystack, gandr-ai-sdk, n8n-nodes-gandr, gandr-livekit, gandr-pipecat,
         examples, community-integrations)
LiveKit Agents (Python), full guide https://gandr.ai/integrations/livekit
  Download the single-file plugin: https://gandr.ai/integrations/livekit/gandr_tts.py
  from gandr_tts import GandrTTS
  session = AgentSession(tts=GandrTTS(voice="gandr-mia"), ...)   # key from GANDR_API_KEY env
  Constructor: api_key, voice, lang, sample_rate (8000/16000/22050/24000),
  speed (0.6-1.5), volume (0.5-2.0), extra (dict merged into the request body,
  e.g. pronunciation_dict), base_url, timeout, http_session.
  Runtime: session.tts.update_options(voice=..., lang=..., speed=..., volume=...)
  Fallback: wrap in livekit.agents.tts.FallbackAdapter([GandrTTS(...), <secondary>]).
Pipecat (Python)
  Install: uv add pipecat-gandr   (documented community integration:
           docs.pipecat.ai/api-reference/server/services/tts/gandr)
  from pipecat_gandr import GandrTTSService
  tts = GandrTTSService(
      api_key=os.environ["GANDR_API_KEY"],
      params=GandrTTSService.InputParams(voice_id="gandr-mia"),
  )
  InputParams: voice_id, language, sample_rate (8000/16000/22050/24000),
  speed (0.6-1.5), volume (0.5-2.0), temperature, cfg_weight, seed,
  voice_wav_b64 (clone reference, sent on the first utterance per connection),
  word_timestamps (True asks the door for per-word offsets, fed into
  Pipecat's word-timestamp machinery).
  One WebSocket held across turns; utterances serialized in order; an
  interruption drops queued speech and reopens the connection.
Pipecat legacy single-file service (per-request HTTP):
  https://gandr.ai/sdk/gandr_pipecat.py
  GandrTTSService(api_key="gnd_...", voice="gandr-mia", word_timestamps=True)
  word_timestamps=True sets add_timestamps on the request; the utterance
  tail then carries word-level timing pairs for captions or alignment.

Vapi (no code)
  Assistant → Voice → custom-voice, set two fields:
    URL:    https://tts.gandr.ai/v1/vapi?voice=gandr-mia
    Secret: your gnd_ key   (Vapi sends it as the X-VAPI-SECRET header)
  The endpoint implements Vapi's custom-voice contract natively: it receives
  {"message":{"type":"voice-request","text":...,"sampleRate":...}} and answers
  raw headerless PCM s16le mono at the requested sampleRate (8000/16000/22050/
  24000; telephony uses 8000). Swap the voice via the ?voice= query param.
  One-click setup UI (finds your assistants, configures them):
    https://gandr.ai/quickstart/
Single-file SDKs (anything else)
  Python: https://gandr.ai/sdk/gandr_tts.py   (pip install websockets requests)
  Node:   https://gandr.ai/sdk/gandr-tts.js   (Node 18+, npm install ws)
  from gandr_tts import GandrTTS
  tts = GandrTTS("gnd_your_key"); tts.prewarm()
  wav = tts.say("Hello there.", voice_id="gandr-mia")   # or voice_wav="ref.wav" to clone
  for pcm in tts.stream("Live call speech.", voice_id="gandr-mia"): play(pcm)
  # Node: say/stream take { voiceId: "gandr-mia" } or { voiceWav: "ref.wav" }
  # per-utterance extras + final stats (timestamps, ttfa_ms, audio_ms):
  for pcm in tts.stream("...", extra={"add_timestamps": "word"}): play(pcm)
  tts.last_stats["word_timestamps"]   # Node: { extra: {...} } -> tts.lastStats
Raw REST/WebSocket, full shapes below; specs at /openapi.yml + /asyncapi.yml.

## Voice (required on every synthesis request)
Clone per request (instant, no upload step):
  "voice": {"mode": "clone", "wav_b64": "<base64 WAV, 5-30s clean speech, <=1.5MB>"}
  The reference is fingerprinted and cached, repeat calls with the same clip
  skip re-cloning, so cloned voices serve at stock-voice latency after first use.
Stock voices (multilingual; no reference needed):
  "voice": {"mode": "id", "id": "gandr-mia"}
  gandr-mia (easy, unhurried, f, flagship) · gandr-ava (warm, friendly, f)
  gandr-jenny (steady, sincere, f) · gandr-dane (smooth, measured, m)
  gandr-leo (clear, professional, m) · gandr-lewis (warm, even, m)
  List programmatically: GET /v1/voices ->
  {"voices":[{"id":"gandr-ava","name":"Ava","language":"multilingual"}, ...]}
WebSocket uses flat fields instead: "voice_id": "gandr-mia" or
"voice_wav_b64" (first turn only).

## REST request body (both /v1/tts/bytes and /v1/tts/sse)
{
  "transcript": "Hello from Gandr.",          // required (max 2000 chars)
  "language": "en",                            // en verified; more in beta, see https://gandr.ai/languages/
  "voice": {"mode": "id", "id": "gandr-ava"}, // or {"mode":"clone","wav_b64":"..."}
  "output_format": {"sample_rate": 24000},     // 8000 / 16000 / 22050 / 24000
  "temperature": 0.9,                          // prosodic variation (melody) 0.1..1.2; omit it and the door fills it in per voice (see Expression recipe)
  "cfg_weight": 0.3,                           // guidance/pacing 0.2 spacious .. 1.0 brisk; omit it and the door sends nothing
  "speed": 1.0,                                // 0.6..1.5, pitch-preserving, post-synthesis
  "volume": 1.0,                               // 0.5..2.0, soft-ceiling mastered, never clips
  "seed": 42,                                  // optional: same seed + text + voice + params -> the same audio
  "add_timestamps": true,                      // optional: true/"word" = words; "char"/"all" adds char spans
  "pronunciation_dict": [                      // per-request sounds-like replacements
    {"text": "tchoupitoulas", "pronunciation": "chop-uh-TOO-liss"}
  ]
}
"text" is accepted as an alias for "transcript"; "lang" for "language".
Two fields are deliberately absent from the body above, and neither belongs in
code you write today: "expressiveness" is ACCEPTED AND INERT (remapped off the
engine 2026-07-29; measured +0.33 semitones, p=0.804) and has no default,
because the door never injects it; and "emotion" is RETIRED, the door
discards it before synthesis. Sending either changes nothing you can hear, and
a request that carries either one still succeeds, if your code sends them
today, it keeps working; it just is not steering anything. They differ in the
one way that matters if you cache or diff by seed: "emotion" is discarded, so
the bytes come back identical, while "expressiveness" is still forwarded and
perturbs the sample, at a held seed, two different values return different
audio. Treat it as a second seed, not a dial.

## Responses
/v1/tts/bytes: 200 with Content-Type audio/wav (mono PCM16 at the requested rate).
/v1/tts/sse:   text/event-stream; each audio event is
                 data: {"data": "<base64 PCM16LE chunk>"}
               final event carries render stats:
                 data: {"done": true, "ttfa_ms": <int>, "audio_ms": <int>}
               with "add_timestamps" set, the final event also carries timings
               (seconds from audio start, aligned against the rendered audio,
               computed after the last audio chunk):
                 "word_timestamps": {"words": [...], "start": [...], "end": [...]}
               "add_timestamps": "char" or "all" additionally returns
                 "char_timestamps": {"chars": [...], "start": [...], "end": [...]}
               Alignment is production-verified for English.
WebSocket:     wss://tts.gandr.ai/ws with header Authorization: Bearer gnd_...
               Send one JSON message per utterance:
                 {"text": "...", "lang": "en", "voice_id": "...",
                  "voice_wav_b64": "<first turn only>",
                  "output_sample_rate": 24000, "add_timestamps": "word"}
               Receive binary PCM16LE frames (mono, at output_sample_rate),
               then a final JSON line {"ttfa_ms":.., "audio_ms":..}
               (+"truncated": true if a stream ends early, retry the remainder). add_timestamps works exactly
               like SSE: the final JSON line carries word_timestamps /
               char_timestamps on live calls.
/v1/vapi:      200 with application/octet-stream, raw headerless PCM s16le
               mono at the requested sampleRate. (This endpoint exists for
               Vapi's contract; everything else should use bytes/sse/ws.)

## Transcript controls (all endpoints)
Automatic readback normalization (English, no markup): times -> "nine A M",
money/percent -> words, codes OX49 -> "O X, four nine", phone-shaped
555-0142 -> digit-by-digit, long IDs/zips/tracking -> digit-by-digit.
<spell>TKT4829XB</spell>         , character-by-character read (codes, IDs, serials)
<break time="800ms"/>            , natural pause at that point (duration advisory)
pronunciation_dict (body field)  , see schema above; lowercase entries also match
                                    sentence-start capitalization

## Expression recipe
Two dials move a read on this engine, and expressiveness is not one of them
(it was remapped off the engine on 2026-07-29; measured effect +0.33
semitones, p=0.804). Slower and more spacious: lower cfg_weight. Wider
melodic range: raise temperature. Raise the melody and loosen the guidance
together, so the bigger delivery stays unhurried:
  {"temperature": 0.9, "cfg_weight": 0.3}
SEND THEM, because what you get by omitting them is not one house setting.
temperature is the ONE expression field the door fills in, and it fills it in
per voice: stock jenny/ava/mia/lewis 0.5, dane 0.65, leo 0.8, the door's
floor for a voice its map does not name (read off the live door env, all three
regions, 2026-08-06). What a CLONE inherits was not part of that read, so send
temperature explicitly when you clone. Omit cfg_weight and the door forwards
nothing, the engine rests at its own 0.5. Omit expressiveness and nothing is
sent either; it has no default and is inert whatever you send, inert, not
discarded: at a held seed two values return different audio.
`emotion` is retired: the door discards the field before synthesis, and the
same request at a held seed returns identical audio whichever emotion is
named. Shape the read with the transcript, temperature and cfg_weight.

## Overflow / prewarm
The fleet is always on. Overflow spills to a fallback lane that can take longer
on its first request, then reaches full speed shortly after. GET /v1/prewarm
returns instantly and opens the path in the background, fire it when a call
starts (e.g. SIP invite) and first audio is full-speed by the time you
synthesize. Both SDKs auto-prewarm and retry through an overflow response. For
agent frameworks, a fallback adapter (see LiveKit above) covers that edge
cleanly.

## Concurrency
Streams are serialized (one utterance at a time per stream). For batch or
render-farm workloads, contact contact@gandr.ai.

## Errors
400 {"error":"bad_json"} or a message naming the field · 401
{"error":"invalid_api_key"} · 402
{"error":"quota_exceeded","used_chars":..,"quota_chars":..,"requested_chars":..}
· 429 {"error":"rate_limited"} per-key rate limit (120 req/min, raised on
request) · 503 {"error":"at_capacity"}, retry with backoff.
402 IS THE ONE TO HANDLE: it means the characters this key was bought with
are spent. It is 402 and not 429 deliberately, it is not a rate limit and
retrying does not help; buy more characters, or move to a stream. A stream
key is not billed by the character and is unlimited and unmetered, so 402 is
not a code you should plan around on one.
The count is kept per door, so treat any remaining figure as approximate. The WebSocket answers the same errors in
band, on an open socket, rather than closing it.
A stream that ended early carries "truncated": true in its final event:
TREAT THAT AS A FAILURE AND RETRY the utterance rather than playing what
arrived. Half a sentence read confidently is worse than a retry, and on a
clinical or financial call it is much worse. Identical requests may be
served from cache, so retries are safe; pass "seed" when you need the same
audio back deliberately.

## Security & status
Security & data handling: https://gandr.ai/security/
Live status: /status/
All generated audio carries an inaudible machine-readable watermark.

## Getting a key
https://gandr.ai/join/, sign in and claim the free key on your console:
50,000 tokens (one token is one character), one per person. Paid
plans are set up with us: tell us what you are running at
https://gandr.ai/sales/#book and the key lands the same day. From
500 lines: https://gandr.ai/waitlist/, where we size the fleet with you
first.

## Try it interactively
https://gandr.ai/quickstart/, paste your key once: audition every stock
voice, one-click Vapi assistant setup, plugin downloads for LiveKit and
Pipecat. (The old /access.html console on the API host is retired; every
human page there now redirects to gandr.ai.)
