Realtime Transcription
Stream microphone or telephony audio over a WebSocket and receive the transcript while the speaker is still talking.
Overview
/v1/realtime is a WebSocket surface for live speech-to-text. You open one socket, push raw audio frames into it, and read transcript events back as they are produced. Nothing is uploaded and nothing is polled.
Key characteristics:
- One socket per session -- authenticate once at connect time, then stream
- Incremental transcript -- revisable
deltaevents while a phrase is being decoded, one finalcompletedevent per segment - Server-side segmentation -- the platform decides where segments end; there is no VAD for the client to tune
- Billed on streamed audio seconds -- an open but silent socket costs nothing
- Explicit close codes -- every refusal and every server-side stop carries a code you can branch on
Realtime transcription is served only where a speech-to-text model is deployed and the realtime surface is enabled. If your model list has no speech model, or a connection closes 4404 immediately, contact your platform administrator.
Batch or realtime?
Both surfaces transcribe the same audio with the same models, and they are billed the same way -- per second of input audio. Pick on latency and on where the audio comes from.
| Use | Endpoint |
|---|---|
| A file you already have (meeting recording, voicemail, podcast) | Speech to Text -- POST /v1/audio/transcriptions |
| Text needed while the person is still speaking (live captions, agent assist, voice UI) | This guide -- wss://.../v1/realtime |
Subtitles (srt / vtt), timed segments, or translation into English | Speech to Text -- realtime does not produce them |
Any audio container (mp3, wav, m4a, webm, ...) | Speech to Text -- realtime accepts raw PCM16 only |
If you are not sure, start with the batch endpoint. It is one HTTP request, it accepts the file formats you already have, and it produces a richer result.
Connecting
wss://api.bulutistan.ai/v1/realtime?intent=transcription&model=your-speech-model
| Query parameter | Required | Description |
|---|---|---|
intent | No | transcription is the only supported value, and it is the default. Any other value closes 4400. |
model | Yes | The model identifier of a speech-to-text model. Missing or empty closes 4400. |
token | Conditional | Credential, for clients that cannot set request headers. See below. |
The /api/v1 prefix works too: wss://api.bulutistan.ai/api/v1/realtime?....
Authentication
The connection requires an API key with the inference:audio scope -- the same scope the batch audio endpoints use. Keys with full access or a general inference scope already cover it. See Authentication.
Two ways to present it:
Authorization: Bearer sk-proj-your-api-keyheader -- for servers, CLIs and any client that controls its own HTTP headers. This is the preferred form.?token=query parameter -- for browsers. The browserWebSocketAPI cannot set request headers, so the credential has no other place to go.
Both forms converge on the same checks: scope, the key's IP allowlist, and the tenant's access to the requested model. Anything that fails closes 4401.
A URL ends up in browser history, in referrer headers and in any proxy log that keeps query strings. For browser clients, mint a short-lived connection token instead (next section) and put that in ?token=. Do not ship sk-proj-... to a browser.
A portal session token cannot open the socket. It is refused by shape, before any lookup.
Browser connection tokens
POST /v1/realtime/token exchanges an authenticated portal session for a single-use credential the browser can put in the WebSocket URL.
curl -X POST https://api.bulutistan.ai/v1/realtime/token \
-H "Authorization: Bearer <portal session token>" \
-H "Content-Type: application/json" \
-d '{"model": "your-speech-model"}'
{
"token": "rtk_...",
"expires_in": 120,
"model": "your-speech-model"
}
The response is 201 and is never cached.
| Property | Value |
|---|---|
| Lifetime | 120 seconds by default -- it only has to survive one handshake |
| Uses | Exactly one. Redeeming the token destroys it. |
| Scope | inference:audio, and nothing else |
| Models | Only the model named in the request body |
| Status | Cause |
|---|---|
| 400 | model missing, empty, or not a string |
| 403 | An API key was used instead of a session. An API key can open /v1/realtime directly and needs no connection token. |
| 404 | Realtime is not enabled on this deployment |
| 503 | Connection tokens are temporarily unavailable -- retry |
Then connect with it:
wss://api.bulutistan.ai/v1/realtime?intent=transcription&model=your-speech-model&token=rtk_...
Audio Format
v1 accepts exactly one format. There is nothing to negotiate.
| Property | Value |
|---|---|
| Encoding | pcm16 -- raw signed 16-bit little-endian samples |
| Sample rate | 16000 Hz |
| Channels | 1 (mono) |
| Framing | Base64, inside an input_audio_buffer.append event |
Three rules the server enforces on every append, each closing 4400:
- No container files. Send raw samples. A payload starting with
RIFF,OggS,fLaCorID3is rejected -- that is a.wav,.ogg,.flacor.mp3file, not a stream. Strip the header (a 44-byteRIFFheader for a standard mono 16 kHz WAV) and send the sample data. - A whole number of samples. The decoded payload must have an even byte count. 16-bit samples come in pairs, and an odd length shifts every subsequent sample by one byte.
- No raw binary frames. Audio travels inside the JSON event, not as a WebSocket binary frame.
Frame size is up to you. A realtime client typically sends 20-200 ms per event. Aim for that range: it keeps latency low and stays far below the per-event cap.
Event Flow
All events are JSON text frames. Every event carries a type and an event_id.
Client to server
| Event | Purpose |
|---|---|
transcription_session.update | Set session options. Accepted only during the opening window -- see below. |
input_audio_buffer.append | One chunk of audio: {"type": "input_audio_buffer.append", "audio": "<base64 pcm16>"}. An empty payload is a no-op. |
input_audio_buffer.commit | End the current utterance and force the final transcript for it. |
Any other type closes 4400. There are only three client events.
Server to client
| Event | Meaning |
|---|---|
transcription_session.created | First event on the socket. Carries the settings now in force. |
transcription_session.updated | Acknowledges an accepted transcription_session.update, echoing the resulting session block. |
input_audio_buffer.committed | Acknowledges an input_audio_buffer.commit. Sent for every commit you send. |
conversation.item.input_audio_transcription.delta | Interim transcript for the segment being spoken. Revisable. |
conversation.item.input_audio_transcription.completed | The finalized transcript of one segment. |
conversation.item.input_audio_transcription.failed | A span of audio produced no transcript. Not terminal -- the session continues. |
error | A terminal failure. One error, then the socket closes with the matching code. |
transcription_session.created
{
"type": "transcription_session.created",
"event_id": "evt_...",
"session": {
"id": "...",
"model": "your-speech-model",
"correlation_id": "...",
"input_audio_format": "pcm16",
"input_audio_sample_rate": 16000,
"input_audio_channels": 1,
"input_audio_transcription": {
"model": "your-speech-model",
"language": "en"
},
"turn_detection": {
"type": null,
"managed_by": "server",
"configurable": false
},
"max_session_seconds": 3600,
"idle_timeout_seconds": 30,
"session_update_window_seconds": 1.0
}
}
Read your limits off this event rather than hardcoding them -- max_session_seconds, idle_timeout_seconds and session_update_window_seconds are what this session will actually be held to.
turn_detection.managed_by is "server" and configurable is false: the platform segments the audio and there is no client-tunable VAD. type is null, which means no speech-boundary events are emitted -- do not wait for input_audio_buffer.speech_started or .speech_stopped, they are never sent.
transcription_session.update
Accepted only in the opening window (1 second by default, reported as session_update_window_seconds) and only before the first input_audio_buffer.append. Later updates close 4409. The language is a connect-time parameter of the decode, so a late change could only be honoured by throwing away audio already in flight.
{
"type": "transcription_session.update",
"session": {
"input_audio_transcription": {"language": "en"}
}
}
language-- an ISO-639 code. Also accepted assession.language. If you do not set one, the model's configured default language is used.input_audio_format,input_audio_sample_rate,input_audio_channels-- optional, and only the supported values are accepted (pcm16,16000,1). Anything else closes4400.
The server answers with transcription_session.updated. If your client waits for that acknowledgement before streaming, it will get one -- including for an update with no body.
Transcript events
{
"type": "conversation.item.input_audio_transcription.delta",
"event_id": "evt_...",
"item_id": "item_...",
"content_index": 0,
"delta": " for joining",
"text": "Thanks everyone for joining the call",
"revised": false
}
Two fields, two ways to consume the stream. Pick one and stay with it:
delta-- append-only. It carries only text the decoder has stopped revising, so concatenating everydeltaof oneitem_idis always correct. This is the OpenAI-shaped field.text-- replace. The confirmed text plus the not-yet-confirmed tail: the earliest and most complete view of the segment. Overwrite your display with it on every event.
revised is true in the rare case where already-confirmed text was rewritten. When it is true, delta is empty and text holds the correction -- a client that concatenates delta will be corrected by the completed event.
{
"type": "conversation.item.input_audio_transcription.completed",
"event_id": "evt_...",
"item_id": "item_...",
"content_index": 0,
"transcript": "Thanks everyone for joining the call today.",
"audio_start_ms": 0,
"audio_end_ms": 3480
}
completed is the finalized transcript of that item_id. It is never re-sent for the same item. audio_start_ms / audio_end_ms are present when the decoder reported segment timings, and are offsets inside the current decode turn -- not absolute offsets from the start of the session.
{
"type": "conversation.item.input_audio_transcription.failed",
"event_id": "evt_...",
"item_id": "item_...",
"content_index": 0,
"error": {
"type": "transcription_incomplete",
"code": "upstream_finalize_timeout",
"message": "..."
},
"audio_start_ms": 41000,
"audio_end_ms": 47000
}
failed says a span of audio reached the decoder but produced no transcript. The session stays open. It exists so you can tell missing transcript from silence rather than assuming nobody spoke.
error
{
"type": "error",
"event_id": "evt_...",
"error": {
"type": "invalid_request",
"code": 4400,
"message": "audio must be pcm16 (16-bit samples); got an odd number of bytes"
}
}
Every error is terminal. The socket closes immediately afterwards with the code in error.code. This is stricter than some realtime APIs, which keep the session alive after a client-side mistake. There is no "fix it and carry on" -- reconnect.
Committing
input_audio_buffer.commit ends the current utterance: the decoder finalizes everything it is holding, you receive the outstanding completed events, and the next append starts a fresh utterance.
You rarely need it. Segmentation is server-side and runs continuously. Send a commit when you know an utterance ended -- a push-to-talk button released, a caller finishing a turn -- and be aware that committing mid-sentence splits the sentence, because the decoder's context does not survive the boundary.
Two commits are coalesced rather than executed: one with no new audio behind it, and one arriving less than a second after the previous commit. Both are still acknowledged with input_audio_buffer.committed, so a client that waits for the ack never hangs.
Roughly every 300 streamed audio seconds, the gateway retires the upstream decode turn and starts a fresh one. Your session, your socket and your transcript continue as normal, and no event announces it -- there is no input_audio_buffer.committed, no session event, nothing.
This bites clients built around request/response pairing. If your code sends audio, waits for a committed it did not ask for, and only then sends more, it will block forever the first time a recycle happens. input_audio_buffer.committed is sent only in response to an input_audio_buffer.commit you sent yourself; never wait for one you did not request.
The recycle is also why audio_start_ms and audio_end_ms are offsets within the current decode turn rather than absolute session offsets. If you need session-absolute timings, keep your own clock on the audio you send.
Closing the socket while audio is still being decoded discards the tail. To get the complete transcript, send input_audio_buffer.commit, wait for the last ...transcription.completed, and then close. Hanging up first is legal and costs nothing extra, but the last few words are lost.
Close Codes
Every server-side stop carries one of these. 1000 is a clean end; 1006 (abnormal closure, no code) means the socket died before a code could be delivered -- usually a network fault, so retry with backoff. A close reason is capped at 123 bytes, and a reason that will not fit is dropped by the transport along with its code, which is the one case where a 1006 is really the platform trying to tell you something. See Troubleshooting.
| Code | Meaning | What to do |
|---|---|---|
1000 | Normal close. The session ended cleanly. | Nothing. |
4400 | Invalid request: unknown intent, missing model, malformed JSON, unknown event type, an unsupported audio format/sample rate/channel count, an oversized append, invalid base64, an odd byte count, a container file, or a raw binary frame. | Fix the client. Retrying unchanged will fail identically. |
4401 | Authentication failed: no credential, a bad or expired key, a spent or expired connection token, the missing inference:audio scope, or an IP outside the key's allowlist. | Check the key and its scope. For browsers, mint a fresh connection token -- they are single-use. |
4402 | Payment required: the model has no audio price configured, or the organization is at a spend ceiling. A session can also be closed 4402 mid-stream when it crosses a ceiling while running. See below -- one code covers several causes. | Check Usage and Plans, or contact your platform administrator. |
4404 | Model not found, or no access: unknown model, a model that is not a speech-to-text deployment, a model outside the key's allowlist, or realtime not enabled on this deployment. | Verify the model identifier. Do not retry the same request. |
4408 | Session ended on the server's clock: the maximum session duration elapsed, no audio arrived for the idle timeout, or the client stopped reading events entirely. | Open a new session. If it was the idle timeout, keep streaming or close when you stop. |
4409 | Invalid state transition: transcription_session.update after the opening window, or after audio was appended. | Send the update as your first event, before any audio. |
4429 | Too many sessions: the organization's concurrent-session limit, the model at capacity, or too many connection attempts from one IP. There is no queue -- the overflow connection is closed, not parked. | Back off and retry. If it is persistent, ask your administrator to raise the limit. |
4503 | Temporarily unavailable: the model is starting, restarting or degraded, or no realtime capacity is reachable right now. | Retry with backoff. This one is transient by definition. |
1011 | Server error. Either the transcription backend failed after the session had been served, or admission itself could not complete: "authentication unavailable", "billing unavailable", "model routing unavailable". | Retry with backoff. A dependency being down is transient, and audio the platform never decoded is not billed. |
Before the final commit runs, the platform bills what was already streamed -- a 4408, 4402 or 1011 close never loses the transcript or the charge for the audio that was actually processed.
4402 covers five different ceilings
Five distinct limits all close 4402, and all five send the identical reason string, "spend limit reached":
| Ceiling | Reachable on the postpaid default? |
|---|---|
| Daily cost cap | Yes |
| Credit limit | Only where a credit limit is set |
| Prepaid wallet balance, checked when the session opens | Prepaid accounts only |
| Prepaid wallet cover for the next metering interval | Prepaid accounts only |
| Pay-as-you-go spend cap | Yes |
There is no billing_code on the wire and no retry hint, so a client cannot tell the five apart from the socket. A WebSocket close reason has 123 bytes to work with, which is not enough room for a diagnostic, and the discriminating detail is written to the server logs instead. Treat 4402 as "a human has to look at the account", not as something to branch on. Usage shows which ceiling you are against.
On the platform default -- postpaid with no credit limit -- only the daily cost cap and the pay-as-you-go cap can actually fire.
One 4402 is not a spend ceiling at all: an unpriced model closes with the reason "model has no audio pricing". That one is a configuration problem, not a limit you reached, and it will not clear on its own.
A prepaid wallet is now checked on every metering interval, not only when the session opens. Nothing is held up front: a session runs for as long as the caller keeps talking, so there is no worst case to price at the handshake. Instead each metering interval reserves its own cost against the wallet, and a prepaid organization whose balance runs out part-way through a call has the next interval refused. The session then ends with an ordinary 4402 close, cleanly and with the terminal error event below, rather than surfacing as a fault. Because the check runs once per interval, a session can stream at most one further interval after the wallet runs dry.
The batch audio endpoints and the token endpoints refuse the same shortfall over HTTP with a 402 instead of a close code -- see Error Handling. The wallet itself, and how to top it up, is on Billing.
When the ceiling is crossed mid-session the server tells you before it hangs up. It emits a terminal error event and then closes:
{
"type": "error",
"error": {
"type": "spend_limit",
"code": 4402,
"message": "spend limit reached"
}
}
Limits
| Limit | Default | Notes |
|---|---|---|
| Maximum session duration | 3600 seconds (60 minutes) | Hard ceiling. Closes 4408. Reported as max_session_seconds on the created event. |
| Idle timeout | 30 seconds | Measured on the audio clock, not on socket traffic: sending non-audio events does not keep an idle session alive. Closes 4408. |
| Concurrent sessions per organization | 2 | Configurable per organization -- ask your platform administrator. Over the limit closes 4429. |
| Concurrent sessions per serving capacity | Platform-managed | Independent of your own ceiling. When every server is full, new sessions close 4429 with "model at capacity". |
Audio per append event | ~1 MiB decoded (about 32 seconds) | Larger closes 4400. Stream 20-200 ms frames instead -- a megabyte-sized append is a file upload wearing a streaming API's clothes. |
| Connection attempts per IP | 60 per minute | Handshake attempts, counted before authentication. Over the limit closes 4429. |
| Streaming speed | Up to 10 seconds of audio ahead of realtime | Feeding audio faster than realtime does not make it transcribe faster; the platform applies backpressure to the socket. No audio is dropped. |
Everything in this table is a default. The values on the transcription_session.created event are the ones your session is actually held to.
Billing
Realtime transcription is billed in seconds of streamed audio -- never in session wall-clock time.
- Only audio the backend actually read is billed -- never the audio your client sent. Anything still queued and undelivered when the socket closes is charged to nobody.
- Idle time is free. A socket that is open and silent costs nothing. This is exactly why the idle timeout exists: held-but-silent capacity is unbillable, so it is released.
- Charged in intervals. Usage is recorded every 60 streamed audio seconds and once more at session end, so a dropped connection loses at most the interval in flight rather than the whole session.
- Rounded up once. The rounding to a whole second happens once per session at the final commit, not on every interval.
- Communicated per minute. Per-model audio rates are shown in USD per minute in the Client Portal; the measurement underneath stays per second.
- Pay as you go. Audio usage is not drawn from plan included-token allowances. See Plans and Usage.
Because the charge follows delivered audio, a client's own send-side counter can legitimately be slightly higher than the recorded usage. The difference is audio the platform never accepted, and it can only ever point in your favour.
How far past a ceiling a session can run
Spend ceilings are checked against recorded usage, and usage is recorded once per commit interval. A session can therefore stream a little past a ceiling before the 4402 close lands, and the overshoot is bounded rather than open-ended:
| Ceiling | Bounded by |
|---|---|
| Daily cost cap | One commit interval (60 streamed audio seconds) |
| Credit limit | One commit interval (60 streamed audio seconds) |
| Pay-as-you-go spend cap | Two commit intervals |
Every second inside that window is real audio the backend read and transcribed, so it is billed. If you need a hard stop rather than a bounded one, track your own spend and close the socket yourself.
Not in v1
These are deliberately not offered on the realtime surface. Where an alternative exists, it is named:
- Word-level timestamps -- segment-level offsets only (
audio_start_ms/audio_end_ms, and only when the decoder reports them). - Speaker diarization -- transcript events carry no speaker labels.
- Realtime translation -- transcription keeps the spoken language. Use
POST /v1/audio/translationson a file, see Speech to Text. - Audio generation and speech-to-speech --
intent=transcriptionis the only supported intent; anything else closes4400. - Additional audio formats -- PCM16 / 16 kHz / mono only. For
mp3,wav,m4a,webm,flacandogg, use the batch endpoint. - Subtitle output -- no
srt/vttfrom the realtime stream. Render subtitles from the batch endpoint instead.
Python Example
Uses the websockets library. This reads raw PCM16 from a file and paces it at realtime; replace the source with your microphone or telephony feed.
import asyncio
import base64
import json
import websockets
API_KEY = "sk-proj-your-api-key"
URL = (
"wss://api.bulutistan.ai/v1/realtime"
"?intent=transcription&model=your-speech-model"
)
SAMPLE_RATE = 16000
BYTES_PER_SAMPLE = 2
FRAME_MS = 100
FRAME_BYTES = SAMPLE_RATE * BYTES_PER_SAMPLE * FRAME_MS // 1000
async def send_audio(ws, path):
# Raw PCM16 mono 16 kHz -- no WAV header, no container.
with open(path, "rb") as pcm:
while True:
frame = pcm.read(FRAME_BYTES)
if not frame:
break
if len(frame) % 2: # never send a half sample
frame = frame[:-1]
await ws.send(json.dumps({
"type": "input_audio_buffer.append",
"audio": base64.b64encode(frame).decode("ascii"),
}))
await asyncio.sleep(FRAME_MS / 1000) # stay near realtime
# Flush the tail and wait for the final transcript before closing.
await ws.send(json.dumps({"type": "input_audio_buffer.commit"}))
async def main(path):
async with websockets.connect(
URL,
additional_headers={"Authorization": f"Bearer {API_KEY}"},
max_size=None,
) as ws:
created = json.loads(await ws.recv())
print("session:", created["session"]["id"])
# Optional, and only before the first append.
await ws.send(json.dumps({
"type": "transcription_session.update",
"session": {"input_audio_transcription": {"language": "en"}},
}))
sender = asyncio.create_task(send_audio(ws, path))
transcript = []
try:
async for raw in ws:
event = json.loads(raw)
kind = event["type"]
if kind == "conversation.item.input_audio_transcription.delta":
print(event["text"], end="\r", flush=True)
elif kind == "conversation.item.input_audio_transcription.completed":
transcript.append(event["transcript"])
print(event["transcript"])
elif kind == "conversation.item.input_audio_transcription.failed":
print("gap in transcript:", event["error"]["message"])
elif kind == "input_audio_buffer.committed":
if sender.done():
break
elif kind == "error":
# Terminal: the socket closes right after this.
print("error:", event["error"]["code"], event["error"]["message"])
break
except websockets.ConnectionClosed as closed:
print("closed:", closed.code, closed.reason)
await sender
print(" ".join(transcript))
asyncio.run(main("speech.pcm"))
Handle the close code rather than the exception text -- closed.code is one of the values in the table above.
Browser Example
Captures the microphone, downsamples to PCM16 / 16 kHz / mono, and streams it. Mint a connection token first: a browser cannot set the Authorization header, and a long-lived API key must never appear in a URL.
const MODEL = 'your-speech-model';
// 1. Exchange the portal session for a single-use, ~2-minute credential.
const minted = await fetch('https://api.bulutistan.ai/v1/realtime/token', {
method: 'POST',
headers: {
'Authorization': `Bearer ${portalSessionToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ model: MODEL })
});
const { token } = await minted.json();
// 2. Open the socket with it.
const ws = new WebSocket(
`wss://api.bulutistan.ai/v1/realtime` +
`?intent=transcription&model=${encodeURIComponent(MODEL)}` +
`&token=${encodeURIComponent(token)}`
);
ws.onmessage = (message) => {
const event = JSON.parse(message.data);
switch (event.type) {
case 'transcription_session.created':
console.log('idle timeout:', event.session.idle_timeout_seconds, 's');
break;
case 'conversation.item.input_audio_transcription.delta':
// `text` is the fullest interim view -- replace, do not append.
interim.textContent = event.text;
break;
case 'conversation.item.input_audio_transcription.completed':
final.textContent += ' ' + event.transcript;
interim.textContent = '';
break;
case 'error':
// Terminal. The close event follows immediately.
console.error(event.error.code, event.error.message);
break;
}
};
ws.onclose = (e) => console.log('closed', e.code, e.reason);
// 3. Microphone -> PCM16 -> socket.
// Name the device rather than taking `{ audio: true }`: the OS default is
// often a virtual input that streams pure silence. `exact`, never `ideal` --
// `ideal` lets the browser fall back to another input without telling you.
const stream = await navigator.mediaDevices.getUserMedia({
audio: { deviceId: { exact: chosenDeviceId } }
});
const audio = new AudioContext({ sampleRate: 16000 });
const source = audio.createMediaStreamSource(stream);
// A ScriptProcessorNode keeps this example short; prefer an AudioWorklet
// in production. Either way the frames you send must be raw PCM16.
const node = audio.createScriptProcessor(2048, 1, 1);
node.onaudioprocess = (e) => {
if (ws.readyState !== WebSocket.OPEN) return;
const floats = e.inputBuffer.getChannelData(0);
const pcm = new Int16Array(floats.length);
for (let i = 0; i < floats.length; i++) {
const s = Math.max(-1, Math.min(1, floats[i]));
pcm[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
// Base64-encode the raw little-endian samples.
const bytes = new Uint8Array(pcm.buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
ws.send(JSON.stringify({
type: 'input_audio_buffer.append',
audio: btoa(binary)
}));
};
source.connect(node);
node.connect(audio.destination);
// 4. Stopping: flush, wait for the last transcript, then close.
function stop() {
node.disconnect();
source.disconnect();
stream.getTracks().forEach((t) => t.stop());
ws.send(JSON.stringify({ type: 'input_audio_buffer.commit' }));
// Close after the final ...transcription.completed arrives, not before.
}
If the browser will not give you a 16 kHz AudioContext, resample to 16 kHz yourself before encoding -- the session accepts no other rate.
A frame of digital silence is a valid frame. The socket stays healthy, every second is accepted and metered, and the transcript stays empty -- with no error anywhere, because nothing went wrong on the wire. Nothing in the protocol can tell you that your microphone is not producing audio. That check belongs to your client, and it is worth building.
Two rules cover the common failure, which is a browser opening a virtual input device -- the ones conferencing, streaming and audio-routing tools install -- because it happened to be the OS default:
- Choose the device explicitly. Enumerate
navigator.mediaDevices.enumerateDevices(), keep theaudioinputentries, and request the one you want withdeviceId: { exact }. Skip Chromium'sdefaultandcommunicationsaliases and offer "system default" as a choice of its own -- the alias is exactly what points at the virtual device. Device labels stay empty until the origin has been granted the microphone once, so re-read the list after the first successfulgetUserMedia. - Check the first couple of seconds, then stop. Test for exact zero samples, not for quietness: a real microphone in a silent room still emits dither and preamp noise, so a loudness threshold eventually aborts a session because somebody paused to think. If every sample in the first ~2 seconds is zero, close the socket and tell the user -- do not wait for a transcript that is never coming. Stream those seconds normally while you decide; withholding them adds latency to every working session and punches a hole in the audio of the one you were trying to help.
The Client Portal's live microphone Playground does both, and is a quick way to confirm a machine's microphone before you debug your own client.
Troubleshooting
| Symptom | Cause |
|---|---|
Closes 4400 "audio must be raw pcm16 samples, not a container file" | You are sending a .wav / .mp3 / .ogg / .flac file. Strip the header and send sample data. |
Closes 4400 "got an odd number of bytes" | The chunk is not a whole number of 16-bit samples -- usually a frame split at an odd offset, or 8-bit audio. |
Closes 4408 shortly after connecting | No audio for the idle timeout. Non-audio events do not reset it. |
Closes 4409 on your first update | The update arrived after the opening window, or after audio. Send it as the very first event. |
| Transcript stops but the socket stays open | Check for ...transcription.failed events. They report audio that produced no transcript, and are not terminal. |
| Last few words missing | The socket was closed before the final completed. Commit, wait, then close. |
Nothing after transcription_session.created | A client waiting on transcription_session.updated or input_audio_buffer.committed that never sent the corresponding event. Both acks are only sent in response to a client event. |
| A long session stalls after several minutes | The same mistake, later: the client is waiting for an input_audio_buffer.committed around the silent turn recycle (about every 300 streamed audio seconds). The recycle emits no event. Only wait for an ack you asked for. |
Closes 1006 right after an unrecognised event | The close reason is truncated to 123 bytes on the admission path, and the reason for an unknown event interpolates your text: unsupported event type: {type}. A long enough bogus type produces a reason the transport drops, and you observe a bare 1006 instead of the intended 4400. Fix the event type you are sending. |
| Transcript text jumps backwards | You are concatenating text instead of delta. text is a replace-view; delta is the append-only one. |
| The session runs and is billed, but no transcript ever arrives | The audio is digital silence -- almost always a virtual input device picked up as the OS default. Nothing on the socket can report this; check the samples client-side and name the device explicitly. See Browser Example. |