Docs
Contact Dashboard

AudreyLive streaming

Live streaming

Stream audio over a WebSocket and get partial and final text as people speak.

WSSwss://api.p99lab.com/v1/stream

How a stream works

  1. Connect with Authorization: Bearer on the handshake, or put the key in the configuration.
  2. Configure: the first message is a JSON text message, sent within 10 seconds.
  3. Send audio as binary messages: 16-bit little-endian PCM, mono, at the configured sample rate, up to 64 KB each. Send it as it is captured; 20 ms chunks work well.
  4. Read results: partial text while someone speaks, a final for each utterance, and turn_end when turn detection decides the speaker has finished.
  5. Finish with {"type": "end"}. Audrey finalises what it has, sends done and closes.

The transcript is the text of every final, joined in order.

Configuration

{
  "language": "en",
  "sample_rate": 16000,
  "format": true
}

Every field is optional. Telephone audio is usually 8000 Hz. All fields are in the API reference.

A complete client

These clients read a 16-bit mono WAV file, send it in 20 ms chunks at real-time speed, send a flush and the end message, and print each final. Python needs pip install websockets (version 13 or newer); Node.js needs version 22 or newer and no packages.

Python
"""Stream a 16-bit mono WAV file to Audrey in 20 ms chunks and print each final.

pip install websockets   (version 13 or newer)
python stream.py call.wav
"""
import asyncio
import json
import os
import sys
import wave

import websockets

URL = "wss://api.p99lab.com/v1/stream"
KEY = os.environ["P99LAB_API_KEY"]


async def send_audio(ws, path):
    with wave.open(path, "rb") as w:
        if w.getsampwidth() != 2 or w.getnchannels() != 1:
            raise SystemExit("need a 16-bit mono WAV file")
        rate = w.getframerate()
        if rate not in (8000, 16000, 24000, 48000):
            raise SystemExit("resample to 8, 16, 24 or 48 kHz first")
        await ws.send(json.dumps({"language": "en", "sample_rate": rate, "format": True}))
        frames = rate // 50                       # 20 ms of audio
        while chunk := w.readframes(frames):
            await ws.send(chunk)
            await asyncio.sleep(0.02)             # real time, as a live call would
    await ws.send(json.dumps({"type": "flush"}))  # finalise the last utterance now
    await ws.send(json.dumps({"type": "end"}))


async def main(path):
    finals = []
    async with websockets.connect(URL, additional_headers={"Authorization": f"Bearer {KEY}"}) as ws:
        sender = asyncio.create_task(send_audio(ws, path))
        try:
            async for raw in ws:
                msg = json.loads(raw)
                if msg["type"] == "final":
                    print(f"[{msg['start']:7.2f} - {msg['end']:7.2f}] {msg['text']}")
                    finals.append(msg["text"])
                elif msg["type"] == "error":
                    raise RuntimeError(msg["message"])
                elif msg["type"] == "done":
                    break
        finally:
            sender.cancel()
    print("\n" + " ".join(t for t in finals if t))


if __name__ == "__main__":
    asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else "call.wav"))
Node.js
// Stream a 16-bit mono WAV file to Audrey in 20 ms chunks and print each final.
// Node.js 22 or newer (built-in WebSocket), no packages:  node stream.mjs call.wav
import { readFileSync } from "node:fs";

const URL = "wss://api.p99lab.com/v1/stream";
const KEY = process.env.P99LAB_API_KEY;

function readWav(buf) {
  let off = 12, fmt = null, data = null;
  while (off + 8 <= buf.length) {
    const id = buf.toString("ascii", off, off + 4);
    const size = buf.readUInt32LE(off + 4);
    if (id === "fmt ") fmt = { channels: buf.readUInt16LE(off + 10), rate: buf.readUInt32LE(off + 12), bits: buf.readUInt16LE(off + 22) };
    if (id === "data") data = buf.subarray(off + 8, off + 8 + size);
    off += 8 + size + (size % 2);
  }
  if (!fmt || !data || fmt.channels !== 1 || fmt.bits !== 16) throw new Error("need a 16-bit mono WAV file");
  if (![8000, 16000, 24000, 48000].includes(fmt.rate)) throw new Error("resample to 8, 16, 24 or 48 kHz first");
  return { rate: fmt.rate, pcm: data };
}

const { rate, pcm } = readWav(readFileSync(process.argv[2] ?? "call.wav"));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const finals = [];
const ws = new WebSocket(URL);

ws.addEventListener("open", async () => {
  // Node's WebSocket cannot set headers, so the key goes in the configuration message.
  ws.send(JSON.stringify({ key: KEY, language: "en", sample_rate: rate, format: true }));
  const step = (rate / 50) * 2; // 20 ms of 16-bit samples, in bytes
  for (let i = 0; i < pcm.length && ws.readyState === WebSocket.OPEN; i += step) {
    ws.send(pcm.subarray(i, i + step));
    await sleep(20); // real time, as a live call would
  }
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(JSON.stringify({ type: "flush" })); // finalise the last utterance now
    ws.send(JSON.stringify({ type: "end" }));
  }
});

ws.addEventListener("message", (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === "final") {
    console.log(`[${msg.start.toFixed(2)} - ${msg.end.toFixed(2)}] ${msg.text}`);
    finals.push(msg.text);
  } else if (msg.type === "error") {
    console.error(`error: ${msg.message}`);
    ws.close();
  } else if (msg.type === "done") {
    console.log("\n" + finals.filter(Boolean).join(" "));
    ws.close();
  }
});

Partials and finals

A partial is the utterance so far, sent about every 300 ms while someone is speaking. Each partial replaces the previous one, and its last words may still change. Partials are plain words, without written forms.

A final is the finished text of one utterance, with written forms applied when format is on. It does not change again. Its text can be empty when the sound turned out not to be speech.

{"type": "partial", "text": "I'd like to move my appointment to", "start": 12.48, "decode_ms": 41}
{"type": "final", "text": "I'd like to move my appointment to Thursday at 3:30 p.m.", "start": 12.48, "end": 15.2, "decode_ms": 118}

Times are seconds from the start of the stream. decode_ms is the server time spent on that message.

When an utterance ends

An utterance becomes a final at the first of these:

  • Turn detection decides the speaker has finished (English, on by default). See turn detection.
  • A pause of endpoint_ms (default 1500 ms).
  • Your flush: {"type": "flush"}.
  • Long speech without a pause is finalised at a quiet point, at most every 28 seconds.

Flush

Send {"type": "flush"} when your own logic, such as a voice activity detector, decides the speaker has finished. The current utterance is finalised at once, without waiting for the pause. A flush with less than 0.2 s of open speech is ignored, and no turn_end follows a flushed utterance.

{"type": "flush"}

Ending a stream

Send {"type": "end"} when the audio is finished. Audrey finalises the open utterance, delivers every final, then sends done and closes the socket. Wait for done before treating the transcript as complete.

{"type": "done", "audio_seconds": 312.6}

done may carry further fields; ignore the ones you do not use.

Dropped connections

If the connection drops without an error or done, finals you already received are complete; the utterance in progress is lost. Reconnect, send the configuration again and continue with the live audio. A session can last up to 2 hours.

↑↓ to move↵ to open