Quickstart
Stream a WAV file and print what each speaker said. You need an API key and Node.js.
You need an API key and your stream URL, which ends in /v1/stream. API access is not open yet; the Overview says how to get it.
Transcribe a WAV file #
Save this as transcribe.mjs and run it with Node.js 22 or later (node --version). The WAV file must hold 16 kHz little-endian signed 16-bit mono PCM; the script stops if it does not, and ffmpeg can convert any recording:
ffmpeg -i recording.m4a -ar 16000 -ac 1 -c:a pcm_s16le meeting.wav// transcribe.mjs: stream a WAV file and print each speaker's final text.
import { readFileSync } from "node:fs";
const url = process.env.SPEECHRELAY_URL; // your stream URL, ending in /v1/stream
const key = process.env.SPEECHRELAY_KEY;
const wav = readFileSync(process.argv[2]);
// Read the WAV chunks: "fmt " describes the audio, "data" holds the samples.
let format, pcm;
for (let at = 12; at + 8 <= wav.length; ) {
const id = wav.toString("ascii", at, at + 4);
const size = wav.readUInt32LE(at + 4);
if (id === "fmt ") format = wav.subarray(at + 8, at + 8 + size);
if (id === "data") pcm = wav.subarray(at + 8, at + 8 + size);
at += 8 + size + (size % 2);
}
if (!format || format.readUInt16LE(0) !== 1 || format.readUInt16LE(2) !== 1 ||
format.readUInt32LE(4) !== 16000 || format.readUInt16LE(14) !== 16) {
throw new Error("Convert the file to 16 kHz mono 16-bit PCM first.");
}
if (!pcm?.length) throw new Error("This WAV file has no audio samples.");
const second = 32000; // bytes in one second of 16 kHz 16-bit mono audio
const socket = new WebSocket(url, ["dicon.v1", `bearer.${key}`]);
let upload, deadline, ended = false;
// Every ending comes here once: stop the upload and the wait, close the
// connection, and exit unsuccessfully unless the session completed normally.
function end(failure) {
if (ended) return;
ended = true;
clearInterval(upload);
clearTimeout(deadline);
if (failure) {
console.error(failure);
process.exitCode = 1;
}
socket.close();
}
// How long to wait for the server is this script's choice, not a service limit.
function waitFor(what, seconds) {
clearTimeout(deadline);
deadline = setTimeout(() => end(`no ${what} within ${seconds} s`), seconds * 1000);
}
waitFor("acceptance", 15);
socket.onopen = () => {
socket.send(JSON.stringify({
type: "session.create",
protocol_version: "1.0",
mode: "mt_asr",
expected_num_speakers: "auto",
}));
};
socket.onmessage = ({ data }) => {
const event = JSON.parse(data);
if (event.type === "session.accepted") {
clearTimeout(deadline);
socket.send(JSON.stringify({ type: "stream.start" }));
// One second of audio per message, once a second, so the audio waiting
// on the server stays under the limit.
let sent = 0;
upload = setInterval(() => {
socket.send(pcm.subarray(sent, sent + second));
sent += second;
if (sent >= pcm.length) {
clearInterval(upload);
socket.send(JSON.stringify({ type: "stream.end" }));
waitFor("completion", 30);
}
}, 1000);
} else if (event.type === "transcript.final") {
console.log(`${event.speaker}: ${event.text}`);
} else if (event.type === "error") {
end(`error ${event.code}: ${event.message}`);
} else if (event.type === "session.completed") {
end(event.reason === "end_of_stream" ? null : `session ended: ${event.reason}`);
}
};
socket.onclose = ({ code }) => {
end(`connection closed (${code}) before the session completed`);
};
SPEECHRELAY_URL=<your stream URL> SPEECHRELAY_KEY=<your key> node transcribe.mjs meeting.wavWhat the script does #
- Connects to your stream URL, offering the subprotocol
dicon.v1and the key asbearer.<key>. Authentication covers both ways to send a key. - Sends
session.createand waits forsession.accepted, which carries the session and request IDs. - Sends
stream.start, then the audio as binary messages. It sends one second of audio each second because one message may carry at most 2 seconds and at most 8 seconds may wait on the server (Limits). - Prints each
transcript.final: the speaker label and that speaker's complete text. - Sends
stream.end. The server finishes, sendssession.completedand closes with1000. - Stops at the first sign of failure: an
errorevent, asession.completedwhosereasonis notend_of_stream, or a close before completion. It prints why, stops sending and exits with status 1, keeping the finals already printed. It waits at most 15 seconds forsession.acceptedand 30 seconds forsession.completed; those waits are the script's own choice.
{"type":"session.create","protocol_version":"1.0","mode":"mt_asr","expected_num_speakers":"auto"}mode is mt_asr or ts_asr, and expected_num_speakers is "auto" or an integer; which ones a worker accepts depends on its engine. Streaming describes every field.
Next steps #
- Show text while people talk: read
transcript.partialas Streaming describes. - Follow one speaker: in
ts_asr, sendenrollment.submitbeforestream.start(Streaming). - Handle failures: a wrong key fails the connection with HTTP
401before it opens, and other problems arrive aserrorevents. Errors says which codes to retry.
Browser streaming helper #
For a browser app that already has 16 kHz mono PCM, live or recorded, this helper runs the stream, applies partials and reports errors. It does not capture or convert audio. Pass chunks of any length as an array or async iterable: it sends at most one second per message and stays no more than two seconds ahead of the audio's own duration, inside the Limits. Check error before treating the result as complete:
const result = await transcribePcm(url, token, pcmChunks);
// result.transcripts: a Map of speaker IDs to received transcript updates.
// result.error: null on normal completion, otherwise a safe error message.Complete stream helper — expand to copy
const SAMPLE_RATE = 16000;
// The default limits: 2 seconds of audio per message, 8 seconds queued.
const MAX_MESSAGE_SAMPLES = 2 * SAMPLE_RATE;
function createSpeechRelayStream({
url, token, onTranscript, onError, onComplete
}) {
const speakers = new Map();
let socket;
let accepted = false;
let finished = false;
let disposed = false;
let terminal = false;
let resolveReady, rejectReady;
const ready = new Promise((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
const timer = setTimeout(() => {
fail("Session acceptance timed out.");
}, 15000);
function dispose() {
if (disposed) return;
disposed = true;
clearTimeout(timer);
if (!accepted) rejectReady(new Error("Stream disposed before ready."));
if (socket) {
for (const [type, handler] of Object.entries(handlers)) {
socket.removeEventListener(type, handler);
}
socket.close();
}
}
function fail(message) {
if (terminal || disposed) return;
terminal = true;
if (!accepted) rejectReady(new Error(message));
dispose();
onError(message);
}
function send(value) {
try {
socket.send(value);
return true;
} catch {
fail("Could not send to the stream.");
return false;
}
}
function receive(event) {
if (terminal || disposed) return;
let message;
try {
message = JSON.parse(event.data);
if (!message || typeof message !== "object") throw new Error();
} catch {
fail("Invalid stream message.");
return;
}
if (message.type === "session.accepted" && !accepted) {
if (!send(JSON.stringify({ type: "stream.start" }))) return;
accepted = true;
clearTimeout(timer);
resolveReady();
} else if (message.type === "error") {
// Do not echo server messages or transport errors: they may contain input.
fail("The server reported a stream error.");
} else if (message.type === "session.completed") {
if (!accepted || message.reason !== "end_of_stream") {
fail("The session ended without normal completion.");
return;
}
terminal = true;
dispose();
onComplete();
} else if (accepted && (
message.type === "transcript.partial" ||
message.type === "transcript.final"
)) {
const state = speakers.get(message.speaker) ?? {
committed: [], open: "", incomplete: false, finalText: null
};
speakers.set(message.speaker, state);
if (message.type === "transcript.final") {
state.finalText = message.text;
state.open = "";
state.incomplete = false;
} else {
if (state.incomplete || state.finalText !== null) return;
if (state.committed.length !== message.stable_prefix_words) {
state.incomplete = true;
} else {
state.committed.push(...message.appended);
state.open = message.open_word;
}
}
onTranscript({
speaker: message.speaker,
text: state.finalText ?? [...state.committed, state.open]
.filter(Boolean).join(" "),
final: state.finalText !== null,
incomplete: state.incomplete
});
}
}
const handlers = {
open() {
if (terminal || disposed) return;
send(JSON.stringify({
type: "session.create", protocol_version: "1.0",
mode: "mt_asr", expected_num_speakers: 2
}));
},
message: receive,
error() { fail("WebSocket connection failed."); },
close() { fail("Stream closed before completion."); }
};
try {
const protocols = ["dicon.v1"];
if (token) protocols.push("bearer." + token);
socket = new WebSocket(url, protocols);
for (const [type, handler] of Object.entries(handlers)) {
socket.addEventListener(type, handler);
}
} catch {
fail("Could not open the WebSocket.");
}
return {
ready,
// Sends one message as given; the caller keeps to the audio's pace.
sendAudio(chunk) {
if (!accepted || finished || disposed) {
throw new Error("Audio requires a ready, unfinished stream.");
}
if (!(chunk instanceof Int16Array)) {
throw new TypeError("Audio must be an Int16Array.");
}
if (chunk.length > MAX_MESSAGE_SAMPLES) {
throw new RangeError("One message holds at most 2 seconds of audio.");
}
if (chunk.length === 0) return;
const bytes = new ArrayBuffer(chunk.length * 2);
const view = new DataView(bytes);
for (let i = 0; i < chunk.length; i++) {
view.setInt16(i * 2, chunk[i], true);
}
send(bytes);
},
finish() {
if (finished) return;
if (!accepted || disposed) {
throw new Error("Finish requires a ready stream.");
}
finished = true;
send(JSON.stringify({ type: "stream.end" }));
},
dispose
};
}
// url: your authorized WebSocket endpoint, including /v1/stream.
// Illustrative parameters: "wss://api.speechrelay.online/v1/stream",
// "YOUR_TOKEN". These do not grant access.
// pcmChunks: an array or async iterable of 16 kHz mono signed 16-bit PCM
// Int16Arrays of any length, from a live source or already recorded.
async function transcribePcm(url, token, pcmChunks) {
// One second per message, never more than two seconds ahead of the
// audio's own duration, so recorded audio stays inside both limits.
const PIECE_SAMPLES = SAMPLE_RATE;
const AHEAD_MS = 2000;
const transcripts = new Map();
let error = null;
let ended = false;
const completed = new Promise((resolve) => {
function end() {
ended = true;
resolve();
}
const stream = createSpeechRelayStream({
url, token,
onTranscript(update) { transcripts.set(update.speaker, update); },
onError(message) { error = message; end(); },
onComplete() { end(); }
});
async function upload() {
try {
await stream.ready;
let due = Date.now();
for await (const chunk of pcmChunks) {
if (!(chunk instanceof Int16Array)) {
throw new TypeError("Audio must be an Int16Array.");
}
for (let i = 0; i < chunk.length; i += PIECE_SAMPLES) {
const piece = chunk.subarray(i, i + PIECE_SAMPLES);
const pieceMs = (piece.length * 1000) / SAMPLE_RATE;
const now = Date.now();
due = Math.max(due, now);
const wait = Math.ceil(due + pieceMs - AHEAD_MS - now);
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
stream.sendAudio(piece);
due += pieceMs;
}
}
stream.finish();
} catch {
// After the stream ends, sendAudio and finish throw; that is not an error.
if (ended) return;
error = "Could not finish sending PCM audio.";
stream.dispose();
end();
}
}
void upload();
});
await completed;
// Preserve received output even when the stream fails.
return { transcripts, error };
}For finer control, createSpeechRelayStream returns ready, sendAudio(Int16Array), finish() and dispose(); await ready before sending. sendAudio sends one message of at most 32000 samples (2 seconds) and throws above that; it does not pace, so send audio at the rate it was recorded. onTranscript receives {speaker, text, final, incomplete}, which are helper fields, not wire fields: incomplete marks a missed partial until the final text arrives. The helper does not retry.
The helper sends the key in the subprotocol list because a browser cannot set a header on a WebSocket. Never put a key in the URL.