openapi: 3.1.0

# Written against the gateway source, endpoint by endpoint, not from an earlier draft.
#
# It documents the five paths a client calls, and only those. The service answers more —
# /health and /metrics and /stats, /capabilities, /extract_fingerprint, /test_separate, a root
# banner — but all of that is operational: liveness for the load balancer, telemetry, and
# which pairs and voices a particular box happens to have been provisioned with. It says more
# about how we run the thing than about how to call it. It all keeps working; none of it is
# part of the offered surface.
#
# Where an endpoint accepts more than one shape of request, the canonical one is documented
# and the older aliases are noted rather than given equal billing.

info:
  title: Liora Flow API
  version: 1.7.0
  description: |
    Speech-to-speech translation over HTTP.

    Each stage of the pipeline is callable on its own — transcription, language detection,
    translation, speech synthesis — and `/process_audio` runs all of them in a single
    request, taking audio in one language and returning spoken audio in several others.

    ## Authentication

    Send your key in the `x-api-key` header. It is also accepted as `Authorization: Bearer`
    and as an `api_key` query parameter, for clients that cannot set headers.

    There are two kinds of key and they are not interchangeable. The translation endpoints
    take the key we issue for them. The rooms and app endpoints take your company's own key,
    which you create in the panel and which reaches your company's rooms and nobody else's —
    that is what makes it safe to hand out. A key of the wrong kind is refused as invalid,
    which is the same answer a revoked one gets: the difference is of no use to a legitimate
    caller and of plenty to somebody probing.

    ## Working with the responses

    The two endpoints that return audio — `/tts/batch` and `/process_audio` — reply with a
    ZIP by default, since a single request produces one file per target language. Add
    `base64=true` and you get JSON instead, with the same archive encoded inside it, which
    is easier to handle from a browser.

    `/process_audio` puts a `metadata.json` in the archive alongside the audio: the
    transcription, the translations, and which voice and format each file was rendered with.

    ## Languages

    Spanish, English, French, German, Portuguese, Italian and Chinese, in any direction.

    A given deployment carries the pairs and voices it was configured with. If you ask for a
    language this instance does not serve, the call fails rather than silently returning
    something else — so treat an unexpected error on a new language as a provisioning
    question and talk to us, not as a fault in your request.

    ## Rooms, and the audio that comes out of them

    Everything above is one call with one file. A room is the other half: something is
    spoken in it continuously, and several people listen in the language they chose.

    A room is created once, through `/api/v1/rooms`, and it is yours — it belongs to your
    company, and a key of yours reaches your rooms and nobody else's. Creating one hands
    back the credentials its device connects with, and that is the only time the passphrase
    is ever returned. There is a panel that does exactly the same thing, over the same
    endpoints: whichever you use, the other shows the result.

    ## Configuring a room

    A room arrives configured. What follows is what you may change, and almost all of it
    you will not.

    **Send only what you are changing.** Every setting keeps the value it has unless your
    request names it, so the ordinary call carries one or two fields. You are never expected
    to send the tuning back, or to know what it is.

    These are the ones anybody touches:

    | Path | What it is |
    |------|------------|
    | `delivery.mode` | Where the translated audio goes: `livekit` (a room people join) or `socket`. |
    | `delivery.livekit.room` | The name of that room. Each language arrives inside it as its own track. |
    | `delivery.livekit.displayName` | How the translation shows up for whoever is listening. |
    | `gpu.rateAudio` | How fast the translated voice speaks. 1 is natural; 0.5–2. |
    | `gpu.fastTranscription` | Assume the room's language. See below — it is the one worth reading twice. |
    | `delivery.whip.url` | Your own WebRTC, over WHIP. See *Taking the audio yourself*. |
    | `delivery.text.url` | Where we POST each translated line. See *Taking the text yourself*. |

    And the languages, which have [their own endpoint](#tag/rooms/PUT/api/v1/rooms/{id}/languages)
    because a language is not a setting: it is a voice generated live, and adding one is
    adding work to a GPU.

    ### The one worth reading twice

    `gpu.fastTranscription` is on by default, and on means: **everything said in this room
    is transcribed as if it were the room's language.** That is right, and fastest, when
    the room always sounds the same — a parish, a lecture hall, a plant floor.

    Turn it off and the engine detects the language of each intervention and, when it is
    not the room's, brings the transcription back to it before translating onward. It costs
    latency. It is the only setting that works in a room where people alternate languages,
    and leaving it on there does not produce an error — it produces a confident
    transcription of the wrong language.

    ### Taking the audio yourself

    If you already have WebRTC infrastructure — a video platform, an event app, a control
    room — you do not have to join a room of ours to get the translation. Put a **WHIP**
    endpoint in `delivery.whip.url` and we publish into it.

    **Each target language is its own WHIP session.** Not one session with several tracks: a
    WHIP endpoint is allowed to accept a single audio track and plenty do, so five languages
    in one session would arrive as one, with four silently missing. Put `{lang}` in the URL
    and each language gets its own path; leave it out and the language travels as a `lang`
    query parameter. Either way your side can tell them apart, which it has to — they are
    different languages of the same talk.

    ```
    delivery.whip.url = https://you.example.com/ingest/{lang}/whip   → …/ingest/en/whip
    delivery.whip.url = https://you.example.com/whip                 → …/whip?lang=en
    ```

    `delivery.whip.token` is sent as `Authorization: Bearer` if you set it. We DELETE the
    session resource when a talk ends, so nothing is left holding a connection.

    By default this is **in addition** to the room: your listeners keep working and your own
    stack gets the audio too. Set `delivery.mode` to `whip` and it becomes the only
    destination.

    Known to work as-is with LiveKit, Janus (through its WHIP server), MediaMTX, SRS, Ant
    Media and Cloudflare. For something that speaks RTMP or SRT instead, MediaMTX in front
    of it bridges from WHIP without either of us writing code.

    ### Taking the text yourself

    WHIP carries media and nothing else — there is no data channel in the protocol — so the
    subtitles cannot travel with the audio. Set `delivery.text.url` and we **POST one line
    per translated sentence**:

    ```json
    {
      "room": "main-hall",
      "segmentId": 12,
      "lang": "en",
      "sourceLang": "es",
      "text": "Good morning, everyone.",
      "ts": 1786551234567
    }
    ```

    `segmentId` is the sentence, and every language of that sentence carries the same one —
    that is how you group them, and how you tell a late arrival from a new sentence.

    A webhook rather than a socket on purpose: it is a line every few seconds, so there is
    nothing to gain from holding a connection open, and a POST survives your restart by
    construction. Requests reuse one keep-alive connection.

    It is independent of where the audio goes, so it works in every mode. If your endpoint
    is slow or down it costs its own subtitles and nothing else: the audio is never delayed
    waiting for it, and a line that cannot be delivered is dropped rather than queued
    forever. `delivery.text.token` is sent as `Authorization: Bearer`.

    ### What is not here: the acoustics

    A room is also calibrated — how loud a sound has to be to count as speech, how much
    silence closes a sentence, what gets discarded as noise. Those are **not exposed by this
    API and not settable through it.** They are set once per room, by us, from the acoustics
    of the place, and a value out of place there does not fail loudly: it produces a room
    that clips the first syllable, or one that translates the air conditioning.

    If a room mishears, cuts people off or stays silent, tell us. It is a five-minute change
    at our end and there is nothing to integrate.

    ## Sending audio from a room

    The HTTP endpoints above take an audio file per request. A room that is being spoken in
    does not work that way: it sends a continuous stream, and it has to keep sending through
    a network that drops packets. For that there is a second way in, over **SRT**.

    This is how the appliance in a room feeds a live talk. You only need it if you are
    bringing your own device; ours arrives configured.

    **Connecting.** Your device is the caller. Creating the room gives you the whole URL,
    assembled — host, port (8890 unless we say otherwise), the device id and the passphrase:

    ```
    srt://HOST:8890?streamid=DEVICE_ID&mode=caller&latency=120&passphrase=SECRET
    ```

    - `streamid` is the device id we issue. It is what tells the room apart from every other
      one, so it belongs to the device, not to the event. The Haivision form
      (`#!::u=DEVICE_ID`) is accepted too, for encoders that only speak that dialect.
    - `passphrase` is per device and **not optional**: a connection that is not encrypted is
      refused, whatever else it gets right. Ten characters minimum — that is SRT's own floor,
      not ours.
    - `latency` is the window SRT is given to recover lost packets before handing the audio
      on. 120 ms is a good starting point on a mobile uplink; raising it buys more resilience
      at the cost of the same delay downstream.

    **What to send.** Opus in an MPEG-TS container. Mono, 16 kHz, around 24 kbps is what a
    room needs — speech, not music, and the extra bandwidth buys nothing you will hear.

    Send continuously. Do not enable discontinuous transmission: a stream that stops during
    silence and restarts on speech clips the beginning of the sentence that restarted it.

    This is the exact pipeline our own appliance runs, and the shortest way to test yours:

    ```
    gst-launch-1.0 -e \
      alsasrc device=plughw:CODEC,0 ! audioconvert ! audioresample ! \
      audio/x-raw,rate=16000,channels=1 ! \
      opusenc bitrate=24000 ! mpegtsmux ! \
      srtsink uri="srt://HOST:8890?streamid=DEVICE_ID&mode=caller&latency=120&passphrase=SECRET"
    ```

    A line-level capture often arrives quieter than a microphone does. If yours is faint,
    add a `volume volume=2.0` between `audioresample` and `opusenc` rather than pushing the
    encoder harder — that is what a typical wired installation needs.

    **If it will not connect.** The refusal is always one of four: an unrecognised device
    id, a connection with no encryption, the wrong passphrase, or the installation being at
    its connection limit. You do not have to guess which — every attempt is recorded, and
    `GET /api/v1/events` tells you in a sentence.

    Audio never begins to flow in any of the four, so a stream that connects and then goes
    quiet is a capture problem at your end, not a rejection at ours — and that too is
    visible there, as a `connected` with no `disconnected` after it.

    ## Limits

    - Maximum upload size: 50 MB
    - Requests are given 30 seconds before they are cut off

  contact:
    name: Liora Flow
    email: info@geekdevops.es
    url: https://lioraflow.com
  license:
    name: Proprietary

servers:
  - url: https://api.lioraflow.com
    description: Translation — the endpoints that take audio or text and give it back translated.
  # The rooms and app endpoints answer on a different host, which is issued with the key
  # rather than published here. Deliberately: it is not a public entry point, and printing
  # it on a documentation page invites traffic from everybody who is not a customer.
  - url: "{management}"
    description: |
      Rooms and app. The address is the one we send you with your API key — the same base
      for every one of these endpoints.
    variables:
      management:
        default: https://your-instance.lioraflow.com
        description: The base URL issued with your key.

security:
  - apiKey: []

tags:
  - name: Rooms
    description: |
      The rooms your company owns, and the credentials their devices connect with.

      What a room is translated into — the languages, the voice each is heard in, how fast
      it speaks — is yours to change here. What it is *calibrated* with is not exposed:
      where a sentence ends, how loud speech has to be, what counts as noise. Those are set
      once per room by us, from the acoustics of the place, and are not something an
      integration can send. If a room mishears or cuts people off, tell us and we will
      adjust it.
  - name: Usage
    description: What your rooms consumed, and what it will be billed as.
  - name: Credit
    description: What is left to pay for it with, and every movement that got you there.
  - name: Pipeline
    description: The whole path from speech in one language to speech in others.
  - name: Speech
    description: Transcription and synthesis.
  - name: Text
    description: Translation and language detection.

paths:
  # ── Rooms and app ──────────────────────────────────────────────────────────
  #
  # These live on the management host, not on the translation one, so each names the
  # server it answers on. Written against the handlers: what they accept, what they
  # refuse, and with which code.

  /api/v1/rooms:
    servers: &management
      - url: "{management}"
        variables:
          management:
            default: https://your-instance.lioraflow.com
    get:
      tags: [Rooms]
      summary: List your rooms
      description: |
        Every room your company owns. A key reaches its own company's rooms and no others,
        so there is nothing to filter and no way to ask for somebody else's.

        Passphrases are not here. They are returned once, when the room is created.
      responses:
        "200":
          description: The rooms.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  rooms:
                    type: array
                    items: { $ref: "#/components/schemas/Room" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/NoCompany" }

    post:
      tags: [Rooms]
      summary: Create a room
      description: |
        Issues the room, its pipeline and — for a room with a device in it — its credentials.

        **The room is created able to translate.** Its acoustics are set from a template
        chosen by `source`, because a microphone wired into a room and a phone held in a
        hand are not the same signal: held to the same thresholds, a phone is heard as noise
        and never translated. That template is ours to adjust afterwards if the room needs
        it; what you change is what you can judge — the languages and the voices.

        **The passphrase is in this response and in no other, ever.** It is stored in a form
        we can present at an SRT handshake — the protocol needs the value itself — which is
        a reason to hand it over once rather than to repeat it on every read. Keep it when
        you get it; a room whose passphrase was lost has to be created again.

        `source` decides whether there are credentials at all:

        - `srt` (the default) — an appliance wired into the room's sound. It authenticates
          at the handshake, so it needs a stream id and a passphrase.
        - `webrtc` — somebody speaking into a phone that is already signed in. Nothing to
          install and nothing to authenticate with, so no credentials block comes back.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  maxLength: 120
                  description: A label for you. It never reaches the audio path.
                source:
                  type: string
                  enum: [srt, webrtc]
                  default: srt
                languages:
                  allOf:
                    - $ref: "#/components/schemas/Languages"
                  description: |
                    What is spoken in the room and what it is heard in, set at creation. The
                    room arrives translating; leave it out and it is created speaking Spanish
                    with no targets, and the languages endpoint sets them later.
            example:
              name: Main hall
              source: srt
              languages:
                origin: es
                targets:
                  - { lang: en, gender: male }
                  - { lang: fr }
      responses:
        "201":
          description: The room, and its credentials when it has any.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  room: { $ref: "#/components/schemas/Room" }
                  credentials: { $ref: "#/components/schemas/RoomCredentials" }
              example:
                success: true
                room:
                  id: 4f2c8e1a-77b0-4a3e-9f65-2b1d0c9a8e77
                  source: srt
                  client_id: rm_9f65b1d0c9a8
                  name: Main hall
                  enabled: true
                credentials:
                  streamid: rm_9f65b1d0c9a8
                  passphrase: 7Qm2XvA9pR4tL8sZ
                  srt_url: srt://HOST:8890?streamid=rm_9f65b1d0c9a8&mode=caller&latency=120&passphrase=7Qm2XvA9pR4tL8sZ
        "400":
          description: |
            `BAD_SOURCE` — source is neither srt nor webrtc.
            `NAME_TOO_LONG` — over 120 characters.
            `INVALID_LANGUAGES` — a language we do not translate, one repeated, or a target
            equal to the language spoken in the room. Nothing is created: the room is not
            left half-made for you to clean up.
            `INVALID_BODY` — the body is not JSON.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/NoCompany" }

  /api/v1/rooms/{id}:
    servers: *management
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
        description: The room's id, as returned when it was created.
    get:
      tags: [Rooms]
      summary: Read one room
      responses:
        "200":
          description: The room.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  room: { $ref: "#/components/schemas/Room" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RoomNotFound" }

    patch:
      tags: [Rooms]
      summary: Rename a room, or take it off the air
      description: |
        Send `name`, `enabled`, or both. A field you do not send is left alone — a body
        carrying only `enabled` does not blank the name.

        `enabled: false` refuses new connections without destroying the room or its
        credentials. That is what an account in arrears, a season over or a room being
        reconfigured needs: a device somebody has to physically visit should not require a
        new passphrase for any of them.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name: { type: string, maxLength: 120 }
                enabled: { type: boolean }
            example:
              enabled: false
      responses:
        "200":
          description: The room as it now stands.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  room: { $ref: "#/components/schemas/Room" }
        "400":
          description: |
            `NOTHING_TO_UPDATE` — neither field was sent.
            `NAME_TOO_LONG` — over 120 characters.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RoomNotFound" }

    delete:
      tags: [Rooms]
      summary: Delete a room
      description: |
        Gone, along with the credentials its device connected with. If you only want it off
        the air, `PATCH` it with `enabled: false` instead.
      responses:
        "200":
          description: Deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RoomNotFound" }

  /api/v1/rooms/{id}/languages:
    servers: *management
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [Rooms]
      summary: What is spoken in the room, and what it is heard in
      responses:
        "200":
          description: The languages.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  languages: { $ref: "#/components/schemas/Languages" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RoomNotFound" }
        "409": { $ref: "#/components/responses/RoomNotConfigured" }

    put:
      tags: [Rooms]
      summary: Replace them
      description: |
        The whole set travels every time: a language that is not in the body is a language
        the room is no longer translated into. There is no partial update here, because
        there is no way to say "stop translating into German" with one.

        Each target carries its own voice and, if it needs one, its own speed. Some
        languages take longer to say the same thing and their audio piles up; raising the
        speed of that one language brings it back in line. The speed lives with its
        language on purpose — remove the language and its speed goes with it.

        Every code is checked against the languages the engine actually has. Asking for one
        it does not serve fails here rather than producing a room that quietly translates
        into nothing.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Languages" }
            example:
              origin: es
              targets:
                - { lang: en, gender: male, speed: 1.3 }
                - { lang: fr, gender: female }
                - { lang: de, gender: male, speed: 1.3 }
      responses:
        "200":
          description: The languages as they now stand.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  languages: { $ref: "#/components/schemas/Languages" }
        "400":
          description: |
            `INVALID_LANGUAGES` — a code we do not translate, a language repeated, a target
            equal to the origin, or a voice that is neither male nor female.
            `INVALID_SPEED` — a speed outside 0.5…2.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ApiError" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RoomNotFound" }
        "409": { $ref: "#/components/responses/RoomNotConfigured" }

  /api/v1/events:
    servers: *management
    get:
      tags: [Rooms]
      summary: What happened to your rooms' connections
      description: |
        Every attempt one of your devices made to connect, accepted or refused, newest
        first. It is the answer to "my device does not work": a refusal here says which one
        it was — an id that matches no room, a passphrase that does not match, a room that
        is turned off.

        Each event carries a `message` written for a person. Show that rather than the
        `event` code, and your dashboard says the same sentence about it that our panel
        does.

        Kept for thirty days.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 500 }
      responses:
        "200":
          description: The events.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  events:
                    type: array
                    items: { $ref: "#/components/schemas/ConnectionEvent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/NoCompany" }

  /api/v1/rooms/{id}/events:
    servers: *management
    parameters:
      - name: id
        in: path
        required: true
        schema: { type: string }
      - name: limit
        in: query
        schema: { type: integer, default: 50, minimum: 1, maximum: 500 }
    get:
      tags: [Rooms]
      summary: The same, for one room
      responses:
        "200":
          description: The events of that room.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  events:
                    type: array
                    items: { $ref: "#/components/schemas/ConnectionEvent" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/RoomNotFound" }

  /api/v1/usage:
    servers: *management
    get:
      tags: [Usage]
      summary: What your rooms consumed
      description: |
        Speech translated, per room and per day, and the minutes it will be billed as.

        Only speech counts. The engine cuts the audio into sentences and never sends
        silence anywhere, so a four-hour meeting with ninety minutes of talking consumes
        ninety minutes — not four hours.

        What is sold is a minute **into one language**. A minute translated into three is
        three minutes billed, because each language is a separate voice being generated.
        That multiplication is already done in `billable_minutes`; `speech_seconds` is the
        speech it came from, so the arithmetic is yours to check.

        Minutes are rounded up once over the whole range you ask for, never per sentence.
        A sentence the engine could not understand well enough to translate is not counted.

        Defaults to the current month.
      parameters:
        - name: from
          in: query
          description: First day included, `YYYY-MM-DD`. Defaults to the first of this month.
          schema: { type: string, format: date, example: "2026-08-01" }
        - name: to
          in: query
          description: Last day included, `YYYY-MM-DD`. Defaults to today.
          schema: { type: string, format: date, example: "2026-08-31" }
      responses:
        "200":
          description: The consumption of that range.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  from: { type: string, format: date }
                  to: { type: string, format: date }
                  totals: { $ref: "#/components/schemas/UsageTotals" }
                  days:
                    type: array
                    description: One entry per room and day, newest day first.
                    items: { $ref: "#/components/schemas/UsageDay" }
        "400":
          description: |
            `INVALID_RANGE` — a date that is not a date, or a range that ends before it
            starts.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/NoCompany" }

  /api/v1/credit:
    servers: *management
    get:
      tags: [Credit]
      summary: What credit is left
      description: |
        One credit is **one minute of voice in one language**. A minute translated into three
        languages spends three credits, because each language is a separate voice being
        generated.

        Not every account is on credit. One billed separately answers `billing_mode:
        invoiced` with no balance at all rather than zeros somebody would have to interpret.

        Two states matter and they are not the same one:

        * `in_overdraft` — the balance is below zero and everything is still working. Nothing
          is ever cut off mid-sentence to protect a few cents of GPU time; what is owed comes
          off the next top-up.
        * `spent` — the overdraft is gone too, and rooms have stopped translating.

        `credits` rounds **down**. Telling somebody they have three minutes when they have
        two and a half is a lie they find out about mid-event.
      responses:
        "200":
          description: The balance.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  billing_mode:
                    type: string
                    enum: [prepaid, invoiced]
                    description: |
                      `invoiced` means this account is settled another way — a contract, a
                      monthly invoice. Its usage is measured exactly as anybody else's and
                      never charged against credit, so **the balance fields are absent** and
                      a `message` explains why. Check this before reading anything below it.
                  credits:
                    type: integer
                    description: Minutes of voice in one language, rounded down. Absent when `billing_mode` is `invoiced`.
                    example: 420
                  seconds:
                    type: integer
                    description: The same balance in seconds, which is how it is actually kept.
                    example: 25200
                  low:
                    type: boolean
                    description: Worth telling somebody about — under a fifth of what was bought, or under five minutes.
                  in_overdraft:
                    type: boolean
                    description: Below zero and still translating.
                  spent:
                    type: boolean
                    description: Out, including the overdraft. This is the only state in which service stops.
                  overdraft_seconds:
                    type: integer
                    description: How far below zero this account may go.
                    example: 600
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/NoCompany" }

  /api/v1/credit/history:
    servers: *management
    get:
      tags: [Credit]
      summary: Every movement, and why
      description: |
        The balance is derived from exactly these lines, so any number we show can be
        reconciled against them.

        Purchases, gifts, refunds and adjustments are written once. A `usage` line is one
        room on one day and grows through that day — the meter reports every half minute,
        and one line says what three thousand would.
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 50, minimum: 1, maximum: 500 }
      responses:
        "200":
          description: The movements, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  movements:
                    type: array
                    items:
                      type: object
                      properties:
                        at: { type: string, format: date-time }
                        reason:
                          type: string
                          enum: [purchase, gift, usage, refund, adjustment, expiry]
                        reference:
                          type: string
                          description: A payment id, or `roomClientId:day` for a day of usage.
                        note: { type: string }
                        credits:
                          type: number
                          description: The movement in credits. Negative is consumption.
                          example: -12.5
                        seconds: { type: integer }
                        balance_after: { type: integer }
                        credits_after: { type: integer }
                        is_consumption: { type: boolean }
        "400":
          description: "`INVALID_LIMIT` — limit must be between 1 and 500."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/NoCompany" }

  /api/v1/jobs:
    servers:
      - url: "{management}"
        variables:
          management:
            default: https://your-instance.lioraflow.com
    get:
      tags: [Jobs]
      summary: Your queue
      description: |
        The jobs this company has sent, newest first. A job is anything that does
        not fit in a single request: a recording, a document, or a long text.

        ```bash
        curl -s $LIORA_URL/api/v1/jobs \
          -H "x-api-key: $LIORA_KEY"
        ```
      parameters:
        - in: query
          name: limit
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
      responses:
        "200":
          description: The queue
          content:
            application/json:
              schema:
                type: object
                properties:
                  jobs:
                    type: array
                    items: { $ref: "#/components/schemas/Job" }
    post:
      tags: [Jobs]
      summary: Open a job
      description: |
        A text job is queued by this one call and the answer already carries what
        it costs.

        A recording is not. It opens here and the answer tells you where to send
        the bytes; the price is only known once the audio has arrived and its
        speech has been measured, because billing the length of the file would
        be billing the silences.

        A long text, start to finish:

        ```bash
        curl -s $LIORA_URL/api/v1/jobs \
          -H "x-api-key: $LIORA_KEY" -H "content-type: application/json" \
          -d '{"kind":"text","text":"Queridos hermanos...","source":"es","targets":["en","fr"]}'
        ```

        A recording, which is three calls — open, send, close:

        ```bash
        # 1. Open it, and keep the id.
        ID=$(curl -s $LIORA_URL/api/v1/jobs \
          -H "x-api-key: $LIORA_KEY" -H "content-type: application/json" \
          -d '{"kind":"audio","filename":"misa.mp3","source":"es","targets":["en"]}' \
          | python3 -c 'import sys,json; print(json.load(sys.stdin)["id"])')

        # 2. Send the file. In one go here; in pieces, advance the offset.
        curl -s -X PATCH "$LIORA_URL/api/v1/jobs/$ID/upload?offset=0" \
          -H "x-api-key: $LIORA_KEY" --data-binary @misa.mp3

        # 3. Close it. This is where the speech is measured and the price answered.
        curl -s -X POST "$LIORA_URL/api/v1/jobs/$ID/complete" \
          -H "x-api-key: $LIORA_KEY"

        # Later: the subtitles in English.
        curl -s "$LIORA_URL/api/v1/jobs/$ID/result?format=srt&lang=en" \
          -H "x-api-key: $LIORA_KEY" -o misa-en.srt
        ```
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                kind:
                  type: string
                  enum: [text, audio]
                  default: text
                text:
                  type: string
                  description: The text to translate. Up to 100,000 characters.
                source:
                  type: string
                  description: The language it is in. Detected if left out.
                targets:
                  type: array
                  items: { type: string }
                  description: Languages to translate into.
                clean:
                  type: boolean
                  description: Strip HTML and stray characters before translating.
                filename:
                  type: string
                  description: Audio jobs only. The name of the file you will send.
                speakers:
                  type: boolean
                  description: Audio jobs only. Separate the recording by voice.
            examples:
              text:
                summary: A long text into four languages
                value:
                  kind: text
                  text: "Queridos hermanos, hoy nos reunimos…"
                  source: es
                  targets: [en, fr, de, pt]
              audio:
                summary: Open a recording
                value:
                  kind: audio
                  filename: misa-2026-08-16.mp3
                  source: es
                  targets: [en, fr]
                  speakers: true
      responses:
        "201":
          description: Opened
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "402": { description: Not enough credit }
        "413": { description: Too long }
        "429": { description: Already three jobs queued }

  /api/v1/jobs/{id}:
    servers:
      - url: "{management}"
        variables:
          management:
            default: https://your-instance.lioraflow.com
    get:
      tags: [Jobs]
      summary: How a job is doing
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer, format: int64 }
      responses:
        "200":
          description: The job
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "404": { description: No job of yours with that id }
    delete:
      tags: [Jobs]
      summary: Cancel one that has not started
      description: |
        Only while queued or still uploading. One already running has already
        spent the card, and stopping it half way would leave the work charged
        and nothing delivered.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer, format: int64 }
      responses:
        "200": { description: Cancelled }
        "409": { description: Too late to cancel }

  /api/v1/jobs/{id}/upload:
    servers:
      - url: "{management}"
        variables:
          management:
            default: https://your-instance.lioraflow.com
    patch:
      tags: [Jobs]
      summary: Send part of the recording
      description: |
        Send the bytes in pieces, with the offset you are at. If it does not
        match ours you get a 409 carrying the real one, so an upload that was
        interrupted resumes at the exact byte instead of starting over.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer, format: int64 }
        - in: query
          name: offset
          required: true
          schema: { type: integer, format: int64 }
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        "200":
          description: Accepted, with the new offset
          content:
            application/json:
              schema:
                type: object
                properties:
                  offset: { type: integer, format: int64 }
        "409":
          description: Wrong offset — the right one is in the body
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: OFFSET_MISMATCH }
                  offset: { type: integer, format: int64 }

  /api/v1/jobs/{id}/complete:
    servers:
      - url: "{management}"
        variables:
          management:
            default: https://your-instance.lioraflow.com
    post:
      tags: [Jobs]
      summary: Close the upload and queue it
      description: |
        The speech is measured here and the answer says how many seconds of it
        there are and what the job will cost. This is the moment the price
        exists, which is why it is a call of its own.
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer, format: int64 }
      responses:
        "200":
          description: Measured and queued
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "402": { description: Not enough credit for what was measured }
        "422": { description: No speech found in that recording }

  /api/v1/jobs/{id}/result:
    servers:
      - url: "{management}"
        variables:
          management:
            default: https://your-instance.lioraflow.com
    get:
      tags: [Jobs]
      summary: Download what came out
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: integer, format: int64 }
        - in: query
          name: format
          schema: { type: string, enum: [json, txt, srt, vtt], default: json }
        - in: query
          name: lang
          schema: { type: string, default: transcript }
          description: Which language. Defaults to the original transcript.
      responses:
        "200":
          description: The result
          content:
            application/json: { schema: { type: object } }
            text/plain: { schema: { type: string } }
        "409": { description: Not finished yet }
        "410": { description: Gone — results are kept for seven days }

  /process_audio:
    post:
      tags: [Pipeline]
      summary: Translate speech into speech
      description: |
        Takes an audio file and returns spoken translations of it, one per target language.

        Transcription, language detection, translation and synthesis all happen inside this
        one call. If you do not say what language the audio is in, it is detected.

        Two ways to state the targets:

        - `target_configs_json` — a JSON array, one entry per language, when you want to
          choose voice, gender, speed or format per language.
        - `target_languages` — a comma-separated list, when the defaults will do.

        The reply is a ZIP holding one audio file per language plus a `metadata.json`, or
        the same archive inside a JSON body if you ask for `base64`.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [audio]
              properties:
                audio:
                  type: string
                  format: binary
                  description: The speech to translate.
                target_configs_json:
                  type: string
                  description: |
                    JSON array of target configurations. Takes precedence over
                    `target_languages`.
                  example: '[{"language":"en","gender":"female","format":"mp3"},{"language":"de","gender":"male","speed":0.95}]'
                target_languages:
                  type: string
                  description: |
                    Comma-separated language codes, for when the defaults are fine. Ignored
                    if `target_configs_json` is present.
                  example: en,de,fr
                tts_format:
                  type: string
                  enum: [mp3, wav, opus]
                  default: mp3
                  description: Applies to every language, when using `target_languages`.
                source_language:
                  type: string
                  description: |
                    The language being spoken. Detected from the audio when omitted.
                    Also accepted as `source_lang`.
                  example: es
                fast_transcription:
                  type: string
                  enum: ["true", "false"]
                  default: "true"
                  description: Set to `false` to favour accuracy over speed.
                base64:
                  type: string
                  enum: ["true", "false"]
                  default: "false"
                  description: Return JSON with the archive encoded inside, instead of a ZIP.
                voice_fingerprint:
                  type: string
                  description: |
                    A previously captured voice profile, as JSON. When present, the speaker
                    is checked against it and the result reported in the response.
      responses:
        "200":
          description: |
            A ZIP, or JSON when `base64=true` was sent.
          headers:
            X-Detected-Language:
              schema: { type: string }
              description: The language the audio was found to be in.
            X-Total-Files:
              schema: { type: integer }
              description: Files in the archive, counting `metadata.json`.
            X-Total-Time-Ms:
              schema: { type: number }
            X-Speaker-Verified:
              schema: { type: boolean }
              description: Only meaningful when a `voice_fingerprint` was sent.
          content:
            application/zip:
              schema:
                type: string
                format: binary
                description: One audio file per target language, plus `metadata.json`.
            application/json:
              schema: { $ref: "#/components/schemas/ProcessAudioResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /stt/transcribe:
    post:
      tags: [Speech]
      summary: Transcribe audio
      description: |
        Turns speech into text. Give it the language if you know it; leave it out and it is
        detected from the audio.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [audio]
              properties:
                audio:
                  type: string
                  format: binary
                language:
                  type: string
                  description: Detected from the audio when omitted. Also accepted as `lang`.
                  example: es
                translate:
                  type: string
                  enum: ["true", "false"]
                  default: "false"
                  description: Transcribe straight into English rather than the spoken language.
      responses:
        "200":
          description: The transcription.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Transcription" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /tts/batch:
    post:
      tags: [Speech]
      summary: Synthesise speech
      description: |
        Renders a list of texts to audio in one request — typically the same sentence in
        several languages.

        The reply is a ZIP with one file per item, or JSON carrying the same archive when
        `base64=true`.
      parameters:
        - name: base64
          in: query
          schema: { type: boolean, default: false }
          description: Return JSON with the archive encoded inside, instead of a ZIP.
        - name: zip_name
          in: query
          schema: { type: string, default: tts_outputs.zip }
      requestBody:
        required: true
        description: |
          A list of items. An object of the form `{"items": [...]}` is also accepted.
        content:
          application/json:
            schema:
              type: array
              minItems: 1
              items: { $ref: "#/components/schemas/SynthesisItem" }
            example:
              - text: Good morning, everyone.
                language: en
                gender: female
                format: mp3
              - text: Guten Morgen zusammen.
                language: de
                gender: male
                speed: 0.95
      responses:
        "200":
          description: A ZIP, or JSON when `base64=true` was sent.
          headers:
            X-Items:
              schema: { type: integer }
            X-Success-Items:
              schema: { type: integer }
              description: How many of them rendered. Lower than `X-Items` if some failed.
            X-Processing-Time-Ms:
              schema: { type: number }
          content:
            application/zip:
              schema: { type: string, format: binary }
            application/json:
              schema: { $ref: "#/components/schemas/SynthesisResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

  /translate:
    post:
      tags: [Text]
      summary: Translate text
      description: |
        Translates one text into one or more languages in a single request.

        Accepts either a form body or JSON. The JSON form uses the shorter field names
        `source` and `targets`.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string }
                source_language:
                  type: string
                  default: es
                  description: Also accepted as `source`.
                target_languages:
                  type: string
                  default: en
                  description: Comma-separated. Also accepted as `targets`.
                  example: en,de,fr
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string }
                source: { type: string, default: es }
                targets:
                  type: array
                  items: { type: string }
            example:
              text: Buenos días a todos.
              source: es
              targets: [en, de]
      responses:
        "200":
          description: The translations, keyed by language.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TranslationResult" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }
        "503": { $ref: "#/components/responses/Unavailable" }

  /detect-language:
    post:
      tags: [Text]
      summary: Detect the language of a text
      description: |
        Reports the most likely language, with the runners-up and their confidence.

        Accepts either a form body or JSON.
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string }
                top_k:
                  type: integer
                  default: 5
                  minimum: 1
                  maximum: 20
                  description: How many candidates to return.
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string }
                top_k: { type: integer, default: 5, minimum: 1, maximum: 20 }
                threshold:
                  type: number
                  format: float
                  description: Drop candidates below this confidence.
            example:
              text: Buenos días a todos.
              top_k: 3
      responses:
        "200":
          description: The detection.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LanguageDetection" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "500": { $ref: "#/components/responses/ServerError" }

