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

# Speech to text with speaker diarization

> Transcribe a recording and label who said each word. ElevenLabs Scribe-compatible.

`POST https://api.lokutor.com/v1/speech-to-text` transcribes a recording and, when you ask for it, says which speaker said each word. The request and response follow the ElevenLabs Speech to Text (Scribe) shape, so a client written for that API works by changing the base URL and the key.

## What it is, honestly

Diarization splits a recording into speakers and numbers them `speaker_0`, `speaker_1`, ... in order of first appearance.

* **Where it works well:** English meetings. On a real 60-minute, 5-person meeting from the AMI corpus it measured a diarization error rate of 8.0%, found 5 of 5 speakers, and attributed 99.4% of words to the right speaker.
* **Where it does not:** very short interjections ("yeah", "mm") and overlapping speech are missed or given to the wrong speaker.
* **Not yet measured:** telephone-quality audio and non-English audio. Treat the speaker labels there as unverified.

Transcription itself is the same recogniser as `/stt/transcribe`, with greedy decoding (not the language-model beam).

## Request

`multipart/form-data`. Authenticate with `xi-api-key: YOUR_KEY`, `X-API-Key: YOUR_KEY` or `Authorization: Bearer YOUR_KEY`.

```bash theme={null}
curl https://api.lokutor.com/v1/speech-to-text \
  -H "xi-api-key: $LOKUTOR_API_KEY" \
  -F file=@meeting.wav \
  -F language_code=eng \
  -F diarize=true \
  -F num_speakers=5 \
  -F timestamps_granularity=word
```

| Field | Type | Default | Description |
| - | - | - | - |
| `file` | file | required | WAV, PCM 8 or 16 bit, mono or stereo (stereo is mixed to mono), 8 to 48 kHz. Other formats (MP3, M4A, ...) are not decoded yet: convert first. |
| `model_id` | string | | Accepted and ignored. |
| `language_code` | string | detect | ISO-639-1 or ISO-639-3: `ca`/`cat`, `de`/`deu`, `en`/`eng`, `es`/`spa`, `eu`/`eus`, `fr`/`fra`, `gl`/`glg`, `it`/`ita`, `pt`/`por`. Any other language is refused with 400. |
| `diarize` | boolean | `false` | Label each word with its speaker. |
| `num_speakers` | integer 1-32 | detect | The number of speakers, if you know it. Improves the result when given. |
| `timestamps_granularity` | `none`, `word`, `character` | `word` | `character` behaves as `word`. With `none`, `words` is empty, unless `diarize` is true (the speakers are returned per word regardless). |
| `tag_audio_events` | boolean | | Accepted and ignored: no audio events are produced. |

## Response

```json theme={null}
{
  "language_code": "eng",
  "language_probability": 1.0,
  "text": "Hello everyone. Thanks for joining.",
  "words": [
    {"text": "hello", "start": 0.12, "end": 0.40, "type": "word", "speaker_id": "speaker_0"},
    {"text": " ", "start": 0.40, "end": 0.56, "type": "spacing", "speaker_id": "speaker_0"},
    {"text": "everyone", "start": 0.56, "end": 0.92, "type": "word", "speaker_id": "speaker_0"},
    {"text": "thanks", "start": 2.10, "end": 2.34, "type": "word", "speaker_id": "speaker_1"}
  ]
}
```

* `language_code`: ISO-639-3 (`eng`, `spa`, ...). `language_probability` is `1.0` when you passed `language_code` and `null` when the language was detected.
* `text`: the full transcript, formatted.
* `words[].type`: `word`, or `spacing` for the gap between words.
* `words[].speaker_id`: `speaker_0` for everything when `diarize` is false.

## Errors

Errors use the ElevenLabs shape, with the HTTP status you would expect:

```json theme={null}
{"detail": {"status": "audio_too_long", "message": "Diarization takes recordings up to 7200 seconds; this one is 7510."}}
```

