How to Stream Speech-to-Text over WebSocket

Streaming speech-to-text over a WebSocket takes four things: an access token, a short-lived WebSocket token, correctly framed 16 kHz mono PCM audio, and a loop that renders text as it arrives. This guide wires up all four, with code you can copy.

Dictate Team 9 min read Questions? Contact us

What you need before you start

  • A Dictate account. The Free tier gives you 30 minutes of transcription with no credit card.
  • An audio source that can emit raw PCM: a microphone capture, an existing streaming pipeline, or a WAV/MP3 file you chunk yourself.
  • A WebSocket client. Anything that can send binary frames works - websockets, ws, gorilla/websocket, or the browser WebSocket API.

Step 1: Create an account and keep the access token

Register, then log in. The login response contains an access_token. Store it and send it as a bearer token on API calls:

POST /api/v1/auth/login
Content-Type: application/json

{"email": "you@company.com", "password": "your-password"}

The access token is short-lived by design. Refresh it with the refresh_token from the same response rather than storing a long-lived secret.

Step 2: Request a WebSocket token

WebSocket connections cannot carry an Authorization header from a browser, so Dictate issues a dedicated, deliberately short-lived token for the socket itself:

POST /api/v1/ws-token
Authorization: Bearer <access_token>

The response gives you everything needed to open the connection:

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "port": 443,
  "path": "/ws",
  "expires_in": 300,
  "sample_rate": 16000,
  "remaining_ms": 1800000
}
  • expires_in is 300 seconds. Request one per session rather than caching it; that keeps a leaked token from staying useful.
  • sample_rate is 16000. Send audio at this rate.
  • remaining_ms is your remaining quota for this billing period, in milliseconds. Check it before starting a long session.

Use the ws_url field when the response provides one - it is assembled server-side from the real request host, so you never have to guess the port or the TLS scheme.

Step 3: Open the WebSocket

Connect to /ws?token=YOUR_TOKEN. Two details matter: the connection must be binary, and it must be opened before you start sending audio.

import asyncio, json, websockets
import requests

API = "https://dictate.top"

def get_ws_url():
    token = requests.post(f"{API}/api/v1/auth/login", json={
        "email": EMAIL, "password": PASSWORD
    }).json()["access_token"]
    info = requests.post(f"{API}/api/v1/ws-token", headers={
        "Authorization": f"Bearer {token}"
    }).json()
    scheme = "wss" if info.get("ws_url", "").startswith("wss") else "ws"
    host = info.get("host") or info.get("port")
    return f"{scheme}://{host}{info['path']}?token={info['token']}"

async def main():
    url = get_ws_url()
    async with websockets.connect(url) as ws:
            ...  # send audio, read results
asyncio.run(main())

In a browser, use binaryType = "arraybuffer" before you start receiving. Without it, binary frames arrive as Blob objects and you will silently read nothing.

Step 4: Frame your audio correctly

This is where most integrations go wrong. Three rules:

  • Format: 16 kHz, 16-bit signed, mono PCM. WAV, MP3 and OGG containers are accepted too.
  • Frame size: 100-200 ms of audio per frame. At 16 kHz that is 3,200-6,400 bytes. Below 100 ms you pay framing overhead for no latency win; above 200 ms the results stop feeling live.
  • Send as binary. JSON-encoding the audio and sending it as text roughly triples the payload and breaks the upstream protocol.
# 16 kHz * 2 bytes/sample = 32,000 bytes/second
# 150 ms frame = 32,000 * 0.15 = 4,800 bytes
FRAME_BYTES = 4800

ws.send(PCM_FRAME[:FRAME_BYTES])   # binary, not ws.send(json.dumps(...))

Send frames paced to real time. Dumping a ten-minute recording in one burst gets you the same transcript as speaking it, but none of the live behaviour that makes streaming worth using.

Step 5: Read the results as they arrive

Recognition text arrives incrementally on the same connection. Render each update as it comes in and replace the previous text, rather than appending - most streaming recognizers return the running transcript, not a delta.

async for message in ws:
    if isinstance(message, str):
        data = json.loads(message)
        if "text" in data:
            transcript_el.textContent = data["text"]   # replace, not append
        elif data.get("type") == "error":
            print("session error:", data["message"])

Common failure modes

Connected, but no text comes back

Almost always one of three things, in this order: the token expired (they last 5 minutes), the audio is not 16 kHz 16-bit mono PCM, or the socket was opened as text instead of binary.

"Quota exceeded"

You have used the included duration for the billing period. Check remaining_ms from the token response, or open the dashboard.

Connection closes immediately

The token was already used or has expired. Tokens are single-use per issued session; fetch a new one for each connection attempt.

Text arrives, but it lags badly

Frames are too large or you are not pacing sends. Drop to 100-150 ms and send on a timer matched to the frame duration.

Where to go next