> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kugelaudio.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Speech to Text

> Transcribe audio with luchs-1 over REST or streaming WebSocket

`luchs-1` transcribes English speech. Upload a complete recording over REST, or
stream live PCM16 audio over WebSocket and receive revisable partial
hypotheses plus an authoritative final result.

<Note>
  `luchs-1` is the only accepted model identifier for this endpoint. There is
  no alias for any previous identifier — see [Migrating from
  `qwen3-asr`](#migrating-from-qwen3-asr) below.
</Note>

## Upload a complete recording

<ParamField path="POST" method="/v1/audio/transcriptions" />

Two request shapes are accepted: a JSON body with base64 audio, or a
`multipart/form-data` upload in the OpenAI Whisper API shape.

### Request fields

<ParamField body="audio_b64" type="string">
  Base64-encoded audio (JSON body only). Mutually exclusive with the
  multipart `file` field.
</ParamField>

<ParamField body="file" type="file">
  The audio file (multipart upload only).
</ParamField>

<ParamField body="model" type="string" default="luchs-1">
  Optional. Omit it to select `luchs-1`. An explicit value must equal
  `luchs-1` exactly — any other value, including a previously valid
  identifier, is rejected before the file is read or usage is recorded.
</ParamField>

<ParamField body="language" type="string">
  Optional. Accepted and forwarded, but does not currently change what gets
  recognized — see [Supported language](#supported-language).
</ParamField>

<ParamField body="sample_rate" type="integer" default="16000">
  JSON body only, 8000–48000. The endpoint reads the real sample rate out of
  the uploaded container, so this field only matters for raw PCM without a
  container header.
</ParamField>

<ParamField body="response_format" type="string" default="json">
  Only `json` is currently accepted. `srt`, `vtt`, verbose JSON and SSE
  upload streaming are rejected explicitly rather than silently downgraded.
</ParamField>

### Response fields

| Field               | Type           | Description                                                                                                                                                           |
| ------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text`              | string         | OpenAI-compatible alias of `transcript`.                                                                                                                              |
| `transcript`        | string         | The transcribed text.                                                                                                                                                 |
| `language`          | string         | What was actually transcribed, not necessarily what you requested.                                                                                                    |
| `duration_s`        | number         | Duration of the accepted audio, in seconds.                                                                                                                           |
| `model`             | string         | Always `"luchs-1"`.                                                                                                                                                   |
| `model_revision`    | string \| null | The immutable upstream checkpoint revision. Pin qualification or debugging notes to this, not to `model`.                                                             |
| `word_timestamps`   | array          | Empty for `luchs-1` — see [Not promised](#not-promised).                                                                                                              |
| `word_alternatives` | array          | Ordered spelling candidates for ambiguous decoded words, with calibrated probabilities. Present inline on HTTP; on WebSocket it arrives as a separate deferred event. |

### Examples

<CodeGroup>
  ```bash cURL — JSON, model omitted theme={null}
  curl https://api.kugelaudio.com/v1/audio/transcriptions \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "audio_b64": "'"$(base64 -i speech.wav)"'"
    }'
  ```

  ```bash cURL — multipart, explicit model theme={null}
  curl https://api.kugelaudio.com/v1/audio/transcriptions \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -F "file=@speech.wav" \
    -F "model=luchs-1" \
    -F "response_format=json"
  ```

  ```json Response theme={null}
  {
    "text": "I'd like to book a flight to Berlin.",
    "transcript": "I'd like to book a flight to Berlin.",
    "language": "English",
    "duration_s": 3.42,
    "model": "luchs-1",
    "model_revision": "7278e1e70fe206f11671096ffdd38061171dd6e5",
    "word_timestamps": [],
    "word_alternatives": []
  }
  ```
</CodeGroup>

### Errors

| Status | `error_code`        | Body / trigger                                                                                                                                                         |
| -----: | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|  `401` | `UNAUTHORIZED`      | Missing or invalid API key. Checked before the `model` field, so a bad key plus a bad model still returns `401`.                                                       |
|  `422` | `VALIDATION_ERROR`  | JSON body with an unsupported `model` (including the retired `qwen3-asr`): `{"error": "Invalid transcription request", "error_code": "VALIDATION_ERROR", "code": 422}` |
|  `400` | `VALIDATION_ERROR`  | Multipart `model` form field with an unsupported value: `{"error": "Unsupported ASR model; use luchs-1", "error_code": "VALIDATION_ERROR", "code": 400}`               |
|  `422` | —                   | Audio bytes could not be decoded: `Cannot decode audio: …`                                                                                                             |
|  `503` | `MODEL_UNAVAILABLE` | No ASR backend is currently configured.                                                                                                                                |

Rejection for an unsupported model happens after authentication and before any
transcription or usage recording — a rejected request is never billed.

## Stream live audio

<ParamField path="WS" method="/v1/audio/transcriptions/stream" />

Connect to `wss://api.kugelaudio.com/v1/audio/transcriptions/stream`, send a
`config` frame, then base64 PCM16 audio chunks, then an explicit
end-of-speech signal.

### Client frames

```json theme={null}
{"type": "config", "sample_rate": 16000, "language": "en", "model": "luchs-1"}
{"type": "audio_chunk", "audio_b64": "..."}
{"type": "end_of_speech"}
```

`model` on the `config` frame is optional. Omit it, or send `"luchs-1"`. Any
other value is rejected before any audio is forwarded:

```json theme={null}
{"error": "Unsupported ASR model; use luchs-1", "error_code": "VALIDATION_ERROR", "code": 400}
```

followed by WebSocket close code **`1003`** (reason: the same message).

### Server frames

* **Partial** — `{"type": "partial", "partial_text": "...", "is_final": false}`.
  `partial_text` is the complete rolling hypothesis; replace your previous
  value rather than appending. Partials never carry `model` or
  `model_revision` — that absence is how you tell a revisable guess from an
  attributable result. Partials arrive roughly every 4 seconds of **accepted
  audio**, not wall-clock time; a turn shorter than 4 seconds produces no
  partial at all, only the final frame.
* **Final** — `{"type": "partial", "partial_text": "...", "is_final": true, "model": "luchs-1", "model_revision": "...", "turn_end_reason": "client_end_of_speech", "word_alternatives": []}`.
  The only frame that carries `model` and `model_revision`, and the
  authoritative result for the turn.
* **Alternatives** — `{"type": "alternatives", "partial_text": "...", "model": "luchs-1", "model_revision": "...", "word_alternatives": [...]}`.
  Arrives after the final, is never `is_final`, and repeats the final's text
  so it can be handled independently. An empty list means none were produced,
  not that scoring was skipped.

`turn_end_reason` on the final frame is one of exactly three values:
`client_end_of_speech`, `model_end_of_turn`, `silence_timeout`. Treat any
other value as a protocol error rather than guessing at its meaning.

### Close codes

|   Code | Meaning                                                                                                                                                     |
| -----: | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `1000` | Normal close.                                                                                                                                               |
| `1001` | The server is draining for a rolling update. The authoritative final frame for the audio you already sent arrives before this close; reconnect to continue. |
| `1003` | The client asked for a model other than `luchs-1`, or the backend does not support streaming.                                                               |
| `1011` | No ASR backend is wired, or the upstream stream failed.                                                                                                     |
| `1013` | The replica accepted the connection only to report it is draining. Retry against the load balancer.                                                         |
| `4001` | Authentication failed.                                                                                                                                      |
| `4029` | Rate limit exceeded.                                                                                                                                        |
| `4500` | The backend is temporarily unavailable.                                                                                                                     |

See [Error Codes](/api-reference/errors) for the shared HTTP/WebSocket error
body shape.

## Supported language

`luchs-1` transcribes **English**. The `language` field — on the JSON body,
the multipart form, and the WebSocket `config` frame — is optional and
accepted, but it does not currently change what gets recognized: audio is
transcribed as English regardless of what you send. The field is forwarded
rather than rejected, so setting it does not break your request; it just has
no effect on recognition yet. The response's `language` field always reports
what was actually transcribed.

## Audio limits

* **Containers**: complete WAV/RIFX/RF64, FLAC and Ogg files, plus most other
  common container formats. Sample rates from 8 kHz to 48 kHz are accepted;
  audio is resampled internally as needed.
* **Streaming input**: raw PCM16 mono, base64-encoded in `audio_chunk`
  messages, at the rate declared in `config` (default 16000 Hz).
* **No advertised maximum duration.** A very long turn can fail while the
  final result is being produced rather than being rejected up front — keep
  turns to a length appropriate for a live conversation rather than uploading
  arbitrarily long recordings over the streaming path.
* **Partial cadence**: partials are due roughly every 4 seconds of accepted
  audio; see [Server frames](#server-frames) above.

## Not promised

No word or segment timestamps and no diarization from `luchs-1` today.
`word_timestamps` exists in the response shape for backends that produce it;
for `luchs-1` it is always empty, meaning "not produced," never "no speech
detected."

## Migrating from `qwen3-asr`

The previously published model identifier `qwen3-asr` is **retired with no
alias**. Any request that names it explicitly is rejected; omitting the field
entirely, or naming `luchs-1`, keeps working.

| Surface                  | Before                                                            | After                                      |
| ------------------------ | ----------------------------------------------------------------- | ------------------------------------------ |
| curl (multipart)         | `-F "model=qwen3-asr"`                                            | Drop the field, or `-F "model=luchs-1"`    |
| Python SDK               | `client.asr.transcribe(audio, model="qwen3-asr")`                 | Drop the argument, or `model="luchs-1"`    |
| JavaScript SDK           | `{ audio, model: 'qwen3-asr' }`                                   | Drop the field, or `model: 'luchs-1'`      |
| Java SDK                 | `transcribe(audio, filename, contentType, language, "qwen3-asr")` | Use `ASRResource.MODEL_ID`, or `"luchs-1"` |
| WebSocket `config` frame | `{"type": "config", "model": "qwen3-asr", ...}`                   | Drop the field, or `"model": "luchs-1"`    |

Also update anything that reads the response `model` field — it now always
returns `"luchs-1"` instead of a prior backend name. If you need to identify
the exact underlying artifact (for qualification, debugging, or an audit
trail), read `model_revision` instead; it is the immutable checkpoint
revision and is never rewritten.

## SDKs

<CardGroup cols={3}>
  <Card title="Python" icon="python" href="/sdks/python/speech-to-text">
    `client.asr.transcribe(...)`
  </Card>

  <Card title="JavaScript" icon="js" href="/sdks/javascript/speech-to-text">
    `client.asr.transcribe({ ... })`
  </Card>

  <Card title="Java" icon="java" href="/sdks/java/speech-to-text">
    `client.asr().transcribe(...)`
  </Card>
</CardGroup>

None of the three SDKs has a WebSocket streaming client for speech-to-text
yet — each only wraps the REST upload above. For streaming, use the raw
WebSocket protocol described in [Stream live audio](#stream-live-audio)
directly. This is expected to change in the next major release of each SDK.