| HTTP | `status` | When |
| - | - | - |
| 400 | `invalid_parameters` | `diarize`, `num_speakers` or `timestamps_granularity` is not valid. |
| 400 | `unsupported_language` | `language_code` is not one of the nine languages. |
| 400 | `unsupported_audio` | The file is not a readable WAV or its sample rate is not supported. |
| 400 | `audio_too_long` | A diarization request longer than the limit (7,200 s, 2 hours). |
| 400 | `invalid_request` | No `file`, an empty file, or the recogniser refused the request. |
| 401 | `invalid_api_key` | Missing or invalid key. |
| 402 | `quota_exceeded` | Your balance or plan limit does not cover the request. |
| 403 | `diarization_not_available` | Diarization is not enabled yet. |
| 413 | `file_too_large` | Over 10 MB, or over 256 MB for a diarization request. |
| 429 | `too_many_concurrent_requests` | Your account already has a diarization request running. |
| 503 | `diarization_busy` | No GPU could be started for your file within 15 minutes. Nothing is billed. Wait a few minutes (the `Retry-After` header says 120 s) and send it again. |
| 502 | `transcription_failed` | The recogniser could not process the audio. Retry. |

## Limits

* Audio length for `diarize=true`: 7,200 seconds (2 hours).
* File size: 10 MB without diarization; 256 MB with it. Two hours of 16 kHz mono WAV is 219 MB; stereo or 44.1 kHz audio of that length is larger than the limit, so send 16 kHz mono (or cut the file).
* One diarization request at a time per account. Without diarization the usual concurrency rules apply.
* **Speaker labels run on a GPU that is started for your request.** If no diarization has run in the last 10 minutes, the first request waits 5 to 9 minutes (typically 8) for the GPU to start before processing begins; requests that follow within 10 minutes start at once. Processing itself takes about 2.7 minutes for an hour of audio. Keep the connection open and set your client timeout to at least 20 minutes. There is no polling endpoint: the answer arrives on the same connection.

## Pricing

Transcription is billed per audio minute at the speech-to-text rate. Speaker labels are a separate line on top: `stt_diarization_per_minute` per audio minute, with a minimum of `stt_diarization_min_minutes` audio minutes per request (both on the rate card, `GET /v1/pricing`). At the current card that is $0.026 per audio minute ($1.56 per hour), billed for at least 60 minutes, so a request is never charged less than $1.56 and a two-hour file is $3.12. The minimum exists because the speaker model runs on a GPU started for your request, which we pay for while it starts and for the ten minutes it waits for the next one; sending several files in a row does not lower the price of each, so send long recordings rather than many short ones. A request that fails, or is answered 503 `diarization_busy`, is not billed.

## Differences from ElevenLabs

* `words[].text` is spoken-form (lower case, no punctuation); the top-level `text` is formatted.
* No `logprob` fields.
* No `audio_event` entries; `tag_audio_events` is ignored.
* `language_probability` is `1.0` when `language_code` is given and `null` when detected (ElevenLabs returns an estimated probability).
* A word's `end` is the frame its last piece was emitted at, so words are short and `spacing` entries carry the gaps between them.
* Speakers are numbered by first appearance (`speaker_0` is whoever speaks first).
* Languages: `ca de en es eu fr gl it pt` only.
* WAV input only for now.
* Greedy decoding, not the language-model beam.
* Not implemented: `webhook`, `cloud_storage_url`, `additional_formats`, `entity_detection`, `keyterms` on this route.

## Also on `/stt/transcribe`

`POST /v1/stt/transcribe` accepts `diarize` and `num_speakers` too, and returns Lokutor's own shape: the usual fields plus `turns` (consecutive speech by one speaker) and `segments`, exactly as the recogniser produces them. Same limits and errors (in Lokutor's error shape).

## Attribution

Speaker diarization uses [pyannote speaker-diarization-community-1](https://huggingface.co/pyannote/speaker-diarization-community-1) (pyannote.audio), licensed under CC-BY-4.0. See [Licences](https://docs.lokutor.com/licences) for the full notice.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.