Guide · streaming

Live translation and spoken translations

Translate a stream as it is spoken, and have each translated sentence spoken back in a voice. Text frames and audio clips, over the same socket.

A realtime session (and every recording, for its listeners) can translate what is said into other languages as it goes, and speak the translation: each finished sentence is synthesised in the language's voice and sent as a short audio clip. Translations follow the transcript by a second or two; a spoken sentence follows its translation.

  1. You send: options: translate ["es"], sentences { enabled: true }, voice ["es"]
  2. VocalStack sends: transcript.committed (the words as spoken)
  3. VocalStack sends: transcript.sentence (open) (the sentence so far, a provisional translation)
  4. VocalStack sends: transcript.sentence (closed) (the sentence and its final translation)
  5. VocalStack sends: transcript.voice (that sentence, spoken in Spanish (Ogg/Opus))
  1. Pick the languages

    GET /v1/languages lists what can be transcribed (transcription), translated into (translation) and spoken (voice), with names. GET /v1/voices/available?lang=es lists the voices that speak a language, best first; default marks the one used when none is named, and with a key your own cloned voices are there too.

  2. Ask for translations

    translate: ["es", "fr"] on the session's options. Without sentences, each commit is followed by a transcript.translated frame naming its seq: attach translations to that segment. You can change the list mid-stream (session.update, translate: […]); it applies to what follows.

  3. Read them by sentence

    With sentences: { enabled: true }, the server also assembles commits into sentences and translates whole sentences, which reads far better. transcript.sentence is a full replacement of one sentence, keyed by sentenceId: status goes open → closed (or dropped: discard it), seqs lists the commits it covers, and translations are provisional while it is open and final once it closes. Upsert by sentenceId; never append.

  4. Speak them

    voice: ["es"] (it needs sentences) speaks each closed sentence's Spanish translation in the language's default voice; "es:<voice id>" names a voice from the catalogue, and voiceByLang: { "es": "<voice id>" } sets one per language. With voiceCast on (the default), each speaker gets a distinct voice of matching gender. Each clip arrives as a transcript.voice frame: sentenceId, lang, durationS, and audio, the clip itself (base64 Ogg/Opus, 24 kHz mono). A long sentence comes in pieces (part of parts) so the first can play while the rest are made: play them back to back, in order.

    {"type":"session.ready"}
    {"type":"transcript.committed","segment":{"seq":0,"start":0,"end":1.4,"text":"Good morning, everyone.","speaker":0,"words":[]}}
    {"type":"transcript.sentence","sentence":{"sentenceId":0,"status":"open","text":"Good morning, everyone.","seqs":[0],"speaker":0,"start":0,"end":1.4,"translations":{"es":"Buenos días a todos."}}}
    {"type":"transcript.sentence","sentence":{"sentenceId":0,"status":"closed","text":"Good morning, everyone.","seqs":[0],"speaker":0,"start":0,"end":1.4,"translations":{"es":"Buenos días a todos."}}}
    {"type":"transcript.voice","sentenceId":0,"generation":0,"lang":"es","voice":"es_ES-davefx-medium","format":"ogg","durationS":1.3,"synthS":0.4,"part":0,"parts":1,"voiceKey":"","audio":"T2dnUwACAAAA…"}
  5. For a recording's listeners

    A listener on the subscribe socket asks with lang=es (translations filtered to Spanish) and voice=<voice id> (Listen); a clip reaches every listener on that language and voice, synthesised once. On a recording, transcript.voice carries a url instead of audio: fetch it with GET /v1/recordings/{id}/voice/{sentence}?lang=es (Ogg/Opus; format=caf for Apple). transcript.voiceStatus says when a voice is still preparing (its clips are held and will come) or unavailable. After the recording, GET /v1/recordings/{id}/voice lists every clip left behind, language by language.

TypeScript SDK

translate.ts

import { createReadStream } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { VocalStack, applySentence, type SentenceSegment } from '@vocalstack/sdk';

const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY });

const { voices } = await vs.voices.available('es');
const voice = voices.find((v) => v.default) ?? voices[0];

