openapi: 3.0.3
info:
  title: Serna / Pezhvak — Persian ASR API (سرنا / پژواک)
  version: "2.0.0"
  description: |
    **EN** — Public HTTP + WebSocket gateway for the Persian speech-to-text system.

    Engines (`engine` / ElevenLabs `model_id`):
      * **serna** (سرنا ۳) — default, fast, CTC + KenLM language model. Word timestamps from CTC alignment.
      * **serna-stream** (سرنا جریانی) — live websocket only.
      * **pezhvak** (پژواک) — accurate, offline. Word timings may be `null`.
      * **mersa** (مرسا) — light, CPU. Word timings may be `null`.
      * **serna2** (سرنا ۲) — previous generation, panel comparison only.

    Unknown ElevenLabs model ids (`scribe_v1`, `scribe_v2`, …) fall back to the gateway's
    `DEFAULT_ENGINE`. A disabled engine is rejected with `400` plus the list of live engines.

    **FA** — درگاه عمومی سامانه‌ی گفتاربه‌نوشتار فارسی. دو رابط: API خودمان و رابط سازگار با الون‌لبز.

    ### Authentication
    When the gateway is started with `API_KEY`, every `/v1/*` endpoint requires the key in one of:
    `X-API-Key`, `xi-api-key`, `Authorization: Bearer <key>`, `?api_key=<key>`, `?token=<key>`.
    Open without a key: `/`, `/docs`, `/openapi.yaml`, `/health`, `/ready`, `/metrics` and panel assets.
    Native routes answer `401 {"error":"unauthorized"}`; ElevenLabs routes answer
    `401 {"detail":{"status":"invalid_api_key",…}}`.

    ### Jobs
    Every file transcription becomes a job (`queued` → `running` → `done`|`error`). A synchronous
    request waits for the result (up to `JOB_TIMEOUT_SEC`); `?async=1` (native) or `webhook=true`
    (ElevenLabs) returns `202` immediately. Results are kept for `RESULT_TTL_DAYS` days and can be
    downloaded as json/txt/srt/vtt.

    ### Notes
    * Any audio/video container ffmpeg can read is accepted; it is converted to 16 kHz mono internally.
    * `X-Request-Id` is echoed back on every response (generated when absent).
servers:
  - url: /
    description: this gateway
tags:
  - name: transcription
  - name: jobs
  - name: ElevenLabs-compatible
  - name: meta
