{
  "openapi": "3.1.0",
  "info": {
    "title": "Kurdish TTS & STT API",
    "version": "1.2.0",
    "summary": "Text-to-speech and speech-to-text HTTP API for the Kurdish language (Sorani, Kurmanji & Badini).",
    "description": "Public developer API for Kurdish text-to-speech (TTS) and speech-to-text (STT). TTS covers Sorani (Central Kurdish, Arabic script), Kurmanji (Northern Kurdish, Latin script) and Badini/Behdinî (Northern Kurdish written in the Arabic script). STT covers Sorani and Kurmanji only — there is no Badini transcription model.\n\n**Authentication.** All endpoints except `GET /api/get-speakers` require an API key sent in the `x-api-key` header. TTS and STT use **separate key spaces** — a TTS key will not authenticate against the STT endpoints and vice-versa. Generate keys at https://www.kurdishtts.com/settings/api.\n\n**One TTS model.** Every synthesis request is rendered by V5. `model_version` does not choose a model or an engine — it chooses which **speaker group** your `speaker_id` is resolved in: `v3` (198 speakers), `v4` (664), or `v5` (20 curated, the six `badini_` speakers, plus the 13 Cast and Studio speakers on plans bought for the API). The groups share no IDs. A `badini_` id is the one exception to sending `model_version` yourself: it exists in no other group, so it resolves on v5 whatever you send. v3 and v4 are permanent selectors, not a deprecated path; existing integrations keep their current value and speaker IDs.\n\n**Gotchas worth knowing before you integrate:**\n- `speed` uses the industry-standard convention (higher = faster); it is clamped to 0.25–4.0.\n- `model_version` accepts `v3`, `v4`, or `v5` and defaults to `v3` when omitted. Because the groups share no IDs, send it explicitly — a `v4` speaker id with no `model_version` is resolved against `v3` and refused.\n- An UNKNOWN `model_version` returns 403 naming your plan, not 422. A speaker outside the selected group returns 400 under `v5` but 403 under `v3`/`v4`.\n- `/api/tts-stream` progressively streams SSE or raw PCM at 24 kHz. Its legacy WAV option is buffered.\n- The `websocket_url` returned by `/api/stt-stream-connect` is temporary and does not carry your API key.\n\n**AI agents.** The same TTS and STT are also exposed over the Model Context Protocol at https://www.kurdishtts.com/api/mcp (Streamable HTTP; `Authorization: Bearer <api-key>`), with the tools `synthesize_speech`, `transcribe_audio`, `start_streaming_transcription`, `list_voices`, `list_dialects` and `get_plan`. `list_voices`, `list_dialects` and `get_plan` need no API key. Setup guide: https://www.kurdishtts.com/docs/guides/claude-kurdish-voice.",
    "contact": {
      "name": "Kurdish TTS",
      "url": "https://www.kurdishtts.com/contact"
    }
  },
  "externalDocs": {
    "description": "Human-readable API reference with copy-paste curl, Python and JavaScript examples",
    "url": "https://www.kurdishtts.com/docs/api"
  },
  "servers": [
    { "url": "https://www.kurdishtts.com", "description": "Production" }
  ],
  "security": [],
  "tags": [
    { "name": "TTS", "description": "Text-to-speech" },
    { "name": "STT", "description": "Speech-to-text" },
    { "name": "Voices", "description": "Voice catalog" }
  ],
  "paths": {
    "/api/tts-proxy": {
      "post": {
        "tags": ["TTS"],
        "summary": "Synthesize Kurdish speech from text",
        "operationId": "synthesizeSpeech",
        "description": "Convert Kurdish text to speech. Returns `audio/wav` by default (`audio/ogg` or `audio/mpeg` when `format` is `opus` or `mp3`), or a JSON payload with base64 audio and word-level timestamps when `include_timestamps` is true. The dialect is derived automatically from the `speaker_id` prefix (`sorani_…` / `kurmanji_…` / `badini_…`). Send `dialect` explicitly only for the Cast and Studio speakers, which have no prefix and read either Sorani or Kurmanji; pairing one with `badini` is refused with a 400 rather than returning wrong-dialect audio.",
        "security": [{ "TtsApiKey": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/TtsRequest" },
              "examples": {
                "basic": {
                  "summary": "Basic Sorani synthesis (works on a free key)",
                  "description": "speaker_id must belong to the group named by model_version. sorani_85 is one of the four voices a FREE plan may render on v3.",
                  "value": { "text": "سڵاو، چۆنیت؟", "speaker_id": "sorani_85", "model_version": "v3" }
                },
                "v4WithTimestamps": {
                  "summary": "V5 rendering with the v4 voice catalog and word timestamps",
                  "value": { "text": "Silav, tu çawa yî?", "speaker_id": "kurmanji_236", "model_version": "v4", "include_timestamps": true }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Synthesized audio. `audio/wav` by default, `audio/ogg` (Opus) or `audio/mpeg` (MP3) when `format` is set; `application/json` (with base64 audio + timestamps) when `include_timestamps` is true.",
            "content": {
              "audio/wav": { "schema": { "type": "string", "format": "binary" } },
              "audio/ogg": { "schema": { "type": "string", "format": "binary" } },
              "audio/mpeg": { "schema": { "type": "string", "format": "binary" } },
              "application/json": { "schema": { "$ref": "#/components/schemas/TtsTimestampsResponse" } }
            }
          },
          "400": { "description": "Bad request (missing speaker_id, text too long, invalid speed).", "content": { "text/plain": { "schema": { "type": "string" } } } },
          "401": { "description": "Missing or invalid API key.", "content": { "text/plain": { "schema": { "type": "string" } } } },
          "403": { "description": "Plan inactive, character quota exceeded, or speaker/model not allowed on your plan.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "422": { "description": "Backend parameter validation error. The `detail` array carries the specific messages.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ValidationError" } } } }
        }
      }
    },
    "/api/tts-stream": {
      "post": {
        "tags": ["TTS"],
        "summary": "Stream Kurdish speech from text",
        "operationId": "streamSpeech",
        "description": "Render with V5 and return audio progressively as SSE or raw PCM. `model_version` selects the speaker group your `speaker_id` is resolved in, not a model or engine. The legacy WAV format remains available as a buffered compatibility response.",
        "security": [{ "TtsApiKey": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/TtsStreamRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "SSE events, raw 16-bit mono PCM at 24 kHz, or a buffered WAV response according to `stream_format`.",
            "content": {
              "text/event-stream": { "schema": { "type": "string" } },
              "audio/pcm": { "schema": { "type": "string", "format": "binary" } },
              "audio/wav": { "schema": { "type": "string", "format": "binary" } }
            }
          },
          "400": { "description": "Missing field or invalid stream format.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "description": "Missing or invalid TTS API key." },
          "403": { "description": "Plan inactive, quota exceeded, or voice/catalog not included in the plan.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "503": { "description": "Voice roster temporarily unavailable; request was refused rather than bypassing entitlement checks." }
        }
      }
    },
    "/api/stt-proxy": {
      "post": {
        "tags": ["STT"],
        "summary": "Transcribe an uploaded Kurdish audio file",
        "operationId": "transcribeAudio",
        "description": "Transcribe a Kurdish audio file (WAV/MP3/FLAC/OGG/M4A). Max file size and transcript length depend on your plan (free: 10 MB / 500 chars; starter: 50 MB / unlimited; pro: 100 MB / unlimited). One credit is debited per successful transcription.",
        "security": [{ "SttApiKey": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "type": "object",
                "required": ["file", "dialect"],
                "properties": {
                  "file": { "type": "string", "format": "binary", "description": "Audio file (WAV/MP3/FLAC/OGG/M4A)." },
                  "dialect": { "type": "string", "enum": ["sorani", "kurmanji"], "description": "Kurdish dialect of the audio." }
                }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Transcription result.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SttResponse" } } } },
          "400": { "description": "Missing file, invalid dialect, or file exceeds the plan size limit.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "description": "Missing or invalid STT API key.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "No STT plan, plan inactive, or credit limit exceeded.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/api/stt-stream-connect": {
      "post": {
        "tags": ["STT"],
        "summary": "Open a live streaming transcription session",
        "operationId": "startStreamingTranscription",
        "description": "Returns a temporary WebSocket URL for real-time transcription. Connect to the URL within 5 minutes, then stream raw 16-bit PCM mono audio at 16 kHz as binary frames. Send `{\"type\":\"control\",\"event\":\"finalize\"}` to flush; the server streams `{\"text\":\"…\",\"is_final\":bool}` messages and `{\"type\":\"control\",\"event\":\"done\"}` when complete. One streaming session is debited per call.",
        "security": [{ "SttApiKey": [] }],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["dialect"],
                "properties": { "dialect": { "type": "string", "enum": ["sorani", "kurmanji"] } }
              }
            }
          }
        },
        "responses": {
          "200": { "description": "Streaming session created.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StreamConnectResponse" } } } },
          "400": { "description": "Invalid body or dialect.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "401": { "description": "Missing or invalid STT API key.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
          "403": { "description": "No STT plan, plan inactive, or streaming session limit exceeded.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    },
    "/api/get-speakers": {
      "get": {
        "tags": ["Voices"],
        "summary": "List available Kurdish voices",
        "operationId": "listVoices",
        "description": "Public, unauthenticated catalog of available voices. Use the returned `id` as the `speaker_id` for `/api/tts-proxy`. The dialect of each voice is encoded in its id prefix.",
        "parameters": [
          { "name": "model_version", "in": "query", "required": false, "schema": { "type": "string", "enum": ["v3", "v4", "v5"] }, "description": "Speaker group to list. Not a model or engine choice — every group renders on V5. v3 = 198 speakers, v4 = 664, v5 = 20 curated plus the six `badini_` speakers and the 13 Cast and Studio ones. The groups share no IDs." }
        ],
        "responses": {
          "200": { "description": "Voice catalog.", "content": { "application/json": { "schema": { "type": "object", "properties": { "speakers": { "type": "array", "items": { "$ref": "#/components/schemas/Speaker" } } } } } } },
          "500": { "description": "Failed to fetch speakers.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "TtsApiKey": { "type": "apiKey", "in": "header", "name": "x-api-key", "description": "TTS API key (key space: TTS). Generate at /settings/api." },
      "SttApiKey": { "type": "apiKey", "in": "header", "name": "x-api-key", "description": "STT API key (key space: STT — separate from TTS). Generate at /settings/api." }
    },
    "schemas": {
      "TtsRequest": {
        "type": "object",
        "required": ["text", "speaker_id"],
        "properties": {
          "text": { "type": "string", "description": "Text to synthesize. Max 500 chars on the free plan, 5000 on paid plans.", "maxLength": 5000 },
          "speaker_id": { "type": "string", "description": "Voice id from /api/get-speakers, e.g. `sorani_85` or `kurmanji_6` in the default v3 group. The dialect is derived from the prefix. Groups share no IDs — pass the same `model_version` here and to /api/get-speakers. Free plans render only sorani_85, sorani_214, kurmanji_6, kurmanji_12 on v3 (sorani_1, sorani_986, kurmanji_236, kurmanji_233 on v4), plus badini_story_m and badini_narrator_f on v5 — the only v5 ids a free key may render.", "examples": ["sorani_85", "kurmanji_6"] },
          "dialect": { "type": "string", "enum": ["sorani", "kurmanji", "badini"], "description": "Reading language. Inferred from the speaker_id prefix when it has one, so send it only for the Cast and Studio speakers, which have none. A Cast or Studio speaker paired with badini is refused with a 400." },
          "model_version": { "type": "string", "enum": ["v3", "v4", "v5"], "default": "v3", "description": "Speaker group the speaker_id is resolved in; not a model or engine choice. Every value renders on V5. The v5 group requires a paid API plan, except the two free Badini voices; its Cast and Studio speakers require a plan bought for the API. A badini_ id always resolves on v5 whatever you send here, because it exists in no other group." },
          "include_timestamps": { "type": "boolean", "default": false, "description": "When true, return JSON with base64 audio + word-level timestamps instead of audio/wav." },
          "format": { "type": "string", "enum": ["wav", "opus", "mp3"], "default": "wav", "description": "Container for the binary audio response. `wav` = 24 kHz PCM WAV; `opus` = Ogg/Opus at 24 kHz (`audio/ogg`, roughly 10–15× smaller than WAV, ideal for mobile data); `mp3` = 24 kHz MP3 (`audio/mpeg`). Ignored when `include_timestamps` is true — that response is JSON with base64 PCM." },
          "speed": { "type": "number", "minimum": 0.25, "maximum": 4.0, "description": "Playback speed, industry convention (higher = faster). Clamped to 0.25–4.0." },
          "temperature": { "type": "number", "description": "Optional generation control forwarded to V5. Omit for the engine default; mutually exclusive with `stability`." },
          "stability": { "type": "number", "description": "Optional compatibility control; mutually exclusive with `temperature`." },
          "top_p": { "type": "number", "description": "Optional advanced generation control forwarded to V5." },
          "repetition_penalty": { "type": "number", "description": "Optional advanced generation control forwarded to V5." },
          "seed": { "type": "integer", "description": "Optional reproducibility control." },
          "pitch": { "type": "number", "description": "Post-processing pitch shift." },
          "silence_trim": { "type": "boolean", "description": "Post-processing: trim leading/trailing silence." },
          "chunk_max_seconds": { "type": "number", "description": "Optional maximum seconds per synthesis chunk." },
          "sentence_silence_ms": { "type": "integer", "description": "Optional silence inserted between sentences, in milliseconds." }
        }
      },
      "TtsStreamRequest": {
        "type": "object",
        "required": ["text", "speaker_id"],
        "properties": {
          "text": { "type": "string", "maxLength": 5000, "description": "Text to synthesize. Max 500 characters on free plans and 5000 on paid plans." },
          "speaker_id": { "type": "string", "description": "Speaker id from the group selected by `model_version`. The groups share no IDs, so the two fields must agree." },
          "dialect": { "type": "string", "enum": ["sorani", "kurmanji", "badini"], "description": "Reading language. Inferred from the speaker_id prefix when it has one, so send it only for the Cast and Studio speakers, which have none. A Cast or Studio speaker paired with badini is refused with a 400." },
          "model_version": { "type": "string", "enum": ["v3", "v4", "v5"], "default": "v3", "description": "Speaker group the speaker_id is resolved in; not a model or engine choice. Every value renders on V5." },
          "stream_format": { "type": "string", "enum": ["sse", "pcm", "wav"], "default": "sse", "description": "SSE and PCM are progressive. WAV is a buffered compatibility response." },
          "include_timestamps": { "type": "boolean", "default": false, "description": "For SSE, emit per-chunk word timestamp events and include the cumulative list in the final event." },
          "speed": { "type": "number", "minimum": 0.25, "maximum": 4.0, "description": "Playback speed; higher is faster." },
          "temperature": { "type": "number" },
          "seed": { "type": "integer" }
        }
      },
      "TtsTimestampsResponse": {
        "type": "object",
        "description": "Returned when `include_timestamps` is true.",
        "properties": {
          "audio": { "type": "string", "description": "Base64-encoded PCM audio." },
          "timestamps": { "type": "array", "description": "Word-level timestamps.", "items": { "type": "object", "properties": { "word": { "type": "string" }, "start": { "type": "number" }, "end": { "type": "number" } } } },
          "sample_rate": { "type": "integer", "examples": [24000] },
          "audio_duration": { "type": "number" },
          "generation": {
            "type": "object",
            "description": "What the server actually did. Treat `collapsed: true` as a failed generation even on HTTP 200.",
            "properties": {
              "collapsed": { "type": "boolean" },
              "seed_used": { "type": "integer" },
              "temperature_used": { "type": "number" },
              "retries_used": { "type": "integer" },
              "chunk_count": { "type": "integer" }
            }
          }
        }
      },
      "SttResponse": {
        "type": "object",
        "properties": {
          "text": { "type": "string", "description": "Transcribed text." },
          "dialect_hint": { "type": "string" },
          "language": { "type": "string" },
          "detected_dialect": { "type": "string" },
          "detected_script": { "type": "string" },
          "truncated": { "type": "boolean", "description": "True when a free-plan transcript-length cap clipped the text." },
          "truncation_limit": { "type": "integer", "description": "The character cap applied when `truncated` is true." }
        }
      },
      "StreamConnectResponse": {
        "type": "object",
        "properties": {
          "websocket_url": { "type": "string", "format": "uri", "description": "Temporary wss:// URL. Connect within 5 minutes. Does not contain your API key." },
          "dialect": { "type": "string", "enum": ["sorani", "kurmanji"] },
          "plan": { "type": "string" },
          "streaming_sessions_used": { "type": "integer" },
          "streaming_sessions_total": { "type": "integer" },
          "streaming_sessions_purchased": { "type": "integer" },
          "streaming_sessions_remaining": { "type": "integer" },
          "max_session_duration_seconds": { "type": "integer" },
          "message": { "type": "string" }
        }
      },
      "Speaker": {
        "type": "object",
        "properties": {
          "id": { "type": "string", "description": "Voice id; use as `speaker_id` in /api/tts-proxy.", "examples": ["sorani_1"] },
          "name": { "type": "string", "examples": ["Female 1"] },
          "dialect": { "type": "string", "enum": ["sorani", "kurmanji", "badini"] },
          "gender": { "type": "string", "enum": ["male", "female"] },
          "speaker_id": { "type": "string" }
        }
      },
      "Error": {
        "type": "object",
        "properties": {
          "error": { "type": "string" },
          "detail": { "type": "string" },
          "upgrade_url": { "type": "string" }
        }
      },
      "ValidationError": {
        "type": "object",
        "description": "FastAPI-style validation error forwarded from the model backend.",
        "properties": {
          "detail": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "loc": { "type": "array", "items": { "type": "string" } },
                "msg": { "type": "string" },
                "type": { "type": "string" }
              }
            }
          }
        }
      }
    }
  }
}
