Realtime Speech to Text
Transcribe live audio over WebSocket: finals and interim results, word timestamps and confidence, raw phone-grade audio, per-session billing
The realtime endpoint streams transcription over WebSocket instead of waiting for a complete file. Send audio frames as they arrive, get text back while the speaker is still talking, and flush the last few words on demand. It serves reson8/reson8-transcribe, which runs the Reson8 speech engine on our infrastructure, and is the streaming counterpart of the Speech to Text models.
Typical uses: voice agents, live captions, phone-line transcription. The endpoint accepts raw mu-law and A-law audio, so 8 kHz phone audio needs no conversion.
Prerequisites
- A Nebul API key, exported as
NEBUL_API_KEY. The Quick Start covers key setup. - A WebSocket client. The examples use Python's
websockets(pip install websockets) and Node'sws(npm install ws).
Connect
wss://api.inference.nebul.io/v1/speech-to-text/realtime?model=reson8/reson8-transcribeThe model query parameter is required: the gateway routes and authorizes on it. Authenticate with your platform key on the handshake:
| Header | Value |
|---|---|
Authorization | Bearer $NEBUL_API_KEY |
X-API-Key | your key, as an alternative to Authorization |
A successful handshake returns HTTP 101 and the session is open. A missing or invalid key is rejected before the session starts.
Query parameters
Set tuning parameters on the same handshake URL. They are forwarded to the speech engine; anything outside this list is dropped.
| Parameter | Type | Default | Description |
|---|---|---|---|
encoding | String | auto | auto detects container formats, pcm_s16le is raw PCM, mulaw / alaw are raw G.711 telephony audio. |
sample_rate | Number | 16000 | Sample rate in Hz. Only applies to raw encodings. |
channels | Number | 1 | Channel count, 1 to 10. Only applies to raw encodings. |
language | String | ISO 639-1 code, e.g. nl. A comma-separated list (e.g. nl,en) constrains per-utterance auto-detection. Set it for the best quality. | |
include_interim | Boolean | false | Also return interim (partial) results, with an is_final flag on each transcript. |
include_words | Boolean | false | Word-level detail on transcripts. |
include_confidence | Boolean | false | A confidence value per word. |
include_language | Boolean | false | The detected language per transcript (empty on interims). |
filler_mode | String | natural | Filler words in finals: clean removes them, natural decides per case, verbatim keeps them. |
diarize | Boolean | false | Speaker labels: adds a speaker_id (0 to 7) per transcript. |
max_speakers | Number | Cap on distinct speakers, 1 to 8. Only with diarize=true. |
Timestamps (start_ms, duration_ms) are always included on transcripts and can't be turned off: the endpoint bills on them.
Biasing is not available
Reson8's biasing parameters (phrases, patterns, custom_model_id, bias_strength) are accepted in shape but don't change results on Nebul's deployment yet. Don't build on them.
Send audio
Send audio as binary WebSocket messages. Keep chunks around one second; the engine accepts messages up to 64 KiB per message and transcribes about five to six times faster than real time. If you feed faster than the engine can process, the surplus is dropped (and not billed).
Send this message when the speaker finishes, or when you need the buffer finalized:
{ "type": "flush_request", "id": "turn-3" }The optional id comes back on the matching confirmation, so you can pair requests with answers.
Receive results
Each text result is a JSON message:
{ "type": "transcript", "text": "can I book for nine o'clock", "start_ms": 1200, "duration_ms": 2400, "is_final": true }| Field | Type | Description |
|---|---|---|
text | String | The recognized text. |
is_final | Boolean | Present with include_interim=true: true for a final, false for an interim that may still change. |
start_ms, duration_ms | Number | Position of this transcript in the session's audio, in milliseconds. |
speaker_id | Number | With diarize=true: the dominant speaker, 0 to 7. |
language | String | With include_language=true: the detected language code. |
words | Array | With include_words=true: per-word text, start_ms, duration_ms, and confidence (with include_confidence=true). |
When include_interim=true, you get an interim shortly after speech starts and roughly one per second after. Interims grow as a prefix; a word heard wrong early can be corrected in a later interim or the final.
After a flush, you get a final transcript followed by the confirmation echoing your id:
{ "type": "flush_confirmation", "id": "turn-3" }The endpoint does not detect turn ends on its own. Audio can stop and stay silent without a final arriving, so a voice agent should flush on its own end-of-turn signal. For server-side detection, see Turn Detection.
Example
Streams a file in one-second chunks, flushes, and prints every final transcript:
import asyncio
import json
import os
import websockets
URL = "wss://api.inference.nebul.io/v1/speech-to-text/realtime"
PARAMS = "model=reson8/reson8-transcribe&language=nl&include_interim=true"
async def main():
headers = {"Authorization": f"Bearer {os.environ['NEBUL_API_KEY']}"}
async with websockets.connect(f"{URL}?{PARAMS}", additional_headers=headers) as ws:
async def send_audio():
with open("recording.wav", "rb") as f:
while chunk := f.read(32000):
await ws.send(chunk)
await asyncio.sleep(1) # approximate real time
await ws.send(json.dumps({"type": "flush_request", "id": "final"}))
sender = asyncio.create_task(send_audio())
async for message in ws:
event = json.loads(message)
if event["type"] == "transcript" and event.get("is_final", True):
print("final:", event["text"])
if event["type"] == "flush_confirmation":
break
await sender
asyncio.run(main())For a live microphone, stream chunks as they arrive instead of reading from a file, and keep the socket open for the whole call. One session handles a long call; open a new one per speaker turn only if you want isolated timing.
Billing
Usage is metered per session as a running total of transcribed audio: one input token per millisecond of audio with recognized speech. At the list price of EUR 0.10 per 1M input tokens, an hour of billable audio costs EUR 0.36. Silence and dropped surplus audio produce no transcript timing, and nothing without timing is billed. The Model Catalog lists current pricing.
Realtime and HTTP differ
The realtime endpoint and the HTTP transcriptions endpoint use different engine components: a streaming decoder that finalizes with limited lookahead, and a batch decoder that sees the whole file. Expect small wording differences between the two on the same audio. If you need byte-identical reruns of the same input, use HTTP.
Errors and close codes
A rejected handshake returns HTTP 400 (unknown model or invalid parameter), 401 (missing or invalid key), or 429 (usage limit), with the reason in X-Error-Message.
After the handshake, problems arrive as a close code plus a machine-readable reason. Branch on the code for coarse handling and on the reason for specifics:
| Close code | Reason | Meaning |
|---|---|---|
| 1000 | Normal closure. | |
| 1011 | internal_error | Server failure. Reconnect and resend audio the engine has not confirmed. |
| 1011 | internal_timeout | An internal service did not recover within its reconnect budget. Reconnect; back off if it recurs. |
| 4000 | message_backlog | You did not read messages for 10 seconds. Consume promptly, then reconnect. |
| 1013 | try again later | The speech engine is temporarily unavailable. Safe to retry. |
A connection that drops without a close frame (code 1006 in browsers) is a network failure. Treat it like 1011: reconnect and resend unconfirmed audio.
For prerecorded files, batch formats, and subtitle output, see Speech to Text.