Een realtime transcript komt twee keer binnen: eerst als een concept dat steeds verandert, daarna als een definitieve regel die niet meer verandert. Toon het concept als een vervangbaar einde en de definitieve regel als geschiedenis, en de rest van een live ondertitelweergave is leidingwerk.
Een realtime sessie is het kleinste wat de VocalStack API doet. Je server doet één HTTP-aanroep en krijgt een ondertekende streamUrl terug. Je client opent een WebSocket naar die URL, stuurt ruwe audio zodra die wordt opgenomen, en leest het transcript terug als JSON-tekstframes, vlak achter de spreker. Wanneer de audio stopt, stuur je eof; de server maakt de buffer leeg, meldt wat de sessie heeft gemeten en sluit af.
Er wordt niets opgeslagen. Het transcript bestaat alleen op de verbinding, wat precies is wat een ondertitelweergave, een overlay bij een vergadering of een spraakfunctie in je eigen app wil. Als je de woorden wilt bewaren, delen en achteraf exporteren, dan wil je een livetranscriptie en een uitzending; de laatste sectie wijst die kant op.
De vorm is die van de meeste streaming spraak-API's: een uitwisseling van inloggegevens over HTTP, een langlevende socket waarin audio naar binnen gaat en JSON eruit komt, en een manier om voorlopige tekst van definitieve tekst te onderscheiden. De namen verschillen per API; de bewegende delen zelden. Hier, in volgorde:
POST /v1/realtime/sessions met je API-sleutel, op je server. Het antwoord is { sessionId, streamUrl, expiresAt }.streamUrl.session.loading-heartbeats tot session.ready.transcript.draft (vervang het einde) en transcript.committed (voeg een definitieve regel toe).{"type":"eof"}; lees session.done.Alleen stap 1 heeft je sleutel nodig, dus die gebeurt op je server.
Maak een sleutel aan onder Developers, API keys met het Write-recht. Een sleutel is bedoeld voor servers en scripts: de API weigert een sleutel die vanaf een webpagina of in een URL wordt verstuurd, dus maak je de sessie aan achter een route van jezelf, en ziet de pagina alleen de streamUrl.
De URL draagt zijn eigen token, vanwege een browserbeperking die in het algemeen de moeite waard is om te kennen. De browserconstructor WebSocket kan geen requestheaders instellen, dus er is geen Authorization-header waarin je een sleutel kunt zetten; elke spraak-API die in de browser wordt gebruikt, authenticeert zijn socket met een kortlevend token in de URL (of verstopt in een subprotocolnaam). Kortlevend is het hele punt: een URL belandt in logs en in de geschiedenis, dus wat erin staat moet verlopen. Maak de verbinding voor expiresAt.
De SDK's worden gedownload uit het Developers-gedeelte, niet uit een pakketregister; de Install-sectie van de quickstart toont de eigen commando's van deze omgeving. Met de TypeScript-SDK ziet een serverroute er zo uit:
// On your server, behind a route of yours (POST /api/transcribe, say):
import { VocalStack } from '@vocalstack/sdk';
const vs = new VocalStack({ baseUrl: '{{api}}', apiKey: process.env.VOCALSTACK_API_KEY });
export async function createStream(): Promise<{ sessionId: string; streamUrl: string }> {
const { sessionId, streamUrl } = await vs.realtime.createSession({ spokenLanguage: 'en' });
return { sessionId, streamUrl };
}
Zonder SDK is het één fetch. Deze vraagt ook om Spaans en zet de zinsopbouw uit, in lijn met het wire-voorbeeld verderop:
const res = await fetch('{{api}}/v1/realtime/sessions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.VOCALSTACK_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ spokenLanguage: 'en', translate: ['es'], sentences: { enabled: false } }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); // the error envelope: { code, message, … }
const { streamUrl } = (await res.json()) as { sessionId: string; streamUrl: string; expiresAt: string };
Elke optie heeft een standaardwaarde. De belangrijkste om meteen te kennen zijn spokenLanguage (laat je die weg, dan wordt de taal gedetecteerd), translate (een lijst van doeltalen), speakerLabels en latencyMode; de rest komt hieronder vanzelf aan bod wanneer het nodig is.
Geef de streamUrl door aan wat de audio opneemt; vanaf hier is er geen sleutel meer nodig.
Verbind met streamUrl zoals die is, zonder header. Terwijl de sessie start, stuurt de server session.loading-heartbeats. Daarna komt session.ready. Stuur geen audio voordat die er is. Audio die te vroeg wordt verstuurd, wordt niet voor later bewaard; de server behandelt het als een fout, stuurt een error-frame en sluit de socket.
Zodra de socket open is, heeft elke fout de vorm van dat frame: {"type":"error","code":"…","message":"…","retryable":true|false}. De code is een gesloten lijst (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message is bedoeld voor je logs, en retryable vertelt of een nieuwe sessie de moeite waard is om te proberen. Behandel het vanaf het eerste frame:
const ws = new WebSocket(streamUrl); // the token is in the URL: no header
ws.binaryType = 'arraybuffer';
ws.addEventListener('message', (m: MessageEvent) => {
if (typeof m.data !== 'string') return;
const f = JSON.parse(m.data);
switch (f.type) {
case 'session.loading':
console.error('starting…');
break;
case 'session.ready':
startMicrophone(); // send audio only after session.ready
break;
case 'error':
console.error(`${f.code}: ${f.message}`);
if (f.retryable) scheduleReconnect(); // a new session, with backoff; see "Stop"
break;
}
});
Een ruwe client moet nog twee dingen zelf afhandelen; de SDK's doen dat al. Ten eerste heeft een geweigerde upgrade de gebruikelijke HTTP-foutenvelop van de API: 401 invalid_token, 410 session_expired, of 503 unavailable met een Retry-After-header die je moet respecteren.
Ten tweede lijkt verzadiging op stilte, niet op een fout. Als er binnen 30 seconden geen enkel frame aankomt, behandel dat als capacity_exceeded en verbind opnieuw; een nieuwe sessie komt meestal wel door. Als loading-frames blijven komen maar session.ready nooit, behandel het als engine_unavailable, wat ook opnieuw proberen toelaat. In de SDK's keert RealtimeSession.connect terug zodra session.ready binnenkomt, en faalt het na readyDeadlineMs met de juiste code.
Zodra session.ready binnenkomt, verwacht de server audio in het tempo waarin er gesproken wordt.
Audio gaat naar binnen als binaire frames met ruwe PCM: 32-bit float, little-endian, 16 kHz, mono (pcm_f32le, het standaard audio-formaat van de sessie). Stuur het zodra het wordt opgenomen, in stukken van ongeveer 100 tot 250 ms, zonder tussen de stukken op het transcript te wachten.
De bytesom is het waard om één keer te maken. 16.000 samples per seconde met elk 4 bytes is 64.000 bytes per seconde, dus 100 ms is 6.400 bytes en 250 ms is 16.000. Spraakmodellen worden vaak getraind op 16 kHz mono, en daarom vragen zoveel realtime-API's daarom, terwijl microfoons en browsers opnemen op 44,1 of 48 kHz en vaak in stereo. Er moet iets de audio hersamplen en de kanalen tot één samenvoegen voordat de bytes eruit gaan: hier is dat ffmpeg, en in de browser is het de microfoonhelper van de SDK. 48 kHz-audio versturen en 16 kHz opgeven geeft geen fout; het levert spraak op in slow motion, en dan nog slecht.
In Node is ffmpeg het eenvoudigste opnameapparaat:
import { spawn } from 'node:child_process';
function startMicrophone(): void {
// Raw PCM: f32le, 16 kHz, mono, as binary frames. macOS: -f avfoundation -i :0, Linux: -f pulse -i default.
const mic = spawn('ffmpeg', ['-loglevel', 'error', '-f', 'avfoundation', '-i', ':0', '-f', 'f32le', '-ac', '1', '-ar', '16000', '-']);
mic.stdout.on('data', (chunk: Buffer) => {
// Backpressure: bytes the socket has accepted but not yet put on the wire.
// Two seconds of audio (128,000 bytes) queued means the link, not the server, is behind.
if (ws.bufferedAmount > 128_000) console.error(`link behind by ${ws.bufferedAmount} bytes`);
ws.send(new Uint8Array(chunk));
});
mic.on('close', () => ws.send(JSON.stringify({ type: 'eof' })));
process.once('SIGINT', () => mic.kill());
}
ws.send blokkeert nooit; het zet in de wachtrij. bufferedAmount is de grootte van die wachtrij, en op een gezonde verbinding blijft die dicht bij nul. Live audio kan niet worden gepauzeerd, dus de nuttige reactie op een groeiende wachtrij is die opmerken, niet frames weggooien. De server past ook backpressure toe. Hij accepteert audio een paar seconden vooruit op de echte tijd (limits.audioBufferS) en leest dan niet verder tot hij is ingehaald. Op de terugweg worden vastleggingen zonder verlies in de wachtrij gezet, terwijl concepten worden samengevoegd, zodat een trage lezer minder, maar verser concepten ziet. Een client die zelfs de vastleggingen niet bij kan houden, krijgt een error-frame met slow_consumer en retryable: false, en wordt verbroken; als je dat ziet, doet je berichtenhandler te veel werk.
In een browser vraagt streamMicrophone uit @vocalstack/sdk/mic om de microfoon, zet om naar 16 kHz, en stuurt de stukken samen met clock-frames, waarmee de server gaten kan markeren wanneer de browser de tab vertraagt. De pagina krijgt de sessie van je route en ziet nooit de sleutel:
import { RealtimeSession } from '@vocalstack/sdk';
import { streamMicrophone } from '@vocalstack/sdk/mic';
export async function transcribeMicrophone(
stream: { sessionId: string; streamUrl: string },
render: (committed: string, tail: string) => void,
): Promise<void> {
const session = await RealtimeSession.connect(stream);
const mic = await streamMicrophone(session); // asks for the microphone, sends 16 kHz PCM
document.querySelector('#stop')?.addEventListener('click', () => {
void mic.stop();
session.eof();
});
let committed = '';
for await (const ev of session) {
if (ev.type === 'transcript.draft') render(committed, ev.segments.map((s) => s.text).join(' '));
if (ev.type === 'transcript.committed') {
committed += `${ev.segment.text} `;
render(committed, '');
}
}
}
Die render(committed, tail)-signatuur is het hele UI-patroon; de volgende sectie legt uit waarom.
Twee frametypes bouwen het transcript, en ze door elkaar halen is de meest voorkomende fout.
Eerst waarom er er überhaupt twee zijn. Een spraakmodel bepaalt wat een woord was deels aan de hand van de woorden die erna komen: "I scream" en "ice cream" klinken hetzelfde tot de zin verdergaat. Daarom geeft een realtime herkenner vroeg een gok, en herziet die naarmate er meer audio binnenkomt, en pas vastligt als het genoeg heeft gehoord. Elke streaming spraak-API maakt dit op een of andere manier zichtbaar. Sommige noemen de gokken "interim" of "partial"-resultaten en markeren de vastgelegde met is_final; de woordkeuze verschilt, de discipline niet. Hier heet de gok een concept en de vastgelegde tekst een vastlegging.
transcript.draft is de live staart: de woorden die sinds de laatste vastlegging zijn gehoord, nog steeds in beweging. Elk concept vervangt het vorige volledig. Teken de staart opnieuw met zijn segments; voeg hem nooit toe. Concepten dragen met opzet geen seq, dus probeer ze niet te koppelen aan de vastleggingen waartoe ze worden.
transcript.committed is definitief: één segment met seq (0, 1, 2, zonder gaten), start, end, text, speaker en words met timing per woord. Voeg het toe en laat het concept dat het dekt vallen; het volgende concept begint waar deze vastlegging eindigde.
committed: [seq 0] [seq 1]
draft: "how are" ← frame 1
draft: "how are you do" ← frame 2 REPLACES frame 1
committed: [seq 0] [seq 1] [seq 2 "How are you doing?"]
← the commit consumes the tail; drop the draft
Een UI die concepten toevoegt, toont gedupliceerde, flikkerende tekst. Je ondertitelweergave krijgt een stotter die de spreker nooit had. Render alle vastgelegde segmenten, daarna het laatste concept erna, en wis het concept zodra een vastlegging binnenkomt. Een vastlegging kan woorden bevatten die in geen enkel concept voorkwamen.
Hier zijn een paar seconden spraak op de verbinding, uit het fetch-voorbeeld hierboven (translate: ["es"], zinsopbouw uit):
{"type":"session.loading"}
{"type":"session.ready"}
{"type":"transcript.draft","segments":[{"start":0.0,"text":"Good","speaker":0,"tentative":true}]}
{"type":"transcript.draft","segments":[{"start":0.0,"text":"Good morning every","speaker":0,"tentative":true}]}
{"type":"transcript.committed","segment":{"seq":0,"start":0.0,"end":1.4,"text":"Good morning, everyone.","speaker":0,"words":[{"start":0.0,"end":0.3,"text":"Good"},{"start":0.3,"end":0.8,"text":"morning,"},{"start":0.8,"end":1.4,"text":"everyone."}]}}
{"type":"transcript.translated","seq":0,"translations":{"es":"Buenos días a todos."}}
{"type":"transcript.draft","segments":[{"start":1.6,"text":"Thanks for","speaker":0,"tentative":true}]}
Er zitten twee frametypes rond deze. transcript.amended corrigeert een vastgelegd segment ter plekke (zelfde seq, nieuwe text of speaker) wanneer een woord erin is gecorrigeerd; pas het toe op de regel die je al hebt getekend. transcript.words streamt woordtiming voor de staart vlak voordat die wordt vastgelegd. Het heeft events.words nodig, dat standaard aan staat; een vastlegging bevat altijd zijn words, wat de eventopties ook zeggen. Negeer ten slotte frametypes die je niet kent: nieuwe worden zonder kennisgeving toegevoegd.
Hier is de leeslus van de Python-SDK, met de terminal als ondertitelweergave. De zinsopbouw staat ook hier uit, dus vertalingen komen binnen als transcript.translated, en het error-frame komt als een VocalStackError uit de lus naar buiten:
import asyncio
import os
from vocalstack import RealtimeSession, VocalStack, VocalStackError
MIC = ["ffmpeg", "-loglevel", "error", "-f", "avfoundation", "-i", ":0",
"-f", "f32le", "-ac", "1", "-ar", "16000", "-"]
async def main() -> None:
async with VocalStack("{{api}}", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
created = await vs.realtime.create_session(
spoken_language="en", translate=["es"], sentences={"enabled": False}
)
session = await RealtimeSession.connect(created.session_id, created.stream_url)
mic = await asyncio.create_subprocess_exec(*MIC, stdout=asyncio.subprocess.PIPE)
async def feed() -> None:
assert mic.stdout is not None
while chunk := await mic.stdout.read(8000): # 125 ms of audio
await session.send_audio(chunk)
await session.eof() # the server flushes, then sends session.done
feeder = asyncio.create_task(feed())
try:
async with session:
async for event in session:
if event.type == "transcript.draft":
# The live tail: replace it, never append it.
tail = " ".join(s.text for s in event.segments)
print(f"\r\x1b[K\x1b[2m{tail}\x1b[0m", end="", flush=True)
elif event.type == "transcript.committed":
# Final: append it (seq 0, 1, 2...), and the tail it covers is gone.
print(f"\r\x1b[K{event.segment.text}", flush=True)
elif event.type == "transcript.translated":
print(f" es: {event.translations.get('es')}")
await feeder
print(f"done ({session.done_reason})", session.stats)
except VocalStackError as e:
# An `error` frame mid-stream, or a refused connect.
print(f"\nerror {e.code}: {e.message} (retryable: {e.retryable})")
finally:
if mic.returncode is None:
mic.kill()
asyncio.run(main())
Vertaling per segment, zoals hierboven, is de eenvoudige vorm. De vorm die lezers verkiezen krijgt een eigen sectie.
translate: ["es", "fr"] vraagt om vertalingen terwijl de sessie loopt. Hoe ze binnenkomen hangt af van de zinsopbouw (sentences: { enabled: true }), die standaard aan staat.
Met zinnen uit komt na elke vastlegging even later een transcript.translated-frame dat zijn seq noemt en translations bevat, met de taal als sleutel; hang ze aan dat segment. Dat is wat de voorbeelden tot nu toe hebben gebruikt. Het is eenvoudig en leest als een vastleggingslog: één segment, één vertaling, de zinsgrens waar de spreker even ademhaalde.
Met zinnen aan krijgt de server de vastleggingen samengevoegd tot hele zinnen en vertaalt die, wat veel beter leest. Elk transcript.sentence-frame is een volledige vervanging van één zin, gesleuteld op sentenceId. Zijn status gaat van open naar closed (of dropped, wat betekent: weggooien), en zijn translations zijn voorlopig zolang de zin open is en definitief zodra hij sluit. Bijwerken-of-toevoegen op sentenceId, nooit alleen toevoegen. De TypeScript-SDK levert applySentence precies daarvoor:
import { applySentence, type SentenceSegment } from '@vocalstack/sdk';
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}`);
}
Vertalingen volgen het transcript een seconde of twee later. Als je ze ook uitgesproken wilt hebben, voeg voice: ["es"] toe (het heeft zinnen nodig). Het Spaans voor elke gesloten zin komt dan binnen als een transcript.voice-frame, met het fragment als base64 Ogg/Opus op 24 kHz mono. Een lange zin komt in stukken (part van parts) die achter elkaar moeten worden afgespeeld. Voorlopige tekst wordt nooit uitgesproken. De gids voor livevertaling behandelt stemmen in detail, en waar de seconden naartoe gaan legt de keten van vertraging uit.
Doeltalen worden niet vastgelegd bij het aanmaken.
Een session.update-tekstframe wijzigt spokenLanguage, translate, speakerLabels, latencyMode, voice of voiceByLang vanaf dat moment; velden die je weglaat blijven ongewijzigd.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
De SDK's bieden het aan als session.update(…). translate vervangt de hele lijst, en [] zet de vertaling uit. Segmenten die al zijn afgeleverd, worden niet opnieuw getranscribeerd of vertaald, en als je sprekerlabels aanzet, worden sprekers alleen vanaf dat moment gelabeld. Wat gezegd is, blijft gezegd; de instellingen gelden alleen voor wat komt.
Wanneer de audio stopt, stuur je {"type":"eof"} (session.eof() in de SDK's). De server transcribeert wat hij nog vasthoudt, stuurt de laatste vastleggingen, daarna session.done, en sluit. cancel sturen stopt daarentegen meteen en gooit de rest weg.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason is eof, max_duration (de sessie heeft de maximale lengte van het abonnement bereikt en voerde dezelfde afronding uit) of cancelled. stats wordt door de server gemeten: audioS is de audio die je hebt gestuurd, processingS is de wandkloktijd die de sessie liep, en realtimeFactor is hun verhouding. Een live sessie komt net onder 1 uit, omdat de wandkloktijd de laatste afronding na je eof bevat. Een getal dat duidelijk lager ligt, betekent dat de sessie merkbaar langer duurde dan de audio; de eerste plek om te kijken is hoe snel de audio binnenkwam. Bij een bestand zegt hetzelfde getal hoeveel keer sneller dan het afspelen het transcript terugkwam.
Een sessie duurt maximaal limits.maxDurationS uit GET /v1/realtime/capabilities, en de audio ervan verbruikt je tegoed per seconde tegen het livetarief van je abonnement, met dezelfde minimumduur van 5 minuten als elke transcriptie: elke sessie die audio ontvangt, wordt minstens 5 minuten gefactureerd (prijzen). Dezelfde socket neemt ook een heel bestand aan: maak de sessie aan met input: { kind: "file" }, stuur de eigen bytes van het bestand (mp3, wav, flac, ogg, m4a) en eof, en het transcript komt terug vele malen sneller dan de audio speelt, met onderweg transcript.progress-frames.
Er is geen hervatten. Een verbroken verbinding beëindigt de sessie; opnieuw verbinden betekent een nieuwe sessie aanmaken, die een eigen minimum krijgt, en opnieuw streamen vanaf je live bron, met seq weer bij 0. Het opnieuw verbinden zelf is het standaardpatroon voor elke socket-API, en het is de moeite waard het één keer goed te doen:
retryable: true op een error-frame, een 503 met zijn Retry-After, of 30 seconden zonder frame, wat voor capacity_exceeded staat. invalid_token en session_expired betekenen een nieuwe streamUrl van je server, niet nog een poging met de oude.Houd de route die sessies aanmaakt goedkoop om aan te roepen, en behandel een sluiting als een nieuwe start. Geen hervatting is de prijs van niets opslaan; als het transcript de socket moet overleven, bewaar het dan zelf.
Wil je dezelfde stream als opname bewaren, achteraf corrigeren, exporteren en door een publiek in hun eigen talen laten lezen, publiceer hem dan als een livetranscriptie. De livegids loopt dezelfde PCM-socket langs een transcriptie en een uitzendlink, en de luistergids laat de kant van de luisteraar zien, waar elke luisteraar een taal kiest en iemand die later binnenkomt bijkomt vanaf een lastSeq-cursor. Hoe dat eruitziet voor een ruimte, is het onderwerp van één link, elke taal.
Voor een bestand dat je wilt bewaren, is de opgeslagen route een bestand transcriberen met de API. De volledige frame-referentie, elk veld inbegrepen, staat in de gids Een microfoon streamen in de API-referentie.