Nebul Docs

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's ws (npm install ws).

Connect

wss://api.inference.nebul.io/v1/speech-to-text/turns?model=reson8/reson8-transcribe

The model query parameter is required: the gateway routes and authorizes on it. Authenticate with your platform key on the handshake:

HeaderValue
AuthorizationBearer $NEBUL_API_KEY
X-API-Keyyour key, as an alternative to Authorization

Query parameters

ParameterTypeDefaultDescription
encodingStringautoauto detects container formats, pcm_s16le is raw PCM, mulaw / alaw are raw G.711 telephony audio.
sample_rateNumber16000Sample rate in Hz. Only applies to raw encodings.
channelsNumber1Channel count, 1 to 10. Only applies to raw encodings.
languageStringISO 639-1 code, e.g. nl. A comma-separated list (e.g. nl,en) constrains per-utterance auto-detection.
include_wordsBooleanfalseWord-level detail on turn candidates.
include_confidenceBooleanfalseA confidence value per word.
include_languageBooleanfalseThe detected language per candidate.
eager_turn_probabilityNumber0.5Emit an interim candidate when turn-end probability passes this. Between 0 and 1.
final_turn_probabilityNumber0.92Emit the final candidate and turn_end when turn-end probability passes this. Between 0 and 1.
filler_modeStringnaturalFiller 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" }
EventWhenFields
turn_startThe server detected the beginning of a turn.None.
turn_end_candidateTurn-end probability passed eager_turn_probability (interim) or final_turn_probability (final).text; start_ms, duration_ms; words and language on request.
turn_endThe 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.

FieldTypeDescription
textStringThe recognized turn text so far.
start_ms, duration_msNumberPosition and length of the turn in the session's audio, in milliseconds.
languageStringWith include_language=true: the detected language code.
wordsArrayWith 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 codeReasonMeaning
1000Normal closure.
1011internal_errorServer failure. Reconnect and resend audio from the last turn_end you received.
1011internal_timeoutAn internal service did not recover within its reconnect budget. Reconnect; back off if it recurs.
4000message_backlogYou did not read messages for 10 seconds. Consume promptly, then reconnect.
1013try again laterThe 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.

On this page