Guide · streaming
Go live
Publish live audio to a stored transcription, and put it on a broadcast link listeners open. Control is HTTP; audio is one WebSocket.
A realtime session is gone when its socket closes. To keep what is said, and let other people follow it as it happens, publish to a live transcription: it is stored as it is spoken, listed under Transcriptions, and its recordings can be read, translated, exported and shared afterwards. A broadcast is the link listeners open (code, shareUrl); it is fed by the live transcriptions attached to it. The order: create the live transcription, optionally put it on a broadcast, start a recording, publish audio to its streamUrl, and control it over HTTP.
Create a live transcription
POST /v1/transcriptions/livewith anameand optionalsettings(spokenLanguage,speakerLabels,latencyMode…) creates it idle. Nothing is recorded until a recording starts. Itssourcecan also be a stream the server pulls itself (a YouTube live, an internet radio address), in which case there is no audio for you to send.Put it on a broadcast
POST /v1/broadcastscreates the link (code, andshareUrlto hand out;access: "private"and apasswordkeep it to the people you give them).PUT /v1/transcriptions/{id}/broadcasts/{broadcast}feeds it from the live transcription. One transcription can feed several broadcasts and a broadcast several transcriptions. Listeners follow it with the subscribe socket (Listen).Start a recording
POST /v1/transcriptions/{id}/recordingsstarts one and answers with a publish grant:recording(itsidis what you control and read it by),sessionIdandstreamUrl. A409 conflictwithdetails.takeovermeans another device is recording it now; repeat with{ "takeover": true }to record from here instead.Publish audio
Connect to
streamUrland publish exactly as on a realtime session (Stream a microphone): wait forsession.ready, send raw PCM (pcm_f32le, 16 kHz, mono) as binary frames, and read commits back. The difference is that every commit is also stored and fanned out to the recording's subscribers, translated into the languages they read. A cold machine can take a minute to become ready: giveconnecta long ready deadline (readyDeadlineMs,ready_deadline_s).Pause, resume and stop
Control is HTTP, never a frame on the socket:
pause(idempotent),resume, andstop, which is allowed only from paused. Sendingeofon the publisher socket also ends the recording. A recording moveslive⇄paused→ended→finalized(its transcript settled); listeners stay connected throughended. A recording left paused longer thansettings.maxPauseSends by itself.
TypeScript SDK
import { spawn } from 'node:child_process';
import { VocalStack } from '@vocalstack/sdk';
const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY });
const tx = await vs.transcriptions.createLive({ name: 'Town hall', settings: { spokenLanguage: 'en' } });
const broadcast = await vs.broadcasts.create({ name: 'Town hall' });
await vs.transcriptions.attachBroadcast(tx.id, broadcast.id);
console.log('listeners open', broadcast.shareUrl);
const grant = await vs.transcriptions.startRecording(tx.id);
const recordingId = grant.recording.id;
const stream = await vs.recordings.publish(grant, { readyDeadlineMs: 180_000 }); // a cold machine can take a while
// -re reads the file at its own pace, as a microphone would arrive. For a
// microphone: -f avfoundation -i :0 (macOS), -f pulse -i default (Linux).
const ffmpeg = spawn('ffmpeg', ['-loglevel', 'error', '-re', '-i', './talk.mp3', '-f', 'f32le', '-ac', '1', '-ar', '16000', '-']);
ffmpeg.stdout.on('data', (chunk: Buffer) => stream.sendAudio(new Uint8Array(chunk)));
// eof ends the recording when the audio does; pause, resume and stop are HTTP.
ffmpeg.on('close', () => stream.eof());
process.once('SIGINT', async () => {
ffmpeg.kill();
await vs.recordings.pause(recordingId);
await vs.recordings.stop(recordingId); // only from paused
});
for await (const ev of stream) {
if (ev.type === 'transcript.committed') console.log(`#${ev.segment.seq} ${ev.segment.text}`);
}
console.log('ended:', (await vs.recordings.get(recordingId)).state);
Python SDK
import asyncio
import os
from vocalstack import VocalStack, pace_realtime
# -re is not needed here: pace_realtime sends the PCM at its own pace.
DECODE = ["ffmpeg", "-loglevel", "error", "-i", "./talk.mp3", "-f", "f32le", "-ac", "1", "-ar", "16000", "-"]
async def main() -> None:
async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
tx = await vs.transcriptions.create_live(name="Town hall", settings={"spokenLanguage": "en"})
broadcast = await vs.broadcasts.create(name="Town hall")
await vs.transcriptions.attach_broadcast(tx.id, broadcast.id)
print("listeners open", broadcast.share_url)
grant = await vs.transcriptions.start_recording(tx.id)
recording_id = grant.recording.id
ffmpeg = await asyncio.create_subprocess_exec(*DECODE, stdout=asyncio.subprocess.PIPE)
pcm, _ = await ffmpeg.communicate()
async with await vs.recordings.publish(grant, ready_deadline_s=180) as stream:
feeder = asyncio.create_task(pace_realtime(stream, pcm)) # real time, then eof(): the recording ends
async for event in stream:
if event.type == "transcript.committed":
print(f"#{event.segment.seq} {event.segment.text}", flush=True)
await feeder
# To end without eof: await vs.recordings.pause(recording_id), then stop(recording_id).
print("ended:", (await vs.recordings.get(recording_id)).state)
asyncio.run(main())
HTTP + WebSocket
// No SDK: fetch for the calls, the built-in WebSocket for the audio (Node 22 or newer).
import { spawn } from 'node:child_process';
const API = 'https://api.vocalstack.com';
const headers = { Authorization: `Bearer ${process.env.VOCALSTACK_API_KEY}`, 'Content-Type': 'application/json' };
async function call<T>(method: string, path: string, body?: unknown): Promise<T> {
const r = await fetch(`${API}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
if (!r.ok) throw new Error(`${method} ${path}: ${r.status} ${await r.text()}`);
return (await r.json()) as T;
}
const tx = await call<{ id: string }>('POST', '/v1/transcriptions/live', { name: 'Town hall', settings: { spokenLanguage: 'en' } });
const broadcast = await call<{ id: string; shareUrl: string }>('POST', '/v1/broadcasts', { name: 'Town hall' });
await call('PUT', `/v1/transcriptions/${tx.id}/broadcasts/${broadcast.id}`);
console.log('listeners open', broadcast.shareUrl);
const grant = await call<{ recording: { id: string }; streamUrl: string }>('POST', `/v1/transcriptions/${tx.id}/recordings`, {});
const ws = new WebSocket(grant.streamUrl);
ws.addEventListener('message', (m: MessageEvent) => {
if (typeof m.data !== 'string') return;
const f = JSON.parse(m.data);
if (f.type === 'session.ready') startAudio();
if (f.type === 'transcript.committed') console.log(`#${f.segment.seq} ${f.segment.text}`);
if (f.type === 'session.done') console.log('ended:', f.reason);
});
function startAudio(): void {
const ffmpeg = spawn('ffmpeg', ['-loglevel', 'error', '-re', '-i', './talk.mp3', '-f', 'f32le', '-ac', '1', '-ar', '16000', '-']);
ffmpeg.stdout.on('data', (chunk: Buffer) => ws.send(new Uint8Array(chunk)));
ffmpeg.on('close', () => ws.send(JSON.stringify({ type: 'eof' })));
process.once('SIGINT', async () => {
ffmpeg.kill();
await call('POST', `/v1/recordings/${grant.recording.id}/pause`);
await call('POST', `/v1/recordings/${grant.recording.id}/stop`, {});
});
}