paths:
  /v1/transcribe:
    post:
      tags: [transcription]
      summary: Transcribe an audio file — رونویسی فایل صوتی
      description: |
        Upload an audio file and get the Persian transcript back. All form fields are passed
        through to the worker unchanged. Max upload size is `MAX_UPLOAD_MB` (default 500 MB).
      security: [{ ApiKeyHeader: [] }, { XiApiKeyHeader: [] }]
      parameters:
        - in: query
          name: async
          schema: { type: integer, enum: [0, 1], default: 0 }
          description: "1 = return `202 {job_id}` immediately instead of waiting — حالت ناهمگام"
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: Audio file, any format ffmpeg can decode (wav, mp3, m4a, ogg, opus, mp4, …)
                engine:
                  type: string
                  enum: [serna, pezhvak, mersa, serna2]
                  default: serna
                  description: Engine id — موتور
                hotwords:
                  type: string
                  default: ""
                  description: Comma-separated glossary/bias words — واژه‌نامه، با کاما جدا شود
                punct:
                  type: integer
                  enum: [0, 1]
                  default: 1
                  description: Restore punctuation — نگارش/نقطه‌گذاری
                zwnj:
                  type: integer
                  enum: [0, 1]
                  default: 1
                  description: Insert ZWNJ (نیم‌فاصله)
                lm:
                  type: integer
                  default: -1
                  description: "Serna: KenLM in beam search (1=on, 0=off, -1=server default) — مدل زبانی"
                raw:
                  type: integer
                  enum: [0, 1]
                  default: 0
                  description: Return raw model output with no post-processing (serna only)
                diarize:
                  type: integer
                  enum: [0, 1]
                  default: 0
                  description: "1 = speaker diarization; response gains `segments` and `speakers` — تفکیک گوینده"
                speakers:
                  type: integer
                  default: 0
                  description: Number of speakers if known (0 = auto)
                timestamps:
                  type: integer
                  enum: [0, 1]
                  default: 1
                  description: Emit per-word timings — زمان کلمه‌ای
                beam:
                  type: integer
                  default: 1
                  minimum: 1
                  description: Beam size (pezhvak only)
            encoding:
              file:
                contentType: application/octet-stream
      responses:
        "200":
          description: Transcript
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Transcript" }
        "202":
          description: "Queued (`?async=1`)"
          content:
            application/json:
              schema:
                type: object
                properties:
                  job_id: { type: string }
                  status: { type: string, example: queued }
        "400":
          description: Bad request or disabled engine (body carries the live `engines` list)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413": { description: Upload exceeds MAX_UPLOAD_MB }
        "429": { description: Rate limit (RATE_LIMIT_RPM) exceeded }
        "500":
          description: Inference error
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Queue full (MAX_QUEUE) or shutting down
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "504":
          description: Job exceeded JOB_TIMEOUT_SEC
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/jobs:
    get:
      tags: [jobs]
      summary: List jobs, newest first — فهرست درخواست‌ها
      security: [{ ApiKeyHeader: [] }, { XiApiKeyHeader: [] }]
      parameters:
        - { in: query, name: limit, schema: { type: integer, default: 50, maximum: 500 } }
        - { in: query, name: cursor, schema: { type: string }, description: "`next_cursor` from the previous page" }
      responses:
        "200":
          description: Job list
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobs:
                    type: array
                    items: { $ref: "#/components/schemas/Job" }
                  next_cursor: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/jobs/{id}:
    get:
      tags: [jobs]
      summary: Job status and result — وضعیت یک درخواست
      security: [{ ApiKeyHeader: [] }, { XiApiKeyHeader: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string } }]
      responses:
        "200":
          description: Job
          content:
            application/json:
              schema:
                type: object
                properties:
                  job: { $ref: "#/components/schemas/Job" }
                  result: { $ref: "#/components/schemas/Transcript" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Not found }
    delete:
      tags: [jobs]
      summary: Delete a job and its stored result — حذف درخواست
      security: [{ ApiKeyHeader: [] }, { XiApiKeyHeader: [] }]
      parameters: [{ in: path, name: id, required: true, schema: { type: string } }]
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties: { deleted: { type: boolean } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/jobs/{id}/result.{ext}:
    get:
      tags: [jobs]
      summary: Download the result as json / txt / srt / vtt — دانلود خروجی
      description: |
        Subtitle cues hold at most 12 words or 6 seconds and break at sentence punctuation and
        speaker changes. With diarization each cue is prefixed with the speaker label
        («گوینده ۱: …»). When the engine returns no word timings the cues fall back to the
        diarization segments, or to a single cue spanning the whole file.
      security: [{ ApiKeyHeader: [] }, { XiApiKeyHeader: [] }]
      parameters:
        - { in: path, name: id, required: true, schema: { type: string } }
        - { in: path, name: ext, required: true, schema: { type: string, enum: [json, txt, srt, vtt] } }
      responses:
        "200":
          description: Result file
          content:
            application/json: { schema: { $ref: "#/components/schemas/Transcript" } }
            text/plain: { schema: { type: string } }
            text/vtt: { schema: { type: string } }
        "202": { description: Job is still queued or running }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Not found or result expired }

  /v1/speech-to-text:
    post:
      tags: [ElevenLabs-compatible]
      summary: ElevenLabs Scribe-compatible transcription
      description: |
        Drop-in replacement for `POST https://api.elevenlabs.io/v1/speech-to-text`.
        Point the ElevenLabs SDK's base URL at this gateway and keep using `xi-api-key`.

        Not supported (deliberately): `source_url`, character-level timestamps, audio events,
        entity detection, multichannel, one-off tokens, docx/pdf outputs.
      security: [{ XiApiKeyHeader: [] }, { ApiKeyHeader: [] }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary }
                model_id:
                  type: string
                  default: scribe_v1
                  description: "`serna`/`pezhvak`/`mersa`/`serna2`; anything else → DEFAULT_ENGINE"
                language_code: { type: string, description: Ignored — the answer is always `fas` }
                diarize: { type: boolean, default: false }
                num_speakers: { type: integer }
                keyterms:
                  type: array
                  items: { type: string }
                  description: Repeated field and/or comma-separated — mapped to `hotwords`
                timestamps_granularity:
                  type: string
                  enum: [none, word, character]
                  default: word
                  description: "`character` behaves like `word`"
                additional_formats:
                  type: string
                  description: 'JSON array, e.g. `[{"format":"srt"},{"format":"vtt"}]`'
                webhook:
                  type: boolean
                  default: false
                  description: "true → `202` now, result POSTed to the webhook when ready"
                webhook_id: { type: string, description: A URL here overrides the `WEBHOOK_URL` env var }
                webhook_metadata: { type: string, description: Echoed back in the webhook body }
      responses:
        "200":
          description: Transcript
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ElevenTranscript" }
        "202":
          description: "Queued (`webhook=true`)"
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, example: Transcription queued }
                  request_id: { type: string }
                  transcription_id: { type: string }
        "401": { $ref: "#/components/responses/ElevenUnauthorized" }
        "413": { description: Upload exceeds MAX_UPLOAD_MB }
        "429": { description: Rate limit exceeded }
        "503": { description: Queue full or shutting down }

  /v1/speech-to-text/transcripts/{transcription_id}:
    get:
      tags: [ElevenLabs-compatible]
      summary: Fetch a transcript by id
      security: [{ XiApiKeyHeader: [] }, { ApiKeyHeader: [] }]
      parameters: [{ in: path, name: transcription_id, required: true, schema: { type: string } }]
      responses:
        "200":
          description: Transcript
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ElevenTranscript" }
        "202":
          description: Still processing
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: processing }
                  transcription_id: { type: string }
        "401": { $ref: "#/components/responses/ElevenUnauthorized" }
        "404":
          description: Unknown id
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ElevenError" }

  /v1/speech-to-text/realtime:
    get:
      tags: [ElevenLabs-compatible]
      summary: "WebSocket: ElevenLabs realtime protocol — زنده، پروتکل الون‌لبز"
      description: |
        **WebSocket endpoint** — Swagger UI cannot exercise it. Connect to
        `wss://<host>/v1/speech-to-text/realtime?model_id=scribe_v1&audio_format=pcm_16000`.
        The gateway proxies to a stream worker's `/ws/realtime`; `token` and `api_key` are
        stripped from the forwarded query. On an auth failure the socket receives
        `{"message_type":"auth_error","error":"…"}` and is closed with code 1008.

        Client → server: `{"message_type":"input_audio_chunk","audio_base_64":"…","commit":false}`.
        Server → client: `session_started`, then `partial_transcript` roughly every second of
        audio, and `committed_transcript` (plus `committed_transcript_with_timestamps` when
        `include_timestamps=true`) on each commit.
      parameters:
        - { in: query, name: model_id, schema: { type: string, default: scribe_v1 } }
        - in: query
          name: audio_format
          schema:
            type: string
            enum: [pcm_8000, pcm_16000, pcm_22050, pcm_24000, pcm_44100, pcm_48000]
            default: pcm_16000
        - { in: query, name: commit_strategy, schema: { type: string, enum: [manual, vad], default: manual } }
        - { in: query, name: vad_silence_threshold_secs, schema: { type: number, default: 1.0 } }
        - { in: query, name: vad_threshold, schema: { type: number, default: 0.5 } }
        - { in: query, name: include_timestamps, schema: { type: boolean, default: false } }
        - { in: query, name: keyterms, schema: { type: string }, description: repeated or comma-separated }
        - { in: query, name: api_key, schema: { type: string }, description: API key when auth is enabled }
      responses:
        "101": { description: Switching Protocols — WebSocket established }
        "502": { description: Stream worker unreachable }
        "503": { description: No stream worker configured }

  /v1/stream:
    get:
      tags: [transcription]
      summary: "WebSocket: live streaming transcription — گفتار زنده (وب‌سوکت)"
      description: |
        **WebSocket endpoint, not a plain GET.** Use the panel at `/` (tab «گفتار زنده») or a WS
        client: `ws://<host>/v1/stream?chunk=2&rctx=1&lctx=8&hotwords=a,b&punct=1` (`wss://` behind
        TLS). The gateway is a transparent bidirectional proxy to the stream worker with the fewest
        live sessions.

        1. Client → server: **binary** frames of raw **PCM16, mono, 16 kHz, little-endian**
           (~100–400 ms per frame; no WAV header).
        2. Server → client: `{"partial":"…","final":false,"audio_sec":4.0}` per decoded chunk.
           `partial` is cumulative from the start of the session — replace, don't append.
        3. Client → server: the **text** frame `end` when the microphone stops.
        4. Server → client: `{"text":"…","final":true}` — then the server closes.

        Query parameters are forwarded to the worker; `api_key` and `token` are stripped.
      parameters:
        - { in: query, name: chunk, schema: { type: number, default: 2 }, description: seconds per decode step }
        - { in: query, name: rctx, schema: { type: number, default: 1 }, description: lookahead seconds }
        - { in: query, name: lctx, schema: { type: number, default: 8 }, description: left context seconds }
        - { in: query, name: hotwords, schema: { type: string }, description: comma-separated glossary }
        - { in: query, name: punct, schema: { type: integer, enum: [0, 1], default: 1 } }
        - { in: query, name: api_key, schema: { type: string }, description: API key when auth is enabled }
      responses:
        "101": { description: Switching Protocols — WebSocket established }
        "400": { description: Not a WebSocket upgrade request }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "502": { description: Worker WebSocket unreachable }

  /v1/models:
    get:
      tags: [meta]
      summary: Engines, current model versions, registry tail — مدل‌ها
      security: [{ ApiKeyHeader: [] }, { XiApiKeyHeader: [] }]
      responses:
        "200":
          description: Model info
          content:
            application/json:
              schema:
                type: object
                properties:
                  engines:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, example: serna }
                        name: { type: string, example: سرنا ۳ }
                        role: { type: string, example: default }
                        loaded: { type: boolean }
                        streaming: { type: boolean }
                        enabled: { type: boolean, description: Offered by at least one live worker }
                  default_engine: { type: string }
                  current:
                    type: object
                    additionalProperties: { type: string }
                    description: |
                      Map of `<name>-current` → resolved model path. The filesystem still uses the
                      legacy spelling `sorna-current`; these are the **Serna** models.
                  registry:
                    type: array
                    description: Last accepted training checkpoints with their eval metrics.
                    items:
                      type: object
                      properties:
                        tag: { type: string }
                        step: { type: integer }
                        accepted: { type: boolean }
                        time: { type: string }
                        path: { type: string }
                        metrics:
                          type: object
                          additionalProperties: { type: number }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /v1/version:
    get:
      tags: [meta]
      summary: Gateway build info — نسخه‌ی درگاه
      security: [{ ApiKeyHeader: [] }, { XiApiKeyHeader: [] }]
      responses:
        "200":
          description: Version info
          content:
            application/json:
              schema:
                type: object
                properties:
                  name: { type: string, example: serna-gateway }
                  version: { type: string }
                  build: { type: string }
                  go: { type: string }
                  workers: { type: array, items: { type: string } }
                  stream_workers: { type: array, items: { type: string } }
                  engines: { type: array, items: { type: string } }
                  default_engine: { type: string }
                  uptime_sec: { type: integer }

  /health:
    get:
      tags: [meta]
      summary: Gateway + worker health — سلامت سامانه
      description: Open (no API key). Returns 503 when no worker is ready.
      responses:
        "200":
          description: Healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  version: { type: string }
                  uptime_sec: { type: integer }
                  queue: { type: integer }
                  running: { type: integer }
                  workers_ready: { type: integer }
                  workers:
                    type: array
                    items:
                      type: object
                      properties:
                        url: { type: string }
                        ok: { type: boolean }
                        ready: { type: boolean }
                        busy: { type: boolean }
                        active: { type: integer }
                        sessions: { type: integer }
                        engines: { type: array, items: { type: string } }
                        worker_id: { type: string }
                  worker:
                    type: object
                    description: First live worker, kept for v1 clients
        "503": { description: No worker ready }

  /ready:
    get:
      tags: [meta]
      summary: Readiness probe — آمادگی
      description: 200 only when at least one worker reports `ready`.
      responses:
        "200": { description: Ready }
        "503": { description: No worker ready }

  /metrics:
    get:
      tags: [meta]
      summary: Prometheus metrics — متریک
      description: |
        `serna_requests_total{route,engine,status}`, `serna_request_seconds{route,engine}` (histogram),
        `serna_audio_seconds_total{engine}`, `serna_queue_length`, `serna_workers_ready`.
      responses:
        "200":
          description: Text exposition format
          content:
            text/plain: { schema: { type: string } }

