Turn Detection (Turns)
Server-side turn detection over WebSocket: turn boundaries, eager and final turn candidates, and complete per-turn transcripts for voice agents
The turns endpoint adds server-side turn detection to realtime transcription. Instead of deciding yourself when a speaker finished, the engine watches the audio and emits turn events: a turn_start when speech begins, turn_end_candidate messages as a turn-end becomes likely, and a turn_end that confirms the final transcript. It serves reson8/reson8-transcribe, which runs the Reson8 speech engine on our infrastructure, like Realtime Speech to Text, and replaces the "keep your own end-of-turn detector" part of a voice agent.
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/turns?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 |
Query parameters
| 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. | |
include_words | Boolean | false | Word-level detail on turn candidates. |
include_confidence | Boolean | false | A confidence value per word. |
include_language | Boolean | false | The detected language per candidate. |
eager_turn_probability | Number | 0.5 | Emit an interim candidate when turn-end probability passes this. Between 0 and 1. |
final_turn_probability | Number | 0.92 | Emit the final candidate and turn_end when turn-end probability passes this. Between 0 and 1. |
filler_mode | String | natural | Filler words in transcripts: clean removes them, natural decides per case, verbatim keeps them. |
The realtime-only knobs (include_interim, diarize, max_speakers) don't exist on turns: the turn events replace them.
Timestamps (start_ms, duration_ms) are always included on candidates; the endpoint bills on them, so the flag is fixed.
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, around one second per chunk. If you feed faster than the engine can process, the surplus is dropped (and not billed).
You rarely need to flush: the engine decides turn ends itself. When you want the active turn finished immediately (for example, the caller pressed a key), send:
{ "type": "flush_request" }If a turn is active, you get a final turn_end_candidate followed by turn_end.
Receive turn events
The session alternates three events:
{ "type": "turn_start" }
{ "type": "turn_end_candidate", "text": "can I book for nine o'clock", "start_ms": 1200, "duration_ms": 2400 }
{ "type": "turn_end" }| Event | When | Fields |
|---|---|---|
turn_start | The server detected the beginning of a turn. | None. |
turn_end_candidate | Turn-end probability passed eager_turn_probability (interim) or final_turn_probability (final). | text; start_ms, duration_ms; words and language on request. |
turn_end | The turn is confirmed. Confirms the last candidate as final. | None. |
Each new candidate replaces the previous one, and the final candidate carries the complete turn transcript. So the useful pattern is: act on eager candidates right away (they arrive while the speaker may still continue), and treat the candidate that arrives before turn_end as the settled text.
| Field | Type | Description |
|---|---|---|
text | String | The recognized turn text so far. |
start_ms, duration_ms | Number | Position and length of the turn in the session's audio, in milliseconds. |
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). |
Example
Streams a file and prints each turn as it settles:
import asyncio
import json
import os
import websockets
URL = "wss://api.inference.nebul.io/v1/speech-to-text/turns"
PARAMS = "model=reson8/reson8-transcribe&language=nl"
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"}))
sender = asyncio.create_task(send_audio())
try:
# A flush only produces a turn_end while a turn is active: a file
# ending in silence gets nothing back, so cap the wait.
async with asyncio.timeout(60): # scale to your audio length
async for message in ws:
event = json.loads(message)
if event["type"] == "turn_start":
print("-- turn start")
elif event["type"] == "turn_end_candidate":
print("candidate:", event["text"])
elif event["type"] == "turn_end":
print("-- turn end")
if sender.done():
break
except TimeoutError:
pass # no active turn to flush
await sender # the sender always finishes; the flush needs no reply
asyncio.run(main())One session handles a whole call; turns are detected and delimited within it.
Billing
Usage is metered per session as a running total of transcribed audio, read from the timing on turn_end_candidate messages: 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. A turn that produced audio but no timed candidate bills nothing. The Model Catalog lists current pricing.
Turns and realtime differ
The turns endpoint runs a third engine component: turn boundaries and candidate probabilities come from the turn-detection engine, while the transcript text itself is finalized like realtime. Expect small wording differences against both the HTTP endpoint and the realtime endpoint on the same audio.
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.
| Close code | Reason | Meaning |
|---|---|---|
| 1000 | Normal closure. | |
| 1011 | internal_error | Server failure. Reconnect and resend audio from the last turn_end you received. |
| 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 streaming without turn detection, see Realtime Speech to Text. For prerecorded files, see Speech to Text.