Speech-to-Text Streaming English
Low-latency English speech-to-text over WebSocket. Emits rolling partial transcripts during streaming, with optional utterance segmentation at pauses (endpointing) and optional speaker labels (diarize). No other enrichments.
diarize=true, each final utterance also carries a speaker label. No emotion detection, accent detection, or PII/PHI tagging.
Endpoint
wss://platform.modulate.ai/api/velma-2-stt-streaming-english-v2
Authentication
Pass your API key as a query parameter when opening the connection.wss://platform.modulate.ai/api/velma-2-stt-streaming-english-v2?api_key=YOUR_API_KEY&audio_format=ogg
X-API-Key header. The key must be in the query string at connection time.Supported audio formats
Container formats —sample_rate and num_channels are ignored if supplied:
mp3, wav, flac, m4a, mp4, ogg, opus, webm, aac, aiff, wma, amr, and au, plus 66 others
All 79 accepted container values
All 79 accepted container values
3g2, 3ga, 3gp, 3gpp, 8svx, aa3, aac, ac3, act, adts, aif, aifc, aiff,
amb, amr, asf, at3, au, avr, awb, bwf, c2, caf, dss, dts, dtshd,
eac3, ec3, f4a, f4b, flac, gsm, iff, m2a, m2ts, m4a, m4b, m4r, m4v,
mka, mkv, mlp, mp+, mp1, mp2, mp3, mp4, mpa, mpc, mpga, mpp, mts,
oga, ogg, ogx, oma, omg, opus, paf, pvf, qcp, ra, rf64, rm, rmvb,
snd, svx, thd, ts, tta, voc, vqf, w64, wav, wave, weba, webm, wma,
wmvmp4, m4a, m4b, m4r, m4v, 3gp, 3gpp, 3ga, 3g2, f4a, f4b) must be sent in a streamable layout; otherwise the connection ends with an audio-processing error.
Opus audio is accepted either as opus or, for Opus-in-Ogg streams, as ogg.
Raw PCM formats — sample_rate and num_channels are required:
s8, s16le, s16be, s24le, s24be, s32le, s32be, u8, u16le, u16be, u24le, u24be, u32le, u32be, f32le, f32be, f64le, f64be, mulaw, alaw, g722, vox
g722 and vox are mono-only: num_channels must be 1.
Valid sample rates: 8000, 11025, 16000, 22050, 32000, 44100, 48000, 96000
For lowest end-to-end latency, send audio_format=s16le&sample_rate=16000&num_channels=1. This matches the model’s native input format and bypasses the server’s audio decoder entirely.
Query parameters
| Parameter | Required | Description |
|---|---|---|
api_key | Yes | Your API key |
audio_format | Yes | Container or raw PCM format of the audio you will stream. See supported formats above. |
sample_rate | Raw PCM only | Source sample rate in Hz. Required when audio_format is a raw PCM type; ignored for container formats. |
num_channels | Raw PCM only | Number of audio channels (1–8). Required when audio_format is a raw PCM type; ignored for container formats. The server downmixes to mono internally. |
endpointing | No | true or false (default false). When true, speech is segmented into utterances at pauses — see Utterance segmentation. Any other value is rejected with close code 1003. |
diarize | No | true or false (default false). When true, each final utterance carries speaker fields — see Speaker labels. Any other value is rejected with close code 1003. |
diarize and endpointing are independent; all four combinations are valid.
This endpoint accepts no other enrichment toggles. Emotion, accent, and PII parameters are not recognized and have no effect. Use STT Streaming if you need those.
Utterance segmentation (endpointing)
By default, the connection produces one finalutterance at end-of-stream covering everything you sent, and each partial_utterance reflects the whole stream so far.
With endpointing=true, speech is segmented at pauses:
- Each
partial_utterancecontains the complete transcript of the current segment so far (still replace, never append). - Each time a pause ends a segment, you immediately receive a final
utterancefor it — final text arrives shortly after the speaker stops, instead of at end-of-stream. Each final carriesstart_msandduration_msfor its speech. - The full transcript is the concatenation of the final
utterancetexts, in order. - Every connection still ends with at least one final
utterance(a stream containing no speech yields one with emptytext), followed bydone.
Speaker labels
Setdiarize=true to label which speaker produced each utterance. Labels are attached to final utterance messages only, never to partial_utterance.
speaker is a zero-based index, stable for the lifetime of one connection and meaningless outside it. The same person on a new connection may receive a different index, so the value identifies a speaker within a conversation and is not an identity that can be matched across connections or sessions.
A label is assigned when an utterance is finalized and is never revised. There is no back-patching, so an early utterance keeps whatever label it was given.
speaker is null when diarization was requested but no speaker could be determined for that utterance, which happens on very short or silent segments and on a brand-new voice that has not yet accumulated enough evidence. That is distinct from the field being absent, which means diarize=true was not requested.
Speaker capacity
Up to ten distinct speakers are labeled per connection. Past that, further speakers are merged into existing labels rather than reported separately: their speech is still transcribed, butspeaker is wrong for some utterances.
speakers_capped is the only signal that this happened. Nothing in the transcript or in speaker itself indicates a merged speaker, so a client acting on speaker identity should check speakers_capped alongside n_speakers.Utterances that span a speaker change
Segments are cut at pauses regardless ofdiarize. An utterance that spans a fast exchange with no pause between turns carries the speaker who produced most of it, and speaker_purity reports what proportion that was. 1.0 means the utterance was cleanly single-speaker; a lower value means the single label describes only the majority of it.
With endpointing off
diarize=true&endpointing=false produces one final utterance for the whole stream, so a single speaker cannot describe it. Read n_speakers in that mode and treat speaker as indicative only.
Connection flow
- Connect to the WebSocket endpoint with
api_key,audio_format, (for raw PCM)sample_rateandnum_channels, and optionallyendpointinganddiarize. - Stream audio as binary WebSocket frames. Frames can be any size; 4–64 KB is typical.
- Receive
partial_utteranceJSON messages every ~1.5 seconds. Each contains the complete transcript of its scope so far — replace any previously displayed partial, do not append. - With
endpointing=true, receive a finalutterancemessage each time a pause ends a segment. - Send an empty text frame (
"") to signal end of audio. - Receive the final
utterancefor any content not already finalized at a pause (the whole stream with endpointing off; skipped when the last segment was already finalized mid-stream). - Receive a
donemessage with total audio duration. - The connection closes automatically.
Server messages
partial_utterance
Sent roughly every 1.5 seconds while audio is streaming. Each message contains the complete transcript built so far for the current scope — the whole connection by default, or the current speech segment when endpointing=true. It is not a delta from the previous message: replace your displayed partial text with each new value — never append.
{
"type": "partial_utterance",
"partial_utterance": {
"text": "Hello, how are you",
"is_final": false
}
}
| Field | Type | Description |
|---|---|---|
type | string | Always "partial_utterance" |
partial_utterance.text | string | Complete transcript so far. Replace, don’t append. |
partial_utterance.is_final | boolean | Always false |
diarize=true. Speaker fields appear on final utterance messages only.
utterance
The final transcript for one utterance. Will not be revised. With endpointing off, sent exactly once at end-of-stream, covering the entire audio stream. With endpointing=true, sent each time a pause ends a segment; the full transcript is the concatenation of every utterance text in order.
{
"type": "utterance",
"utterance": {
"text": "Hello, how are you doing today?",
"is_final": true,
"start_ms": 0,
"duration_ms": 2360
}
}
diarize=true, the same message carries four additional fields:
{
"type": "utterance",
"utterance": {
"text": "Good morning, thanks for calling.",
"is_final": true,
"start_ms": 8120,
"duration_ms": 4440,
"speaker": 0,
"n_speakers": 2,
"speakers_capped": false,
"speaker_purity": 1.0
}
}
| Field | Type | Description |
|---|---|---|
type | string | Always "utterance" |
utterance.text | string | Final transcript for this utterance. Will not be revised. |
utterance.is_final | boolean | Always true |
utterance.start_ms | integer | Start of this utterance’s speech, in milliseconds from the beginning of the stream. Approximate. 0 with endpointing off. |
utterance.duration_ms | integer | Duration of this utterance’s speech in milliseconds, excluding the pause that ended it. Approximate. With endpointing off, covers the whole stream including any leading and trailing silence. |
utterance.speaker | integer | null | Present only with diarize=true. Zero-based index of the speaker who produced this utterance. null when no speaker could be determined. |
utterance.n_speakers | integer | Present only with diarize=true. Distinct speakers detected on the connection so far. |
utterance.speakers_capped | boolean | Present only with diarize=true. true once every speaker slot is in use. |
utterance.speaker_purity | number | Present only with diarize=true. Proportion of this utterance’s speech attributed to speaker, from 0 to 1. |
diarize=false no speaker field appears in any message, and the payloads are byte-identical to a non-diarized connection.
done
Sent immediately after the final utterance. Signals stream completion. The connection closes shortly after.
{
"type": "done",
"duration_ms": 14253
}
| Field | Type | Description |
|---|---|---|
type | string | Always "done" |
duration_ms | integer | Total audio duration in milliseconds |
error
Sent if something goes wrong. The connection closes after this message. No further messages follow an error.
{
"type": "error",
"error": "Invalid audio_format='xyz'."
}
| Field | Type | Description |
|---|---|---|
type | string | Always "error" |
error | string | Human-readable description of the error |
1003:
{ "type": "error", "error": "Invalid diarize='yes'. Expected 'true' or 'false'." }
{ "type": "error", "error": "diarize=true is not available on this endpoint." }
diarize.
WebSocket close codes
| Code | Meaning |
|---|---|
1000 | Normal closure after the done message |
1003 | Invalid query parameters — missing audio_format, invalid sample_rate, invalid endpointing, invalid diarize, unsupported value, etc. Also returned when diarize=true is requested but speaker labelling is not enabled on the serving instance. |
1011 | Internal server error. An error message is sent before the close. |
1013 | The service is temporarily at capacity. Not an account or billing condition — retry the connection. Connections requesting diarize=true have a lower concurrency ceiling than plain transcription, so a diarized connection can be refused while a plain one succeeds. |
4001 | The api_key query parameter is missing or invalid |
4002 | Audio bytes did not match the declared raw PCM format, or the audio could not be decoded mid-stream |
4003 | The request is not permitted |
4004 | The API key does not have access to this model |
4029 | Insufficient credits |
4030 | Concurrent request limit reached |
4031 | Monthly usage limit reached |
Rate limits
- Concurrent connection limits apply per model; exceeding them rejects the handshake with close code
4030.
Examples
- Python (websockets)
- JavaScript (Node.js)
import asyncio
import json
import os
import websockets
API_KEY = os.environ["MODULATE_API_KEY"]
AUDIO_FILE = "recording.ogg"
CHUNK_SIZE = 8192
async def transcribe():
url = (
f"wss://platform.modulate.ai/api/velma-2-stt-streaming-english-v2"
f"?api_key={API_KEY}&audio_format=ogg"
)
async with websockets.connect(url, max_size=None) as ws:
async def send_audio():
with open(AUDIO_FILE, "rb") as f:
while chunk := f.read(CHUNK_SIZE):
await ws.send(chunk)
await ws.send("") # signal end-of-stream
send_task = asyncio.create_task(send_audio())
try:
async for msg in ws:
data = json.loads(msg)
if data["type"] == "partial_utterance":
# Replace any previously displayed partial — not a delta
print(f"\r[partial] {data['partial_utterance']['text']}", end="", flush=True)
elif data["type"] == "utterance":
u = data["utterance"]
# "speaker" is present only with diarize=true; None means undetermined
who = f" speaker {u['speaker']}" if u.get("speaker") is not None else ""
print(f"\n[final{who}] {u['text']}")
elif data["type"] == "done":
print(f"\nDone. Duration: {data['duration_ms']}ms")
break
elif data["type"] == "error":
print(f"\nError: {data['error']}")
break
finally:
if not send_task.done():
send_task.cancel()
asyncio.run(transcribe())
const WebSocket = require("ws");
const fs = require("fs");
const API_KEY = process.env.MODULATE_API_KEY;
const AUDIO_FILE = "recording.ogg";
const CHUNK_SIZE = 8192;
const url = new URL("wss://platform.modulate.ai/api/velma-2-stt-streaming-english-v2");
url.searchParams.set("api_key", API_KEY);
url.searchParams.set("audio_format", "ogg");
const ws = new WebSocket(url.toString());
ws.on("open", () => {
const stream = fs.createReadStream(AUDIO_FILE, { highWaterMark: CHUNK_SIZE });
stream.on("data", (chunk) => ws.send(chunk));
stream.on("end", () => ws.send("")); // signal end-of-stream
});
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === "partial_utterance") {
// Each partial is the full transcript so far — replace, don't append
process.stdout.write(`\r[partial] ${msg.partial_utterance.text}`);
} else if (msg.type === "utterance") {
// "speaker" is present only with diarize=true; null means undetermined
const who = msg.utterance.speaker != null ? ` speaker ${msg.utterance.speaker}` : "";
console.log(`\n[final${who}] ${msg.utterance.text}`);
} else if (msg.type === "done") {
console.log(`\nDone. Duration: ${msg.duration_ms}ms`);
ws.close();
} else if (msg.type === "error") {
console.error("\nError:", msg.error);
ws.close();
}
});
ws.on("error", (err) => console.error("WebSocket error:", err.message));
ws.on("close", (code) => console.log(`Connection closed: ${code}`));
&endpointing=true to the URL and the same message loop prints one [final] line per pause instead of a single one at the end; add &diarize=true as well and each of those lines is prefixed with its speaker label.websocat.Related
- Which API should I use? — when Streaming v2 is the right choice vs STT Streaming or batch
- STT Streaming — multilingual streaming with speaker diarization and enrichments
- Authentication and rate limits
{
"type": "partial_utterance",
"partial_utterance": {
"text": "Hello, how are you",
"is_final": false
}
}{
"type": "utterance",
"utterance": {
"text": "Hello, how are you doing today?",
"is_final": true,
"start_ms": 0,
"duration_ms": 2360
}
}{
"type": "error",
"error": "Internal server error"
}{
"type": "done",
"duration_ms": 14253
}{}""In-progress transcript snapshot for the CURRENT speech segment. Sent
zero or more times per segment, roughly every 1.5 seconds. The text
field contains the current segment's transcript so far - not a delta,
and not the whole stream - with auto-capitalization and
auto-punctuation already applied. Clients should REPLACE the current
segment's previously displayed partial text, not append. Partials
never carry speaker labels, even with diarize=true.
The final transcript for one segment of speech, with
auto-capitalization and auto-punctuation applied, plus the segment's
start time and duration. With diarize=true it also carries the
speaker who produced it. Sent once per segment (when the segment ends
at a pause, and once more for the last segment after end-of-stream),
and supersedes the partial_utterance messages for that segment. The
full transcript is the concatenation of these texts in order.
An error occurred during transcription. Sent at most once per
connection; the WebSocket closes shortly after. No further
messages follow an error.
Transcription completed successfully. Sent exactly once, immediately
after the final utterance message. After this message the server
closes the WebSocket.
Raw audio data chunk for transcription. Sent zero or more times
by the client during the stream lifetime. Each chunk's bytes are
decoded by the server according to the connection's
audio_format query parameter.
Empty text frame signaling end of audio. Sent exactly once by the
client after the last audio chunk. The server responds with a
final utterance message followed by a done message, then
closes the connection.
{
"type": "partial_utterance",
"partial_utterance": {
"text": "Hello, how are you",
"is_final": false
}
}{
"type": "utterance",
"utterance": {
"text": "Hello, how are you doing today?",
"is_final": true,
"start_ms": 0,
"duration_ms": 2360
}
}{
"type": "error",
"error": "Internal server error"
}{
"type": "done",
"duration_ms": 14253
}{}""