En realtidstranskription kommer to gange: først som et udkast, der hele tiden ændrer sig, og derefter som en endelig linje, der ikke gør. Vis udkastet som en udskiftelig halerække og den endelige linje som historik; resten af en live-undertekstvisning er rørarbejde.
En realtidssession er det mindste, VocalStack API'et gør. Din server foretager ét HTTP-kald og får et signeret streamUrl tilbage. Din klient åbner en WebSocket til den URL, sender rå lyd, efterhånden som den optages, og læser transskriptionen tilbage som JSON-tekstrammer få øjeblikke efter taleren. Når lyden slutter, sender du eof; serveren tømmer bufferen, rapporterer hvad sessionen målte, og lukker.
Intet gemmes. Transskriptionen findes kun på forbindelsen, og det er det, en undertekstvisning, et mødeoverlay eller en stemmefunktion i din egen app har brug for. Hvis du vil have ordene gemt, delt og eksporteret bagefter, har du i stedet brug for en live-transskription og en udsendelse; det sidste afsnit peger derhen.
Formen er den, de fleste streaming-tale-API'er deler: en udveksling af legitimationsoplysninger over HTTP, en langvarig socket med lyd ind og JSON ud, og en måde at skelne foreløbig tekst fra endelig tekst. Navnene varierer fra et API til det næste; de bevægelige dele gør sjældent. Her, i rækkefølge:
POST /v1/realtime/sessions med din API-nøgle, på din server. Svaret er { sessionId, streamUrl, expiresAt }.streamUrl.session.loading-heartbeats, indtil session.ready.transcript.draft (erstat halen) og transcript.committed (tilføj en endelig linje).{"type":"eof"}; læs session.done.Kun trin 1 kræver din nøgle, så det sker på din server.
Opret en nøgle under Developers, API keys med Write-tilladelsen. En nøgle er til servere og scripts: API'et afviser en nøgle, der sendes fra en webside eller i en URL, så du opretter sessionen bag en rute af din egen, og siden ser kun streamUrl.
URL'en bærer sin egen token på grund af en browserbegrænsning, der er værd at kende generelt. Browserens WebSocket-konstruktør kan ikke sætte request-headers, så der er ingen Authorization-header at putte en nøgle i; alle browservendte tale-API'er godkender deres socket med en kortlivet token i URL'en (eller smuglet ind i et subprotokolnavn). Kortlivet er pointen: en URL ender i logfiler og historik, så det, den bærer, skal udløbe. Opret forbindelse før expiresAt.
SDK'erne downloades fra Developers-området, ikke fra et pakkeregister; Install-afsnittet i hurtigstarten viser dette miljøs egne kommandoer. Med TypeScript-SDK'et ser en serverrute sådan ud:
// 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 };
}
Uden en SDK er det ét fetch-kald. Dette beder også om spansk og slår sætningssammensætning fra, så det passer til wire-eksemplet længere nede:
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 };
Hver indstilling har en standardværdi. De vigtigste at kende fra starten er spokenLanguage (udelad den, og sproget registreres automatisk), translate (en liste over målsprog), speakerLabels og latencyMode; resten dukker op, når der er brug for dem nedenfor.
Giv streamUrl til det, der optager lyden; herfra er ingen nøgle involveret.
Forbind til streamUrl som den er, uden header. Mens sessionen starter, sender serveren session.loading-heartbeats. Derefter kommer session.ready. Send ingen lyd før da. Lyd, der sendes for tidligt, gemmes ikke til senere; serveren behandler det som en fejl, sender en error-ramme og lukker socket'en.
Når socket'en er åben, har hver fejl formen af den ramme: {"type":"error","code":"…","message":"…","retryable":true|false}. code er en fast liste (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message er til dine logs, og retryable fortæller dig, om en ny session er værd at prøve. Håndter den fra den første ramme:
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;
}
});
En rå klient skal selv håndtere to ting mere; SDK'erne gør det allerede. For det første bærer en afvist opgradering API'ets sædvanlige HTTP-fejlenvelope: 401 invalid_token, 410 session_expired eller 503 unavailable med en Retry-After-header, som du skal overholde.
For det andet ligner overbelastning stilhed, ikke en fejl. Hvis der ikke kommer en eneste ramme inden for 30 sekunder, skal du behandle det som capacity_exceeded og forbinde igen; en ny session slipper typisk igennem. Hvis indlæsningsrammer bliver ved med at komme, men session.ready aldrig gør, skal du behandle det som engine_unavailable, som også kan prøves igen. I SDK'erne løser RealtimeSession.connect ved session.ready og fejler efter readyDeadlineMs med den rigtige kode.
Når session.ready er kommet, forventer serveren lyd i det tempo, den tales i.
Lyd sendes som binære rammer af rå PCM: 32-bit float, little-endian, 16 kHz, mono (pcm_f32le, sessionens standardformat audio). Send den, efterhånden som den optages, i bidder på cirka 100 til 250 ms, uden at vente på transskriptionen mellem bidderne.
Det er værd at regne byte-matematikken igennem én gang. 16,000 samples i sekundet med 4 byte hver er 64,000 byte i sekundet, så 100 ms er 6,400 byte, og 250 ms er 16,000. Talemodeller er typisk trænet på 16 kHz mono, og derfor beder så mange realtids-API'er om det, mens mikrofoner og browsere optager med 44.1 eller 48 kHz og ofte i stereo. Noget skal resample lyden og fælde kanalerne sammen til én, før bytes sendes: her er det ffmpeg, og i browseren er det SDK'ens mikrofonhjælper. At sende 48 kHz lyd og erklære 16 kHz fejler ikke; det transskriberer tale i slowmotion, og det dårligt.
I Node er ffmpeg den enkleste optageenhed:
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 blokerer aldrig; den sætter i kø. bufferedAmount er størrelsen på den kø, og på en sund forbindelse ligger den tæt på nul. Live-lyd kan ikke sættes på pause, så den nyttige reaktion på en voksende kø er at bemærke den, ikke at droppe rammer. Serveren anvender også backpressure. Den accepterer lyd nogle få sekunder før realtid (limits.audioBufferS) og stopper derefter med at læse, indtil den har indhentet det. På vejen tilbage sættes commits i kø uden tab, mens udkast sammenlægges, så en langsom læser ser færre og friskere udkast. En klient, der ikke kan følge med selv med commits, modtager en error-ramme med slow_consumer og retryable: false og afbrydes; ser du den, laver din beskedhåndtering for meget arbejde.
I browseren beder streamMicrophone fra @vocalstack/sdk/mic om mikrofonen, resampler til 16 kHz og sender bidderne sammen med clock-rammer, som gør det muligt for serveren at markere huller, når browseren begrænser fanen. Siden får sessionen fra din rute og ser aldrig nøglen:
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, '');
}
}
}
Signaturen render(committed, tail) er hele UI-mønsteret; det næste afsnit forklarer hvorfor.
To rammetyper bygger transskriptionen, og at forveksle dem er den hyppigste fejl.
Først hvorfor der overhovedet er to. En talemodel afgør delvist, hvad et ord var, ud fra de ord, der følger efter. »Ret« kan være en ret mad eller »ret« som i »ret godt«: lyden er den samme, indtil sætningen fortsætter. Så en realtidsgenkender udsender et gæt tidligt, reviderer det, efterhånden som mere lyd kommer, og først stabiliserer sig, når den har hørt nok. Alle streaming-tale-API'er eksponerer dette på en eller anden måde. Nogle kalder gætterne »interim«- eller »partial«-resultater og markerer de stabiliserede med is_final; ordforrådet varierer, disciplinen gør ikke. Her er gættet et udkast, og den stabiliserede tekst er en commit.
transcript.draft er live-halen: de ord, der er hørt siden den sidste commit, og som stadig ændrer sig. Hvert udkast er en fuld erstatning af det forrige. Tegn halen forfra med dens segments; tilføj den aldrig. Udkast har med vilje ingen seq, så forsøg ikke at matche dem med de commits, de bliver til.
transcript.committed er endelig: ét segment med seq (0, 1, 2, uden huller), start, end, text, speaker og words med ordtidspunkter. Tilføj det, og fjern udkastet, det dækker; det næste udkast starter, hvor denne commit sluttede.
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
En UI, der tilføjer udkast, viser duplikeret, flimrende tekst. Din undertekstvisning får et hak, som taleren aldrig havde. Vis alle endelige segmenter, derefter det seneste udkast efter dem, og ryd udkastet, når en commit kommer. En commit kan indeholde ord, der aldrig optrådte i noget udkast.
Her er et par sekunder tale på forbindelsen, fra fetch-eksemplet ovenfor (translate: ["es"], sætningssammensætning slået fra):
{"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}]}
To rammer mere ligger omkring disse. transcript.amended retter et committet segment på stedet (samme seq, ny text eller speaker), når et ord i det er blevet rettet; anvend den på linjen, du allerede har tegnet. transcript.words streamer ordtidspunkter for halen lige før den committes. Den kræver events.words, som er slået til som standard; en commit indeholder altid sine words, uanset hvad eventindstillingerne siger. Til sidst skal du ignorere rammetyper, du ikke kender: nye tilføjes uden varsel.
Her er Python-SDK'ens læseløkke, med terminalen som undertekstvisning. Sætningssammensætning er også slået fra her, så oversættelser kommer som transcript.translated, og error-rammen dukker op som en VocalStackError, der kastes ud af løkken:
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())
Oversættelse pr. segment, som ovenfor, er den enkle form. Den form, læserne foretrækker, har et afsnit for sig selv.
translate: ["es", "fr"] beder om oversættelser, efterhånden som sessionen kører. Hvordan de kommer, afhænger af sætningssammensætning (sentences: { enabled: true }), som er slået til som standard.
Med sætninger slået fra kommer hver commit efterfulgt lidt senere af en transcript.translated-ramme, der navngiver dens seq og bærer translations nøglet på sprog; hæft dem på det segment. Det er det, eksemplerne indtil nu har brugt. Det er enkelt og ligner et commit-log: ét segment, én oversættelse, og sætningsgrænsen står, hvor taleren trak vejret.
Med sætninger slået til sammensætter serveren commits til hele sætninger og oversætter dem, hvilket læses langt bedre. Hver transcript.sentence-ramme er en fuld erstatning af én sætning, nøglet på sentenceId. Dens status går fra open til closed (eller dropped, som betyder kassér den), og dens translations er foreløbige, mens den er open, og endelige, når den lukkes. Opdater efter sentenceId, tilføj aldrig. TypeScript-SDK'et leverer applySentence netop til dette:
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}`);
}
Oversættelser følger transskriptionen et sekund eller to efter. Hvis du også vil have dem talt op, tilføj voice: ["es"] (det kræver sætninger). Den spanske version af hver lukket sætning kommer så som en transcript.voice-ramme, der bærer klippet som base64 Ogg/Opus ved 24 kHz mono. En lang sætning kommer i stykker (part af parts), der afspilles lige efter hinanden. Foreløbig tekst bliver aldrig talt op. Guiden til live-oversættelse dækker stemmer i detaljer, og hvor sekunderne bliver af forklarer forsinkelseskæden.
Målsprogene er ikke faste, når sessionen oprettes.
En session.update-tekstramme ændrer spokenLanguage, translate, speakerLabels, latencyMode, voice eller voiceByLang fra det tidspunkt og frem; felter, du udelader, er uændrede.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK'erne eksponerer den som session.update(…). translate erstatter hele listen, og [] slår oversættelse fra. Segmenter, der allerede er leveret, transskriberes eller oversættes ikke igen, og hvis du slår talerlabels til, mærkes talere kun fra det tidspunkt. Det, der er sagt, er sagt; indstillingerne styrer kun det, der kommer bagefter.
Når lyden slutter, sender du {"type":"eof"} (session.eof() i SDK'erne). Serveren transskriberer det, den stadig har, sender de sidste commits, derefter session.done, og lukker. At sende cancel i stedet stopper straks og dropper resten.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason er eof, max_duration (sessionen nåede planens maksimale længde og kørte den samme tømning) eller cancelled. stats måles af serveren: audioS er den lyd, du sendte, processingS er den vægurstid, sessionen kørte, og realtimeFactor er forholdet mellem dem. En live-session ender lige under 1, fordi vægurstiden inkluderer den endelige tømning efter din eof. Et tal langt under det betyder, at sessionen tog mærkbart længere tid, end lyden varede; det første sted at kigge er, hvor hurtigt lyden kom ind. For en fil siger det samme tal, hvor mange gange hurtigere end afspilning transskriptionen kom tilbage.
En session varer højst limits.maxDurationS fra GET /v1/realtime/capabilities, og dens lyd bruger din kredit pr. sekund til planens live-takst, med det samme minimum på 5 minutter som enhver transskription: hver session, der modtager lyd, faktureres mindst 5 minutter (priser). Den samme socket tager også en hel fil: opret sessionen med input: { kind: "file" }, send filens egne bytes (mp3, wav, flac, ogg, m4a) og eof, og transskriptionen kommer tilbage mange gange hurtigere, end lyden spilles, med transcript.progress-rammer undervejs.
Der er ingen genoptagelse. En afbrudt forbindelse afslutter sessionen; genforbindelse betyder at oprette en ny session, som faktureres med sit eget minimum, og at streame igen fra din live-kilde, med seq tilbage ved 0. Selve genforbindelsen er det standardmønster, enhver socket-API har, og den er værd at få rigtigt én gang:
retryable: true på en error-ramme, en 503 med dens Retry-After, eller 30 sekunder uden en ramme, hvilket står for capacity_exceeded. invalid_token og session_expired betyder en ny streamUrl fra din server, ikke endnu et forsøg med den gamle.Gør ruten, der opretter sessionen, billig at kalde, og behandl en lukning som en frisk start. Fraværet af genoptagelse er prisen for ikke at gemme noget; hvis transskriptionen skal overleve socket'en, skal du selv gemme den.
Når du vil have den samme strøm gemt som en optagelse, rettet bagefter, eksporteret og læst af et publikum på deres eget sprog, så udgiv den som en live-transskription i stedet. Guiden om at gå live gennemgår den samme PCM-socket via en transskription og et udsendelseslink, og guiden for lyttere viser lytterens side, hvor hver lytter vælger et sprog, og en sen tilslutning henter det tabte op fra en lastSeq-markør. Hvordan det ser ud for et rum, er emnet for ét link, alle sprog.
Til en fil, du vil beholde, er den gemte rute at transskribere en fil med API'et. Den fulde reference for rammer, med alle felter, er guiden Stream en mikrofon i API-referencen.