components:
  securitySchemes:
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: Only enforced when the gateway is started with the `API_KEY` env var.
    XiApiKeyHeader:
      type: apiKey
      in: header
      name: xi-api-key
      description: ElevenLabs-style header; accepted on every route.
  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
        job_id: { type: string }
        engines: { type: array, items: { type: string } }
    ElevenError:
      type: object
      properties:
        detail:
          type: object
          properties:
            status: { type: string, example: not_found }
            message: { type: string }
    Word:
      type: object
      properties:
        text: { type: string, example: سلام }
        start: { type: number, nullable: true, example: 0.32 }
        end: { type: number, nullable: true, example: 0.61 }
        logprob: { type: number, example: -0.05 }
        speaker: { type: string, nullable: true, example: گوینده ۱ }
    Transcript:
      type: object
      properties:
        engine: { type: string, example: serna }
        text: { type: string, example: "سلام، این یک آزمایش است." }
        duration: { type: number, example: 12.99, description: audio seconds }
        sec: { type: number, example: 0.41, description: worker-side decode seconds }
        lm: { type: boolean }
        job_id: { type: string, description: added by the gateway }
        words:
          type: array
          items: { $ref: "#/components/schemas/Word" }
        segments:
          type: array
          description: only with `diarize=1`
          items:
            type: object
            properties:
              speaker: { type: string, example: گوینده ۱ }
              start: { type: number }
              end: { type: number }
              text: { type: string }
        speakers: { type: integer, description: only with `diarize=1` }
    Job:
      type: object
      properties:
        id: { type: string }
        created_at: { type: string, format: date-time }
        started_at: { type: string, format: date-time, nullable: true }
        finished_at: { type: string, format: date-time, nullable: true }
        status: { type: string, enum: [queued, running, done, error] }
        route: { type: string, enum: [native, eleven] }
        engine: { type: string }
        filename: { type: string }
        audio_sec: { type: number, nullable: true }
        proc_sec: { type: number, nullable: true }
        worker: { type: string }
        error: { type: string }
        webhook_url: { type: string }
        webhook_status: { type: string }
    ElevenWord:
      type: object
      properties:
        text: { type: string }
        type: { type: string, enum: [word, spacing] }
        start: { type: number, nullable: true }
        end: { type: number, nullable: true }
        logprob: { type: number }
        speaker_id: { type: string, nullable: true, example: speaker_0 }
        characters: { type: string, nullable: true, description: always null }
    ElevenTranscript:
      type: object
      properties:
        language_code: { type: string, example: fas }
        language_probability: { type: number, example: 1.0 }
        text: { type: string }
        transcription_id: { type: string }
        audio_duration_secs: { type: number, example: 12.99 }
        words:
          type: array
          items: { $ref: "#/components/schemas/ElevenWord" }
        additional_formats:
          type: array
          nullable: true
          items:
            type: object
            properties:
              requested_format: { type: string, example: srt }
              file_extension: { type: string, example: srt }
              content_type: { type: string, example: text/plain }
              is_base64_encoded: { type: boolean, example: false }
              content: { type: string }
  responses:
    Unauthorized:
      description: Missing or wrong API key
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ElevenUnauthorized:
      description: Missing or wrong API key (ElevenLabs shape)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ElevenError" }
