Realtime transcription

Stream audio and receive partial and final transcripts over WebSocket.
View as Markdown

Send audio as it becomes available and receive partial transcripts while the speaker talks. At the end of each utterance, receive a final transcript. One connection can transcribe multiple utterances.

For a runnable example, follow the STT quickstart. Free STT endpoints have endpoint-specific concurrent session and daily request limits.

Connect and configure

Connect from your backend using your API key:

wss://api.narilabs.com/v1/realtime?intent=transcription
Authorization: Bearer YOUR_NARI_API_KEY

Wait for session.created, then send your session configuration:

1{
2 "type": "session.configure",
3 "session": {
4 "model": "qwen3-asr:free"
5 }
6}

Wait for session.configured before sending audio. It returns the applied settings and defaults in session, with duration and idle limits in session.limits. The configuration is fixed for this connection.

Session arguments

These fields belong inside session in the initial session.configure:

ArgumentRequired / defaultPurpose
modelRequiredA public STT model ID
languageOptional; omitted or null means automatic recognitionLanguage hint such as en, es, or ko
promptOptional; empty by defaultNames or terminology to help recognition, applied to every utterance
turn_detectionOptional; omitted or null means manual commitSet {"type":"server_vad"} for automatic turn detection

The API reference lists all accepted language codes, VAD fields, and validation limits.

Send audio

Use 16 kHz, signed 16-bit little-endian PCM, mono, without a WAV header. Encode each chunk as base64 and send it in an input_audio_buffer.append JSON message:

1await socket.send(json.dumps({
2 "type": "input_audio_buffer.append",
3 "audio": base64.b64encode(pcm_chunk).decode("ascii"),
4}))

For example, 100 ms of audio is 3,200 bytes before base64 encoding. Each chunk must contain whole two-byte samples, and the complete JSON message must fit within 128 KiB.

Send and receive in Python

This complete example sends audio and reads events concurrently. It keeps receiving after each final result, so automatic boundaries and multiple utterances work on the same connection. At the end of the audio source, it commits remaining audio and waits for all acknowledged utterances to finish.

Use Python 3.11+, set NARI_API_KEY, and place the quickstart sample hello.pcm beside stt_stream.py.

$python -m pip install "websockets>=14,<16"
Download stt_stream.py
1"""WebSocket transcription example. Requires Python 3.11+ and websockets 14–15."""
2import asyncio
3import base64
4import json
5import os
6
7from websockets.asyncio.client import connect
8
9URL = "wss://api.narilabs.com/v1/realtime?intent=transcription"
10
11
12async def pcm_file(path):
13 with open(path, "rb") as audio:
14 while chunk := audio.read(3200): # 100 ms at 16 kHz PCM16 mono
15 yield chunk
16 await asyncio.sleep(len(chunk) / 32000)
17
18
19async def transcribe(audio_source, *, turn_detection=None, on_event=print):
20 """Source yields PCM bytes, or None to manually commit an utterance."""
21 async with connect(
22 URL,
23 additional_headers={"Authorization": f"Bearer {os.environ['NARI_API_KEY']}"},
24 ) as socket:
25 async def receive():
26 event = json.loads(await socket.recv())
27 if event["type"] == "error":
28 raise RuntimeError(event["error"])
29 return event
30
31 assert (await receive())["type"] == "session.created"
32 await socket.send(json.dumps({
33 "type": "session.configure",
34 "session": {"model": "qwen3-asr:free", "turn_detection": turn_detection},
35 }))
36 assert (await receive())["type"] == "session.configured"
37
38 async def send_audio():
39 commit_number = 0
40 async for chunk in audio_source:
41 if chunk is None:
42 commit_number += 1
43 event = {"type": "input_audio_buffer.commit",
44 "event_id": f"commit_{commit_number}"}
45 else:
46 if not chunk or len(chunk) % 2:
47 raise ValueError("Send nonempty chunks of whole PCM16 samples")
48 event = {"type": "input_audio_buffer.append",
49 "audio": base64.b64encode(chunk).decode("ascii")}
50 await socket.send(json.dumps(event))
51 # Flush any remaining audio, including when using VAD.
52 await socket.send(json.dumps({
53 "type": "input_audio_buffer.commit", "event_id": "end_of_input",
54 }))
55
56 async def read_results():
57 pending = set()
58 end_acknowledged = False
59 while True:
60 event = await receive()
61 kind = event["type"]
62 if kind == "input_audio_buffer.committed":
63 pending.add(event["item_id"])
64 elif kind == "transcript.completed":
65 pending.discard(event["item_id"])
66 if kind in ("input_audio_buffer.committed", "input_audio_buffer.commit_empty"):
67 if event.get("client_event_id") == "end_of_input":
68 end_acknowledged = True
69 on_event(event)
70 if end_acknowledged and not pending:
71 return
72
73 async with asyncio.TaskGroup() as tasks:
74 tasks.create_task(send_audio())
75 tasks.create_task(read_results())
76
77
78if __name__ == "__main__":
79 asyncio.run(transcribe(pcm_file("hello.pcm")))
$python stt_stream.py

pcm_file paces a recording in 100 ms chunks. For live input, pass an async iterator that yields PCM bytes as they arrive; no extra pacing is needed. Yield None when your application wants a manual commit. These are conventions of this example function; the actual WebSocket messages are append and commit.

The on_event callback receives every server event. The result-handling and VAD examples build on this module. Keep callbacks short so receiving is not blocked.

Read the results

Partial transcripts may change as more audio arrives, including words returned earlier. After an utterance ends, the server returns a completed transcript that replaces all partial text for that item_id.

A partial result:

1{
2 "type": "transcript.partial",
3 "event_id": "event_1",
4 "item_id": "utterance_1",
5 "transcript": "Hello, welcome",
6 "revision": 1
7}

The completed result for the same item_id:

1{
2 "type": "transcript.completed",
3 "event_id": "event_3",
4 "item_id": "utterance_1",
5 "transcript": "Hello, welcome to Nari Labs.",
6 "language": "en",
7 "commit_reason": "manual",
8 "usage": {
9 "input_audio_seconds": 2.48
10 }
11}
FieldUse
item_idAssociate results with an utterance
transcriptReplace the current partial text or store the completed text
languageRecognized language code, or null if undetermined
commit_reasonWhat ended the utterance: manual, vad, or max_duration
usage.input_audio_secondsInput audio duration assigned to this utterance; usage rules explain the measurement

Transcripts and turn detection explains how to update text, finalize utterances, and reuse the connection. For timeouts and reconnecting, use WebSocket failures.