> ## 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.

# Live Translation

> Speech-to-speech translation over WebSocket (wss://api.lokutor.com/v1/ws/translate): config, audio in, transcripts, translations and translated audio out, incremental mode, latency frames, errors, capacity and pricing

A speaker talks in one language and listeners hear translated speech in another, a phrase behind, keeping the intent, register and tone of what was said (irony stays irony). The output voice is any voice your account can use for text-to-speech: a stock voice or one of your cloned voices.

## Connection

```
wss://api.lokutor.com/v1/ws/translate
```

`/ws/translate` is the same endpoint. Authenticate the handshake as for [`/ws/tts`](https://docs.lokutor.com/api-reference/streaming#connection): an `Authorization: Bearer` or `X-API-Key` header, or `?api_key=` where headers cannot be set. In a browser, pass a short-lived `wst_` ticket rather than your key ([Authentication](https://docs.lokutor.com/authentication.md)).

One translation session holds one concurrent-session slot of your account, the same pool as text-to-speech sockets.

## Messages

### Client to server: config

The first message, within 10 seconds of connecting:

```json theme={null}
{
  "type": "config",
  "source_language": "es",
  "target_language": "en",
  "voice": "en_f1",
  "input_rate": 16000,
  "output_rate": 44100,
  "tone": "relaxed, dry humour"
}
```

| Field | Required | Meaning |
| :- | :- | :- |
| `source_language` | no | Language spoken. `""` (or omitted) detects it and follows the speaker if they switch. A named language is always used as given. |
| `target_language` | yes | Language of the translated speech. Must differ from `source_language`. |
| `voice` | no | Stock voice id or one of your cloned voices (`clone_...`). Empty: the default voice for the target language. |
| `input_rate` | no | Sample rate of your PCM. Default 16000. One of 8000, 11025, 12000, 16000, 22050, 24000, 32000, 44100, 48000. |
| `output_rate` | no | Sample rate of the audio you get back. Default 44100, same set of rates. |
| `incremental` | no | Default `true`: the speaker is translated while still talking (below). `false` translates each phrase after the speaker's pause. |
| `tone` | no | A free-text hint about how the speaker talks, at most 300 characters. It guides delivery; it does not change what is said. |

Languages: `es`, `ca`, `gl`, `eu`, `en`, `fr`, `de`, `it`, `pt` (`GET /languages`).

### Client to server: audio

Binary frames of the speaker's microphone: PCM16 little-endian, mono, at `input_rate`, any frame size (20-100 ms is typical). Send them as you capture them, continuously; silence is fine. Phrases are cut at the speaker's pauses.

`{"type":"end"}` (text) finishes the session: the phrase in progress is translated, everything queued is spoken, then the server sends `{"type":"end"}` and closes. Closing the socket stops everything at once.

### Server to client

| Message | Meaning |
| :- | :- |
| `{"type":"ready","session_id":"...","input_rate":16000,"output_rate":44100}` | Config accepted; start sending audio. |
| `{"type":"transcript","seq":1,"text":"...","language":"es","final":false}` | Incremental mode only: a clause of phrase `seq` that was committed and sent to translation. |
| `{"type":"transcript","seq":1,"text":"...","language":"es","final":true}` | What the speaker said in the whole phrase, in the source language (`language` is the one used). One per phrase; it replaces the `final:false` pieces. |
| `{"type":"translation","seq":1,"text":"...","final":false}` | A piece of the translation as it is produced. |
| `{"type":"translation","seq":1,"text":"<whole translation>","final":true}` | The complete translation of phrase `seq`, sent when the phrase has ended. |
| binary frame | PCM16 mono at `output_rate`: the translated speech. Frames arrive in phrase order, as soon as each sentence or clause is ready. |
| `{"type":"latency", ...}` | After phrase `seq` has been spoken (below). |
| `{"type":"error","data":{"code","message","detail","retryable"}}` | Same structure as the other WebSockets ([Errors](https://docs.lokutor.com/api-reference/errors.md)). |
| `{"type":"end"}` | The session is finished (after your `end`). |

`seq` numbers phrases from 1. The speaker can keep talking while earlier phrases are still being spoken: their transcripts arrive immediately and their audio queues behind the current one. Nothing is reordered or dropped silently. If the backlog grows past 64 phrases, a phrase is dropped and an `error` says so.

If a phrase is already in the target language it is not translated; the words are spoken as they are.

### Incremental mode (default)

While the speaker talks, the server re-transcribes the growing phrase about every 500 ms. Words that two consecutive transcripts agree on (less the last two, which are the least reliable) are *committed* and translated as a clause, cut at punctuation or after about six words, as a sentence in progress so the translation continues naturally. Audio already sent is never retracted or repeated: committed words are final. When the speaker pauses, the whole phrase is transcribed once more and only the words after the committed ones are translated.

Expect the first translated audio to start before the speaker has finished the sentence. The cost is that a clause is translated before the rest of the sentence is known, so word order and nuance can be a little less polished than with `incremental: false`. At most one extra recognition request per session is in flight at a time, and none are made for the part of a phrase beyond 12 s of speech (the final transcription covers it).

### Latency frames

```json theme={null}
{"type":"latency","seq":1,"source_language":"es","partials":4,"stt_ms":120,"llm_first_token_ms":210,"first_audio_from_start_ms":2700,"first_audio_ms":-300,"total_ms":900}
```

| Field | Meaning |
| :- | :- |
| `partials` | Recognition requests made while the phrase was spoken. |
| `stt_ms` | End of speech to the final transcript. |
| `llm_first_token_ms` | First text handed to translation to the first translated token. |
| `first_audio_from_start_ms` | First audio, measured from the start of the speech. |
| `first_audio_ms` | First audio, measured from the end of the speech. Negative when audio started before the speaker finished. |
| `total_ms` | End of speech to the last audio. |

Fields are `null` when not measured (no audio). The values above only show the shape.

One production test (2026-10-27) measured the first translated audio about 2.7 s after the speaker starts, and the median about 0.25 s after a sentence ends. That is one test, not a guarantee: read the `latency` frames of your own sessions.

## Errors

| `code` | When |
| :- | :- |
| `validation.invalid_request_format` | First message is not a config; unsupported `input_rate` or `output_rate`; `tone` too long; unknown message type. Closes if during config. |
| `validation.invalid_language` | Missing or unknown `target_language`, unknown `source_language`, or both equal. |
| `validation.invalid_voice` | Unknown voice, or a cloned voice that is not yours. |
| `auth.rate_limited` | Too many concurrent sessions for your account. |
| `agent.provider_error` | Recognition or translation failed for one phrase (the session continues). |
| `tts.synthesis_failed` | Synthesis failed or is unavailable for one phrase (the session continues). |

Two refusals come before the WebSocket opens, as plain HTTP responses on the handshake:

| Status | When |
| :- | :- |
| `402` | Your balance cannot start a session. [Top up](https://docs.lokutor.com/pricing.md). |
| `503` | `live translation is at capacity, try again in a moment`: see below. Retry shortly. |

## Concurrency and capacity

Your sessions count against your account's concurrent-session limit, the same pool as text-to-speech sockets (`auth.rate_limited` past it).

Separately, the number of translation sessions in flight is capped across all customers, because each session asks the shared recognition service for about 1.5 requests per second of speech and a few sessions would slow every voice agent. When the cap is reached, new connections get the `503` above until a session ends. Treat it as retryable, with a short backoff. If you need guaranteed capacity for an event, contact us in advance.

## Pricing

**\$0.08 a minute of speech, all-in** (before tax; `translation_per_minute` on the [rate card](https://docs.lokutor.com/api-reference/rate-card.md), `GET /v1/pricing`), charged per second of session time from the moment the socket is accepted until it closes. The price includes recognition, the translation model and the synthesized voice: no tokens, characters or speech-to-text seconds are charged on top, and the session is not billed as a text-to-speech socket. It is debited from your prepaid wallet like the other usage lines and appears as a `translation` line in your usage. A session left open with nobody talking is still a session: send `end` or close the socket when you are done.

## Example: translate a WAV file

Streams a 16-bit mono 16 kHz WAV file at real-time pace and saves the translated speech as a WAV file.

```javascript JavaScript (Node 22, ws) theme={null}
import WebSocket from 'ws'
import fs from 'node:fs'

const KEY = process.env.LOKUTOR_API_KEY
const wav = fs.readFileSync('speech_es_16k.wav')
const pcm = wav.subarray(44) // assumes a plain 44-byte header, PCM16 mono 16 kHz
const OUT_RATE = 44100

const ws = new WebSocket('wss://api.lokutor.com/v1/ws/translate', { headers: { 'X-API-Key': KEY } })
const audio = []

ws.on('open', () => ws.send(JSON.stringify({
  type: 'config', source_language: 'es', target_language: 'en',
  voice: 'en_f1', input_rate: 16000, output_rate: OUT_RATE,
})))

ws.on('message', async (data, isBinary) => {
  if (isBinary) { audio.push(data); return }
  const m = JSON.parse(data.toString())
  if (m.type === 'ready') {
    const frame = 16000 * 2 * 0.02 // 20 ms of PCM16
    for (let i = 0; i < pcm.length; i += frame) {
      ws.send(pcm.subarray(i, i + frame))
      await new Promise(r => setTimeout(r, 20)) // real-time pace
    }
    ws.send(JSON.stringify({ type: 'end' }))
  } else if (m.type === 'transcript' && m.final) console.log('heard:', m.text)
  else if (m.type === 'translation' && m.final) console.log('said :', m.text)
  else if (m.type === 'error') console.error(m.data)
  else if (m.type === 'end') {
    const body = Buffer.concat(audio)
    const h = Buffer.alloc(44)
    h.write('RIFF', 0); h.writeUInt32LE(36 + body.length, 4); h.write('WAVEfmt ', 8)
    h.writeUInt32LE(16, 16); h.writeUInt16LE(1, 20); h.writeUInt16LE(1, 22)
    h.writeUInt32LE(OUT_RATE, 24); h.writeUInt32LE(OUT_RATE * 2, 28)
    h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34); h.write('data', 36); h.writeUInt32LE(body.length, 40)
    fs.writeFileSync('translated.wav', Buffer.concat([h, body]))
    ws.close()
  }
})
```

```python Python (websockets) theme={null}
import asyncio, json, os, wave
import websockets

KEY = os.environ["LOKUTOR_API_KEY"]
OUT_RATE = 44100

async def main():
    with wave.open("speech_es_16k.wav", "rb") as w:   # PCM16 mono 16 kHz
        pcm = w.readframes(w.getnframes())
    frame = 16000 * 2 * 20 // 1000                    # 20 ms of PCM16
    audio = bytearray()

    # websockets >= 14 uses additional_headers; older versions use extra_headers
    async with websockets.connect("wss://api.lokutor.com/v1/ws/translate",
                                  additional_headers={"X-API-Key": KEY}) as ws:
        await ws.send(json.dumps({"type": "config", "source_language": "es", "target_language": "en",
                                  "voice": "en_f1", "input_rate": 16000, "output_rate": OUT_RATE}))
        assert json.loads(await ws.recv())["type"] == "ready"

        async def send_file():
            for i in range(0, len(pcm), frame):
                await ws.send(pcm[i:i + frame])
                await asyncio.sleep(0.02)             # real-time pace
            await ws.send(json.dumps({"type": "end"}))

        sender = asyncio.create_task(send_file())
        async for msg in ws:
            if isinstance(msg, bytes):
                audio += msg
                continue
            m = json.loads(msg)
            if m["type"] == "transcript" and m["final"]:
                print("heard:", m["text"])
            elif m["type"] == "translation" and m["final"]:
                print("said :", m["text"])
            elif m["type"] == "error":
                print("error:", m["data"])
            elif m["type"] == "end":
                break
        await sender

    with wave.open("translated.wav", "wb") as out:
        out.setnchannels(1); out.setsampwidth(2); out.setframerate(OUT_RATE)
        out.writeframes(bytes(audio))

asyncio.run(main())
```


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