// A file at full speed here; a microphone works the same (Stream a microphone).
const session = await vs.realtime.connect(
  {
    input: { kind: 'file' },
    spokenLanguage: 'en',
    translate: ['es'],
    sentences: { enabled: true },
    voice: [`es:${voice.id}`],
  },
  {
    // The clips arrive as transcript.voice frames: onFrame sees every frame.
    onFrame: (f) => {
      if (f.type === 'transcript.voice' && f.audio) {
        void writeFile(`es-${f.sentenceId}-${f.part ?? 0}.ogg`, Buffer.from(f.audio, 'base64'));
      }
    },
  },
);
void session.sendFile(createReadStream('./talk.mp3'));

let sentences: SentenceSegment[] = [];
for await (const ev of session) {
  if (ev.type !== 'transcript.sentence') continue;
  sentences = applySentence(sentences, ev.sentence); // upsert by sentenceId
  if (ev.sentence.status === 'closed') console.log(`${ev.sentence.text}\n  es: ${ev.sentence.translations?.es}`);
}
console.log(`${sentences.length} sentences; the Spanish clips are es-<sentence>-<part>.ogg`);

Python SDK

translate.py

import asyncio
import base64
import os

from vocalstack import VocalStack


async def main() -> None:
    async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
        catalogue = await vs.voices.available("es")
        voice = next((v for v in catalogue.voices if v.default), catalogue.voices[0])

        # A file at full speed here; a microphone works the same (Stream a microphone).
        session = await vs.realtime.connect(
            input={"kind": "file"},
            spoken_language="en",
            translate=["es"],
            sentences={"enabled": True},
            voice=[f"es:{voice.id}"],
        )
        async with session:
            sender = asyncio.create_task(session.send_file("./talk.mp3"))
            async for event in session:
                if event.type == "transcript.sentence" and event.sentence.status == "closed":
                    print(event.sentence.text)
                    print("  es:", (event.sentence.translations or {}).get("es"))
                elif event.type == "transcript.voice" and event.audio:
                    with open(f"es-{event.sentence_id}-{event.part or 0}.ogg", "wb") as f:
                        f.write(base64.b64decode(event.audio))
            await sender


asyncio.run(main())

HTTP + WebSocket

translate-raw.ts

// No SDK: fetch for the session, the built-in WebSocket for the stream (Node 22 or newer).
import { createReadStream } from 'node:fs';
import { writeFile } from 'node:fs/promises';

const API = 'https://api.vocalstack.com';
const auth = { Authorization: `Bearer ${process.env.VOCALSTACK_API_KEY}` };

const cat = (await (await fetch(`${API}/v1/voices/available?lang=es`, { headers: auth })).json()) as { voices: { id: string; default: boolean }[] };
const voice = cat.voices.find((v) => v.default) ?? cat.voices[0];

const res = await fetch(`${API}/v1/realtime/sessions`, {
  method: 'POST',
  headers: { ...auth, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    input: { kind: 'file' },
    spokenLanguage: 'en',
    translate: ['es'],
    sentences: { enabled: true },
    voice: [`es:${voice.id}`],
  }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
const ws = new WebSocket(((await res.json()) as { streamUrl: string }).streamUrl);

ws.addEventListener('message', async (m: MessageEvent) => {
  if (typeof m.data !== 'string') return;
  const f = JSON.parse(m.data);
  if (f.type === 'session.ready') {
    for await (const chunk of createReadStream('./talk.mp3', { highWaterMark: 256 * 1024 })) ws.send(new Uint8Array(chunk as Buffer));
    ws.send(JSON.stringify({ type: 'eof' }));
  }
  if (f.type === 'transcript.sentence' && f.sentence.status === 'closed') console.log(`${f.sentence.text}\n  es: ${f.sentence.translations?.es}`);
  if (f.type === 'transcript.voice' && f.audio) await writeFile(`es-${f.sentenceId}-${f.part ?? 0}.ogg`, Buffer.from(f.audio, 'base64'));
  if (f.type === 'session.done') ws.close();
});