openapi: 3.1.0
info:
  title: Gandr TTS API
  version: "1.0"
  description: >-
    Real-time text-to-speech with instant voice cloning. Gap-free
    streaming (production outruns playback, so audio starts before the
    render finishes at any length), multilingual. First audio byte in
    146 ms over the open internet, 116 ms p50 first audio, server side
    warm. Full docs:
    https://gandr.ai/docs, machine-readable: /llms.txt.
    WebSocket endpoint (wss://tts.gandr.ai/ws) is documented in llms.txt
    (out of OpenAPI scope).
  contact:
    email: contact@gandr.ai
servers:
  - url: https://tts.gandr.ai
security:
  - apiKey: []
  - bearer: []
components:
  securitySchemes:
    apiKey: {type: apiKey, in: header, name: x-api-key}
    bearer: {type: http, scheme: bearer}
  schemas:
    Voice:
      description: >-
        mode is exactly "id" or "clone". Any other value (a common guess
        is "preset") answers 400 {"error":"voice required"} even though
        the voice field is present, so on that error with a voice in the
        body, check the mode first. A bare string ("voice": "gandr-mia")
        is tolerated as an alias for the id form.
      oneOf:
        - type: object
          required: [mode, id]
          properties:
            mode: {const: id}
            id: {type: string, example: gandr-jenny}
        - type: object
          required: [mode, wav_b64]
          properties:
            mode: {const: clone}
            wav_b64:
              type: string
              description: base64 WAV reference, 5-30s clean speech, <=1.5MB.
    PronunciationEntry:
      type: object
      required: [text, pronunciation]
      properties:
        text: {type: string, example: tchoupitoulas}
        pronunciation: {type: string, example: chop-uh-TOO-liss}
    TTSRequest:
      type: object
      required: [transcript, voice]
      properties:
        transcript: {type: string, maxLength: 2000}
        language: {type: string, example: en}
        voice: {$ref: "#/components/schemas/Voice"}
        output_format:
          type: object
          properties:
            sample_rate: {type: integer, default: 24000, example: 8000}
        # NONE OF THE THREE EXPRESSION FIELDS PUBLISHES A `default`, and
        # each is missing one for its own reason: the door injects
        # temperature and nothing else, and the temperature it injects is
        # chosen from the voice, so there is no single value a codegen
        # tool could fill in without being wrong for most requests. Read
        # off the live process env of all three doors, both lanes,
        # identical everywhere (2026-08-06): REST_DEFAULT_TEMPERATURE
        # plus a REST_VOICE_TEMPS per-voice map. This file used to
        # publish a "flat house read" of expressiveness 0.3 / cfg_weight
        # 0.6 / temperature 0.45: that was a 2026-07-29 measurement, and
        # the door env has since been moved to mirror the engine's
        # per-voice tuning. The triple is gone.
        expressiveness:
          type: number
          minimum: 0.25
          maximum: 2.0
          # NO `default` ON PURPOSE: the door never injects this field, so
          # omitting it sends nothing. A `default` here would tell a
          # codegen tool to fill in a value the API never applies.
          description: >-
            Accepted and inert: a request that carries it still succeeds,
            and it changes nothing you can hear. The engine remapped the
            field on 2026-07-29 and a controlled measurement puts its
            effect on pitch range at +0.33 semitones (p=0.804). It is not
            discarded the way `emotion` is, though, at a held seed, two
            different values return different audio, because it still
            perturbs the sample. So it behaves as a second seed rather
            than a dial: leave it out of any request you cache or compare
            byte for byte. The door never fills it in, so it has no
            default. Use temperature.
        temperature:
          type: number
          minimum: 0.1
          maximum: 1.2
          # NO `default` HERE EITHER, for the opposite reason. The door
          # does fill this one in: but it picks the value from the
          # voice, so there is no single number to publish as "what the
          # server assumes when you omit the field". A codegen tool that
          # materialised 0.8 into every request would be sending a value
          # five of the six stock voices never see. How much that costs is
          # not something we can put a number on: the only sweep on record
          # runs 0.9 to 1.2 on one voice, so nothing was measured across
          # 0.5 to 0.8 and no claim is made about it. The map belongs in
          # the description, where it carries the voice names with it.
          description: >-
            Prosodic variation, pitch range and melody. 0.1 is locked and
            monotone; set it low for strict, repeatable IVR lines. This is
            the one expression field the door fills in when you omit it,
            and it fills it in PER VOICE: jenny, ava, mia and lewis at
            0.5 · dane at 0.65 · leo at 0.8, which is the door's floor for
            a voice its per-voice map does not name. What a CLONE inherits
            was not part of that reading of the door, so send the field
            explicitly when you clone. Two requests that differ only in
            whether the field was present are two different reads, so send
            the value you want.
        cfg_weight:
          type: number
          minimum: 0.2
          maximum: 1.0
          # NO `default`: omit it and the door forwards nothing at all.
          description: >-
            Guidance strength, which also sets pacing: 0.2 slower and
            spacious, 1.0 tight and brisk. Omit it and the door sends
            nothing; the engine then rests at its own 0.5 (an engine-side
            layer, documented there, not readable from the door), which
            is why no default is published here.
        speed: {type: number, minimum: 0.6, maximum: 1.5, default: 1.0}
        volume: {type: number, minimum: 0.5, maximum: 2.0, default: 1.0}
        seed:
          type: integer
          description: >-
            Accepted and forwarded, but renders are currently
            non-deterministic: the engine draws its own entropy per
            request, so two identical requests are two takes. Treat
            every render as fresh; do not build on seed reproducibility.
        pronunciation_dict:
          type: array
          items: {$ref: "#/components/schemas/PronunciationEntry"}
        add_timestamps:
          oneOf:
            - {type: boolean}
            - {type: string, enum: [word, char, all]}
          default: false
          description: >-
            SSE and WebSocket, the final SSE event (or final WS JSON line)
            carries word_timestamps ({words, start, end}, seconds from audio
            start), aligned against the rendered audio with no
            streaming-latency cost. "char" or "all" additionally returns
            char_timestamps ({chars, start, end}) at sub-word granularity.
  responses:
    BadRequest: {description: 'Malformed body, the JSON names the field. {"error":"bad_json"} when the body will not parse at all.'}
    Unauthorized: {description: 'Missing or invalid API key: {"error":"invalid_api_key"}.'}
    QuotaExceeded:
      description: >-
        {"error":"quota_exceeded","used_chars":…,"quota_chars":…,"requested_chars":…},
        the characters this key was bought with are spent. 402 and not
        429 on purpose: it is not a rate limit and retrying does not help.
        Stream plans are unmetered and never see it. The ledger is kept
        per door, so treat any remaining figure as approximate.
    RateLimited: {description: 'Per-key rate limit, 120 requests/minute: {"error":"rate_limited"}. Back off briefly.'}
    AtCapacity: {description: 'Node saturated: {"error":"at_capacity"}. Retry with backoff; the fleet scales within seconds.'}
paths:
  /v1/tts/bytes:
    post:
      summary: One-shot WAV render (quality lane)
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/TTSRequest"}
      responses:
        "200":
          description: Rendered audio.
          content:
            audio/wav:
              schema: {type: string, format: binary}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "402": {$ref: "#/components/responses/QuotaExceeded"}
        "429": {$ref: "#/components/responses/RateLimited"}
        "503": {$ref: "#/components/responses/AtCapacity"}
  /v1/tts/sse:
    post:
      summary: Streaming synthesis over Server-Sent Events
      requestBody:
        required: true
        content:
          application/json:
            schema: {$ref: "#/components/schemas/TTSRequest"}
      responses:
        "200":
          description: >-
            text/event-stream. Audio events: data: {"data": "<base64 PCM16LE>"}.
            Final event: data: {"done": true, "ttfa_ms": int, "audio_ms": int}.
          content:
            text/event-stream:
              schema: {type: string}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "402": {$ref: "#/components/responses/QuotaExceeded"}
        "429": {$ref: "#/components/responses/RateLimited"}
        "503": {$ref: "#/components/responses/AtCapacity"}
  /v1/audio/speech:
    post:
      summary: OpenAI-compatible speech route (drop-in base_url swap)
      description: >-
        The OpenAI audio.speech contract. Point any OpenAI SDK or
        OpenAI-speaking tool at base_url https://tts.gandr.ai/v1 with a
        Gandr key: input is required (2000 characters per request),
        OpenAI voice names alias onto the Gandr roster and gandr-* ids
        pass through, model is accepted and ignored, speed clamps to
        0.6-1.5. response_format takes mp3, wav or pcm and defaults to
        mp3 exactly like OpenAI: mp3 is 128k CBR mono at 24 kHz, wav is
        24 kHz 16-bit mono, pcm is the same samples headerless. This
        route renders English (language is pinned server-side). An
        unsupported format returns the OpenAI-shaped 400.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [input]
              properties:
                input: {type: string, maxLength: 2000}
                voice: {type: string, example: alloy}
                model: {type: string, example: tts-1}
                response_format: {type: string, enum: [mp3, wav, pcm], default: mp3}
                speed: {type: number, minimum: 0.6, maximum: 1.5}
      responses:
        "200":
          description: Rendered audio; the content type follows response_format.
          content:
            audio/mpeg:
              schema: {type: string, format: binary}
            audio/wav:
              schema: {type: string, format: binary}
            application/octet-stream:
              schema: {type: string, format: binary}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "402": {$ref: "#/components/responses/QuotaExceeded"}
        "429": {$ref: "#/components/responses/RateLimited"}
        "503": {$ref: "#/components/responses/AtCapacity"}
  /v1/voices:
    get:
      summary: List stock voices
      responses:
        "200":
          description: Voice catalog.
          content:
            application/json:
              schema:
                type: object
                properties:
                  voices:
                    type: array
                    items:
                      type: object
                      properties:
                        id: {type: string, example: gandr-ava}
                        name: {type: string, example: Ava}
                        language: {type: string, example: multilingual}
  /v1/usage:
    get:
      summary: Month-to-date usage for the calling key
      responses:
        "200":
          description: Character count and request totals.
          content:
            application/json:
              schema: {type: object}
        "401": {$ref: "#/components/responses/Unauthorized"}
  /v1/key/usage:
    post:
      summary: The ledger read for the calling key (any method works)
      responses:
        "200":
          description: What the key holds and what it has spent, from the quota ledger.
          content:
            application/json:
              schema:
                type: object
                properties:
                  name: {type: string}
                  quota_chars: {type: integer, example: 50000}
                  chars_used: {type: integer, example: 18250}
                  remaining: {type: integer, example: 81750}
        "401": {$ref: "#/components/responses/Unauthorized"}
  /v1/prewarm:
    get:
      summary: Boot a worker in the background (returns instantly)
      responses:
        # 202, not 200: measured against the production door, which is
        # also what the engine's own reference and gandr.ai/docs publish.
        "202":
          description: '{"status": "warming"}, accepted; the boot continues behind it.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: {type: string, example: warming}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "503": {description: No worker is available to wake right now.}