components:
  securitySchemes:
    apiKey:
      type: apiKey
      in: header
      name: x-api-key
      description: |
        Also accepted as `Authorization: Bearer <key>` and as an `api_key` query parameter,
        for clients that cannot set headers.

        The translation endpoints and the rooms/app endpoints take different keys — see
        Authentication.

  responses:
    BadRequest:
      description: Something is missing or malformed in the request.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: The key is missing or not valid.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    ServerError:
      description: The request was understood but could not be completed.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    NoCompany:
      description: The key is valid but is not attached to a company, so it owns no rooms.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    RoomNotConfigured:
      description: |
        The room exists but its translation pipeline has not been set up yet, so there is
        nothing to read or change. A normal state for a room just created, not a fault in
        the request — write to us and we leave it ready.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    RoomNotFound:
      description: |
        No room of yours has that id. A room belonging to somebody else answers the same
        way: the difference is of no use to you and of plenty to somebody probing.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
    Unavailable:
      description: |
        No capacity to serve this request right now. Worth retrying; it is a transient
        condition, not a rejection of the request.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Job:
      type: object
      properties:
        id: { type: integer, format: int64 }
        kind: { type: string, enum: [text, audio] }
        state:
          type: string
          enum: [uploading, queued, running, done, failed]
        source: { type: string }
        targets:
          type: array
          items: { type: string }
        input_size: { type: integer, format: int64 }
        credits_seconds:
          type: integer
          format: int64
          description: What it costs, in seconds of credit — how the balance is kept.
        credits_minutes:
          type: number
          description: The same figure in minutes, which is how it is sold.
        pieces: { type: integer }
        done: { type: integer }
        speakers:
          type: integer
          description: How many distinct voices were found, when you asked for them.
        result:
          type: string
          description: Where to download it, once the state is done.
        created_at: { type: string, format: date-time }

    # ── Rooms and app ────────────────────────────────────────────────────────
    Room:
      type: object
      description: |
        A room you own. The passphrase is deliberately absent: it belongs to the create
        response and to nothing else.
      properties:
        id:
          type: string
          description: What you name the room by in these endpoints.
        source:
          type: string
          enum: [srt, webrtc]
        client_id:
          type: string
          description: |
            What the device presents as its SRT `streamid`. Issued by us, never chosen:
            it has to tell one room apart from every other on the platform.
        name: { type: string }
        enabled:
          type: boolean
          description: False refuses new connections and keeps everything else intact.
        app_id:
          type: string
          description: The short name the app lists it under; absent when it is not offered there.
        app_name: { type: string }
        app_order: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
      example:
        id: 4f2c8e1a-77b0-4a3e-9f65-2b1d0c9a8e77
        source: srt
        client_id: rm_9f65b1d0c9a8
        name: Main hall
        enabled: true
        app_id: main-hall
        app_name: Main hall
        app_order: 0

    RoomCredentials:
      type: object
      description: |
        Returned once, by the create call, for a room whose device authenticates. There is
        no endpoint that gives them again.
      properties:
        streamid:
          type: string
          description: The device id — the same value as the room's `client_id`.
        passphrase:
          type: string
          description: |
            Per device, and not optional: SRT refuses an unencrypted connection whatever
            else it gets right.
        srt_url:
          type: string
          description: |
            The whole thing assembled. It is here because the alternative is every
            integrator building this string and one of them getting the mode or the
            latency wrong. Empty if this installation has not been told its public address.

    Languages:
      type: object
      description: What is spoken in a room, and what the audience hears.
      required: [origin, targets]
      properties:
        origin:
          type: string
          description: |
            The language of whoever is speaking. Get this wrong and nothing translates
            well — everything downstream is transcribed as if it were this.
        targets:
          type: array
          maxItems: 20
          description: |
            One entry per language the audience can listen in. Each is a voice generated
            live on a GPU, not a subtitle, so the list is what the room costs to run.
          items:
            type: object
            required: [lang]
            properties:
              lang: { type: string }
              gender:
                type: string
                enum: [female, male]
                default: female
                description: The voice it is spoken in.
              speed:
                type: number
                minimum: 0.5
                maximum: 2
                description: |
                  Only for a language that runs late. Some take longer to say the same
                  thing and their audio piles up behind the speaker; raising just that one
                  brings it back in line. Left out, it runs at the room's own rate.
      example:
        origin: es
        targets:
          - { lang: en, gender: male, speed: 1.3 }
          - { lang: fr, gender: female }


    UsageTotals:
      type: object
      description: A range summed — what an invoice for it would say.
      properties:
        billable_minutes:
          type: integer
          description: |
            What is charged: speech multiplied by the languages it went out in, rounded up
            once over the whole range.
          example: 270
        minutes:
          type: integer
          description: Minutes of speech, before the languages multiply them.
          example: 90
        speech_seconds:
          type: number
          description: The seconds those minutes were rounded from.
          example: 5400
        segments:
          type: integer
          description: Sentences translated and delivered.
          example: 940
        rooms:
          type: integer
          description: How many of your rooms were used in the range.
          example: 2

    UsageDay:
      type: object
      description: One room's consumption on one day.
      properties:
        client_id:
          type: string
          description: The room. It is what its device connects with.
          example: rm_a1b2c3d4
        day: { type: string, format: date, example: "2026-08-03" }
        speech_seconds: { type: number, example: 1800 }
        language_seconds:
          type: number
          description: |
            Speech multiplied by the languages it was delivered into — the unit that is
            billed, before rounding.
          example: 5400
        segments: { type: integer, example: 300 }

    ConnectionEvent:
      type: object
      description: One attempt a device made to connect, and how it went.
      properties:
        event:
          type: string
          enum:
            - connected
            - disconnected
            - rejected_unknown_device
            - rejected_not_encrypted
            - rejected_bad_passphrase
            - rejected_disabled
            - rejected_capacity
          description: |
            `rejected_unknown_device` — the streamid matches no room of yours.
            `rejected_disabled` — the room exists and is turned off.
            `rejected_bad_passphrase` — right id, wrong secret.
            `rejected_not_encrypted` — the connection URL has no passphrase at all.
            `rejected_capacity` — we could not take it at that moment.
        at: { type: string, format: date-time }
        rejected:
          type: boolean
          description: True when the connection never happened.
        message:
          type: string
          description: |
            What it means, in words, phrased as what to do about it. Show this rather than
            the code.
        detail:
          type: string
          description: Whatever the gateway could add — usually the address it came from.
        client_id:
          type: string
          description: The streamid the device presented, recognised or not.
        room_id:
          type: string
          description: Absent when the id matched no room, which is the point of that event.
      example:
        event: rejected_bad_passphrase
        at: "2026-08-12T15:42:12.532Z"
        rejected: true
        message: The id is right but the passphrase is not. Copy it into the device again.
        detail: from 88.12.4.9:52954
        client_id: rm_9f65b1d0c9a8
        room_id: 4f2c8e1a-77b0-4a3e-9f65-2b1d0c9a8e77

    Admin:
      type: object
      description: |
        One phone allowed to speak into your rooms, and how its voice is handled.

        Each administrator carries their own language, and that language travels with them
        to the engine: their audio is transcribed from what *they* speak, not from what the
        room usually is. While one of them is on air in another language, the room's own
        language becomes one more thing we translate into, so its regular audience is not
        left in silence.
      required: [device, sourceLang]
      properties:
        device:
          type: string
          maxLength: 128
          description: The identifier the phone signs its requests with. No spaces.
        name:
          type: string
          maxLength: 80
          description: Who it is, so a list of identifiers can be read by a human. The app never shows it.
        enabled:
          type: boolean
          default: true
          description: |
            False keeps the entry without letting it broadcast, which is what somebody
            away rather than gone needs. Absent means true.
        sourceLang:
          type: string
          description: The language they speak, and the one their audio is transcribed from.
        canPublishTo:
          type: array
          items: { type: string }
          description: |
            The app room ids they may take. A room that is not listed here is refused, and
            an empty list refuses everything.
        publishTrackName:
          type: string
          pattern: "^[A-Za-z0-9_-]{1,64}$"
          description: |
            How their voice is published. Two administrators who may take the same room
            cannot share it — they would publish over each other, and the listener would
            hear whichever arrived last with no clue why. Empty defaults to
            `source_` and their language.
      example:
        device: 8e41b6d2-0c93-4f77-9a25-6d0be3f21a48
        name: Tour guide
        enabled: true
        sourceLang: en
        canPublishTo: [side-room]
        publishTrackName: source_en

    ApiError:
      type: object
      description: |
        The shape every rooms/app endpoint refuses with. `error` is a stable code worth
        branching on; `message` is Spanish prose for a person, and may change.
      properties:
        success: { type: boolean }
        error: { type: string }
        message: { type: string }
      example:
        success: false
        error: INVALID_ADMIN
        message: 'Tour guide: we do not translate the language "klingon"'

    Error:
      type: object
      properties:
        error:
          type: string
          description: What went wrong, in plain text.
      example:
        error: audio file es requerido

    Transcription:
      type: object
      properties:
        text: { type: string }
        language:
          type: string
          description: The language of the audio, detected when it was not given.
        translated:
          type: boolean
          description: Whether `text` is in English rather than the spoken language.
        filename: { type: string }
        timing_ms:
          type: object
          properties:
            total: { type: number }
            inference: { type: number }
      example:
        text: Buenos días a todos.
        language: es
        translated: false
        filename: meeting.wav
        timing_ms: { total: 184, inference: 157 }

    LanguageDetection:
      type: object
      properties:
        text: { type: string }
        language: { type: string }
        confidence: { type: number, format: float }
        top_predictions:
          type: array
          description: The candidates considered, most likely first.
          items:
            type: object
            properties:
              language: { type: string }
              confidence: { type: number, format: float }
        timing_ms: { type: number }
      example:
        text: Buenos días a todos.
        language: es
        confidence: 0.98
        top_predictions:
          - { language: es, confidence: 0.98 }
          - { language: pt, confidence: 0.01 }
        timing_ms: 3

    TranslationResult:
      type: object
      properties:
        source_text: { type: string }
        source_language: { type: string }
        target_languages:
          type: array
          items: { type: string }
        translations:
          type: object
          description: The translated text, keyed by language code.
          additionalProperties: { type: string }
        timing_ms: { type: number }
      example:
        source_text: Buenos días a todos.
        source_language: es
        target_languages: [en, de]
        translations:
          en: Good morning, everyone.
          de: Guten Morgen zusammen.
        timing_ms: 186

    SynthesisItem:
      type: object
      required: [text, language]
      properties:
        text: { type: string }
        language: { type: string, example: en }
        gender:
          type: string
          enum: [male, female]
          description: Which voice to use for that language.
        voice:
          type: string
          description: A specific voice, when you want one rather than the default.
        speed:
          type: number
          format: float
          default: 1
          description: Below 1 is slower, above 1 is faster.
        format:
          type: string
          enum: [mp3, wav, opus]
          default: mp3
        sample_rate:
          type: integer
          description: Defaults to the voice's own rate.

    SynthesisResult:
      type: object
      description: Returned in place of the ZIP when `base64=true` was sent.
      properties:
        success: { type: boolean }
        items:
          type: integer
          description: How many were asked for.
        successful_items:
          type: integer
          description: How many rendered.
        zip: { $ref: "#/components/schemas/EncodedArchive" }

    EncodedArchive:
      type: object
      description: A ZIP carried inside a JSON response.
      properties:
        format: { type: string, const: zip }
        encoding: { type: string, const: base64 }
        data:
          type: string
          description: The archive, base64-encoded.
        filename: { type: string }
        content_type: { type: string, const: application/zip }

    ProcessAudioResult:
      type: object
      description: Returned in place of the ZIP when `base64=true` was sent.
      properties:
        success: { type: boolean }
        source_audio:
          type: object
          properties:
            filename: { type: string }
            detected_language: { type: string }
            transcribed_text: { type: string }
            original_text:
              type: string
              description: The transcription before any normalisation.
            confidence: { type: number, format: float }
        source_language:
          type: string
          description: The language the translations were made from.
        translations:
          type: object
          description: The translated text, keyed by language code.
          additionalProperties: { type: string }
        audio_files: { $ref: "#/components/schemas/EncodedArchive" }
        audio_files_info:
          type: array
          description: One entry per audio file in the archive.
          items:
            type: object
            properties:
              filename: { type: string }
              language: { type: string }
              format: { type: string }
              voice: { type: string }
              gender: { type: string }
              speed: { type: number, format: float }
        timing:
          type: object
          description: Milliseconds spent, broken down by stage and in total.
          additionalProperties: { type: number }
        speaker_verified:
          type: boolean
          description: Only meaningful when a `voice_fingerprint` was sent.
        fingerprint_requested: { type: boolean }
