Guide · streaming
Listen
Follow a recording as it happens, in any language: subscribe, catch up from a snapshot, then apply live frames in order.
Anyone allowed to read a recording can follow it live over its subscribe socket: the owner with an API key, a reader with a share link's code, anyone at all on a public broadcast. The socket sends what changes from the moment it opens; what was said before is read once, from a snapshot. Getting the two to meet without a gap or a duplicate is the whole trick, and the cursor makes it exact.
Find the recording
Subscribing takes a recording id. The owner has it already: the publish grant's
recording.id, a transcription's or broadcast'scurrentRecording.recordingId, or its list of recordings. A screen waiting for something to go live opens the events socket of the transcription or the broadcast (WS /v1/transcriptions/{id}/events,/v1/broadcasts/{id}/events) and getsrecording.startedwith therecordingIdthe moment it does.Subscribe
Open
WS /v1/recordings/{id}/subscribe. The owner sends the API key asAuthorization: Bearer …(never in the URL); a share link's reader addsshareCode=; a public broadcast's listener sends nothing.lang=esreads it translated (through a share link, the link must allow translation), andvoice=picks who speaks it (Live translation and spoken translations). The first frame issubscribe.ready: the recording'sstateandlastSeq, the cursor the live sequence is at.Catch up
Read
GET /v1/recordings/{id}/transcript(samelang, same credential): every sentence and segment up to the snapshot's ownlastSeq. Every durable frame on the socket carries acursor, gapless per recording; skip the frames whosecursoris at or below the snapshot'slastSeq(they are in it already) and apply the rest in order. A listener gets anoopframe in place of one withheld from it, so the cursor never skips. If it does jump (a dropped frame,subscribe.gap), read the transcript again withafter=<cursor>and carry on from itslastSeq.vs.recordings.subscribein the TypeScript SDK does all of this and hands you the snapshot first.{"type":"subscribe.ready","recordingId":"5c0d…","lastSeq":41,"state":"live","instance":"api-1"} {"type":"(snapshot)","lastSeq":42,"text":"Good morning, everyone. Thanks for coming in today."} {"type":"transcript.committed","cursor":42,"seq":42,"segment":{"seq":17,"start":5.1,"end":6,"text":"Thanks for coming in today.","speaker":0,"words":[]}} {"type":"transcript.committed","cursor":43,"seq":43,"segment":{"seq":18,"start":6.4,"end":8.2,"text":"Let us start with the budget.","speaker":0,"words":[]}} {"type":"transcript.translated","cursor":44,"seq":18,"translations":{"es":"Empecemos por el presupuesto."}} {"type":"noop","cursor":45,"seq":45} {"type":"recording.state","cursor":46,"seq":46,"recordingId":"5c0d…","state":"paused"}Follow it live
From there, frames arrive as the recording goes:
transcript.committedappends a segment (a subscriber gets no drafts),transcript.sentencereplaces a whole sentence bysentenceId,transcript.translatedfills a segment's translations,transcript.amended(the engine) andtranscript.corrected(a person) rewrite one in place, andrecording.statesayslive,paused,ended,finalized. Change language without reopening: send{"type":"subscribe.update","lang":"fr"}(setLanguagein the SDK), then read the snapshot again in it.When it ends
At
endedthe socket stays open: corrections and the last translations can still arrive untilfinalized. Close it when you are done. Afterwards, the snapshot is the whole transcript, andGET /v1/recordings/{id}says how it ended.
TypeScript SDK
import { VocalStack } from '@vocalstack/sdk';
const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY });
const recordingId = process.argv[2]!; // a grant's recording.id, or a transcription's currentRecording.recordingId
// The owner, with the key. A share link's reader: new VocalStack({ baseUrl })
// and { shareCode: 'CODE', asOwner: false }; a public broadcast's listener: { asOwner: false }.
const sub = await vs.recordings.subscribe(recordingId, { lang: 'es' });
// subscribe() resolves once subscribe.ready and the snapshot are in: what
// was said before you came, then only frames past its cursor, gaps healed.
for (const s of sub.snapshot?.sentences ?? []) console.log(s.text, s.translations?.es ?? '');
for await (const ev of sub) {
if (ev.type !== 'frame') continue;
const f = ev.frame;
if (f.type === 'transcript.committed') console.log(f.segment.text);
if (f.type === 'transcript.translated') console.log(` es: ${f.translations.es}`);
if (f.type === 'recording.state') {
console.log(`[${f.state}]`);
if (f.state === 'finalized') sub.close();
}
}
Python SDK
import asyncio
import json
import os
import sys
from vocalstack import VocalStack
async def main() -> None:
async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
recording_id = sys.argv[1] # a grant's recording.id, or a transcription's current_recording
# The owner, with the key. A share link's reader: share_code="CODE", as_owner=False.
socket = await vs.recordings.subscribe(recording_id, lang="es")
ready = json.loads(await socket.recv()) # subscribe.ready {lastSeq, state}
print("state:", ready["state"])
snapshot = await vs.recordings.transcript(recording_id, lang="es")
for s in snapshot.sentences:
print(s.text, (s.translations or {}).get("es", ""))
cursor = snapshot.last_seq
async for message in socket:
if isinstance(message, bytes):
continue # live audio, when asked for
frame = json.loads(message)
at = frame.get("cursor")
if at is not None:
if at <= cursor:
continue # already in the snapshot
if at > cursor + 1: # a gap: read what was missed, then go on
missed = await vs.recordings.transcript(recording_id, lang="es", after=cursor)
for seg in missed.segments:
print(seg.text)
cursor = max(cursor, missed.last_seq)
if at <= cursor:
continue
cursor = at
if frame["type"] == "transcript.committed":
print(frame["segment"]["text"], flush=True)
elif frame["type"] == "transcript.translated":
print(" es:", frame["translations"].get("es"))
elif frame["type"] == "recording.state":
print(f"[{frame['state']}]")
if frame["state"] == "finalized":
await socket.close()
asyncio.run(main())
HTTP + WebSocket
// No SDK, no key: a share link's reader (shareCode), with the built-in
// WebSocket (Node 22 or newer, or a browser).
const API = 'https://api.vocalstack.com';
const [recordingId, shareCode] = [process.argv[2]!, process.argv[3]!];
const q = new URLSearchParams({ shareCode, lang: 'es' });
const ws = new WebSocket(`${API.replace(/^http/, 'ws')}/v1/recordings/${recordingId}/subscribe?${q}`);
let cursor: number | null = null; // null until the snapshot is in
const early: any[] = []; // frames that arrive while it is read
ws.addEventListener('message', async (m: MessageEvent) => {
if (typeof m.data !== 'string') return;
const f = JSON.parse(m.data);
if (f.type === 'subscribe.ready') {
const r = await fetch(`${API}/v1/recordings/${recordingId}/transcript?${q}`);
const snap = (await r.json()) as { lastSeq: number; sentences: { text: string }[] };
for (const s of snap.sentences) console.log(s.text);
cursor = snap.lastSeq;
for (const e of early.splice(0)) apply(e);
return;
}
if (cursor === null) early.push(f);
else apply(f);
});
function apply(f: any): void {
if (typeof f.cursor === 'number') {
if (f.cursor <= cursor!) return; // already in the snapshot
cursor = f.cursor; // (a jump: read /transcript?after=<cursor> again)
}
if (f.type === 'transcript.committed') console.log(f.segment.text);
if (f.type === 'transcript.translated') console.log(` es: ${f.translations.es}`);
if (f.type === 'recording.state') console.log(`[${f.state}]`);
if (f.type === 'recording.state' && f.state === 'finalized') ws.close();
}