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.

  1. Create a live transcription

    POST /v1/transcriptions/live with a name and optional settings (spokenLanguage, speakerLabels, latencyMode…) creates it idle. Nothing is recorded until a recording starts. Its source can 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.

  2. Put it on a broadcast

    POST /v1/broadcasts creates the link (code, and shareUrl to hand out; access: "private" and a password keep 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).

  3. Start a recording

    POST /v1/transcriptions/{id}/recordings starts one and answers with a publish grant: recording (its id is what you control and read it by), sessionId and streamUrl. A 409 conflict with details.takeover means another device is recording it now; repeat with { "takeover": true } to record from here instead.

  4. Publish audio

    Connect to streamUrl and publish exactly as on a realtime session (Stream a microphone): wait for session.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: give connect a long ready deadline (readyDeadlineMs, ready_deadline_s).

  5. Pause, resume and stop

    Control is HTTP, never a frame on the socket: pause (idempotent), resume, and stop, which is allowed only from paused. Sending eof on the publisher socket also ends the recording. A recording moves live ⇄ paused → ended → finalized (its transcript settled); listeners stay connected through ended. A recording left paused longer than settings.maxPauseS ends by itself.

TypeScript SDK

live.ts

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

live.py

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

live-raw.ts

// 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`, {});
  });
}