asyncapi: 3.0.0
info:
  title: Gandr TTS WebSocket API
  version: "1.0"
  description: >-
    Real-time streaming synthesis over a held WebSocket, lowest-latency path,
    designed for live calls and agents: keep one socket open across a call and
    send one JSON message per utterance. REST endpoints are specified in
    /openapi.yml; full docs at /docs.html and /llms.txt.
servers:
  production:
    host: tts.gandr.ai
    protocol: wss
    description: >-
      Connect to wss://tts.gandr.ai/ws with header
      "Authorization: Bearer gnd_...".
channels:
  ws:
    address: /ws
    messages:
      synthesize:
        $ref: "#/components/messages/Synthesize"
      audioFrame:
        $ref: "#/components/messages/AudioFrame"
      renderStats:
        $ref: "#/components/messages/RenderStats"
      socketError:
        $ref: "#/components/messages/SocketError"
operations:
  sendUtterance:
    action: send
    channel: {$ref: "#/channels/ws"}
    summary: One JSON message per utterance (voice_wav_b64 on the first only).
    messages:
      - $ref: "#/channels/ws/messages/synthesize"
  receiveAudio:
    action: receive
    channel: {$ref: "#/channels/ws"}
    summary: >-
      Binary PCM16LE frames (mono, output_sample_rate), then one JSON line of
      render stats per utterance.
    messages:
      - $ref: "#/channels/ws/messages/audioFrame"
      - $ref: "#/channels/ws/messages/renderStats"
      - $ref: "#/channels/ws/messages/socketError"
components:
  messages:
    Synthesize:
      name: synthesize
      contentType: application/json
      payload:
        type: object
        required: [text]
        properties:
          text: {type: string, maxLength: 2000}
          lang: {type: string, example: en}
          voice_id:
            type: string
            example: gandr-jenny
            description: Stock or previously-cloned voice ID.
          voice_wav_b64:
            type: string
            description: >-
              base64 WAV reference (5-30s clean speech, <=1.5MB). Send on the
              first utterance only; the fingerprint is cached for the call.
          # Same ranges as /openapi.yml, and no `default` on any of the
          # three expression fields for the same reasons. The WS lane was
          # read alongside the REST lane on 2026-08-06: the door injects
          # TEMPERATURE ONLY, chosen from the voice, and nothing fills in
          # cfg_weight or expressiveness. The flat house read this file
          # used to publish (0.3 / 0.6 / 0.45) was a 2026-07-29
          # measurement and no longer describes any door.
          expressiveness:
            type: number
            minimum: 0.25
            maximum: 2.0
            # NO `default`: the door never injects it, so omitting it
            # sends nothing.
            description: >-
              Accepted and inert: a message carrying it still synthesizes,
              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 off any utterance you cache or
              compare byte for byte. It has no default because the door
              never injects one. Use temperature.
          temperature:
            type: number
            minimum: 0.1
            maximum: 1.2
            # NO `default` HERE EITHER: the door does fill this one in,
            # but per voice, so there is no single value to publish. A
            # codegen tool that materialised 0.8 into every message would
            # be sending a number five of the six stock voices never see.
            description: >-
              Prosodic variation, pitch range and melody. 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. 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 (engine-side
              layer, 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: >-
              The same seed, text, voice and parameters return the same
              audio (per serving region). Omit for a fresh take each time.
          pronunciation_dict:
            type: array
            items:
              type: object
              required: [text, pronunciation]
              properties:
                text: {type: string}
                pronunciation: {type: string}
          output_sample_rate: {type: integer, default: 24000, example: 8000}
          add_timestamps:
            oneOf:
              - {type: boolean}
              - {type: string, enum: [word, char, all]}
            default: false
            description: >-
              The final JSON line for this utterance also carries
              word_timestamps ("char" or "all" additionally
              char_timestamps), aligned against the rendered audio with no
              streaming-latency cost.
    AudioFrame:
      name: audioFrame
      summary: Binary PCM16LE audio frame (mono, output_sample_rate).
      contentType: application/octet-stream
      payload: {type: string, format: binary}
    # This socket answers its refusals IN BAND and keeps the connection, 
    # a client that only listens for close events will hang. Verified
    # against the production door 2026-08-05: an unknown key completes the
    # handshake and comes back as {"error":"invalid_api_key"} on the open
    # socket rather than a rejected upgrade.
    SocketError:
      name: socketError
      contentType: application/json
      summary: An error for this utterance. The connection stays open.
      payload:
        type: object
        required: [error]
        properties:
          error:
            type: string
            enum: [invalid_api_key, quota_exceeded, need_voice, busy, at_capacity]
            description: >-
              invalid_api_key, the door does not know this key.
              quota_exceeded, the characters this key was bought with are
              spent (the REST lane answers 402 for the same condition);
              stream plans are unmetered and never see it.
              need_voice, resend the utterance with voice_wav_b64, e.g.
              after reconnecting to a different node.
              busy / at_capacity, soft backpressure under burst load;
              retry the utterance after about half a second.
    RenderStats:
      name: renderStats
      contentType: application/json
      summary: Final JSON line after each utterance's audio.
      payload:
        type: object
        properties:
          ttfa_ms: {type: integer, description: Server time to first audio.}
          audio_ms: {type: integer, description: Total audio duration emitted.}
          truncated:
            type: boolean
            description: >-
              Present and true only if the stream ended early on a worker
              fault, retry the remainder.
          word_timestamps:
            type: object
            description: >-
              Present when add_timestamps was set on the utterance:
              {words, start, end}, seconds from audio start, aligned
              against the rendered audio.
          char_timestamps:
            type: object
            description: >-
              Present with add_timestamps "char" or "all":
              {chars, start, end} at sub-word granularity.
          word_timestamps_error:
            type: string
            description: >-
              Present instead of timestamps if alignment failed for this
              render; the audio itself is unaffected.
