En sanntidstranskripsjon kommer to ganger: først som et utkast som stadig endrer seg, deretter som en endelig linje som ikke gjør det. Vis utkastet som en hale som kan erstattes, og den endelige linjen som historikk, så er resten av en direkte undertekstvisning bare koblingsarbeid.
En sanntidsøkt er det minste VocalStack-API-et gjør. Serveren din foretar ett HTTP-kall og får tilbake en signert streamUrl. Klienten åpner en WebSocket til den adressen, sender rå lyd etter hvert som den fanges opp, og leser transkripsjonen tilbake som JSON-tekstrammer like etter taleren. Når lyden er slutt, sender du eof; serveren tømmer køen, rapporterer hva økten målte, og lukker.
Ingenting lagres. Transkripsjonen finnes bare på ledningen, og det er det en undertekstvisning, et møteoverlegg eller en stemmefunksjon i din egen app vil ha. Om du vil at ordene skal beholdes, deles og eksporteres etterpå, vil du heller ha en direktetranskripsjon og en sending; den siste seksjonen peker dit.
Formen er den de fleste strømmende tale-API-er deler: en utveksling av legitimasjon over HTTP, en langlivet socket der lyd går inn og JSON kommer ut, og en måte å skille foreløpig tekst fra endelig tekst. Navnene varierer fra ett API til et annet; de bevegelige delene er sjelden forskjellige. Her er de i rekkefølge:
POST /v1/realtime/sessions med API-nøkkelen din, på serveren din. Svaret er { sessionId, streamUrl, expiresAt }.streamUrl.session.loading-signalmeldinger til session.ready.transcript.draft (erstatt halen) og transcript.committed (legg til en endelig linje).{"type":"eof"}; les session.done.Bare steg 1 trenger nøkkelen din, så det skjer på serveren din.
Opprett en nøkkel under Developers, API keys med tillatelsen Write. En nøkkel er for servere og skript: API-et avviser en nøkkel som sendes fra en nettside eller i en URL, så du oppretter økten bak en rute du selv har laget, og siden ser bare streamUrl.
URL-en bærer sitt eget token på grunn av en nettleserbegrensning som er verdt å kjenne til generelt. Konstruktøren for WebSocket i nettleseren kan ikke sette HTTP-headere, så det finnes ingen Authorization-header å legge en nøkkel i; alle nettleservendte tale-API-er autentiserer socketen sin med et kortlevd token i URL-en (eller smuglet inn i et underprotokollnavn). Kortlevd er poenget: en URL havner i logger og historikk, så det den bærer, må utløpe. Koble til før expiresAt.
SDK-ene lastes ned fra utviklerområdet, ikke fra et pakkeregister; installasjonsdelen i hurtigstarten viser kommandoene for dette miljøet. Med TypeScript-SDK-en ser en serverrute slik ut:
// 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 };
}
Uten SDK er det ett fetch. Denne ber også om spansk og slår av setningsassemblering, i samsvar med wire-eksemplet lenger ned:
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 };
Alle alternativer har en standardverdi. De som er verdt å kjenne fra starten, er spokenLanguage (utelat den, så oppdages språket), translate (en liste over målspråk), speakerLabels og latencyMode; resten dukker opp når de trengs nedenfor.
Gi streamUrl til det som fanger opp lyden; herfra er ingen nøkkel involvert.
Koble til streamUrl slik den er, uten header. Mens økten starter, sender serveren session.loading-signalmeldinger. Deretter kommer session.ready. Send ingen lyd før da. Lyd som sendes tidlig, lagres ikke til senere; serveren behandler det som en feil, sender en error-ramme og lukker socketen.
Når socketen er åpen, har hver feil formen til denne rammen: {"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 beregnet på loggene dine, og retryable forteller deg om en ny økt er verdt et forsøk. Håndter den fra første ramme av:
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 må håndtere to ting til selv; SDK-ene gjør det allerede. For det første har en avvist oppgradering den vanlige HTTP-feilkonvolutten fra API-et: 401 invalid_token, 410 session_expired eller 503 unavailable med en Retry-After-header å respektere.
For det andre ser metning ut som stillhet, ikke som en feil. Om ingen ramme i det hele tatt kommer innen 30 sekunder, behandle det som capacity_exceeded og koble til igjen; en ny økt slipper vanligvis gjennom. Hvis signalmeldinger fortsetter å komme, men session.ready aldri gjør det, behandle det som engine_unavailable, som også kan forsøkes igjen. I SDK-ene løses RealtimeSession.connect ved session.ready og feiler etter readyDeadlineMs med riktig kode.
Når session.ready kommer, forventer serveren lyd i samme tempo som den snakkes.
Lyd sendes som binære rammer med rå PCM: 32-bit flyttall, little-endian, 16 kHz, mono (pcm_f32le, øktens standard audio-format). Send den etter hvert som den fanges opp, i biter på omtrent 100 til 250 ms, uten å vente på transkripsjonen mellom bitene.
Regnestykket er verdt å gjøre én gang. 16 000 prøver i sekundet à 4 byte er 64 000 byte i sekundet, så 100 ms er 6 400 byte og 250 ms er 16 000. Talemodeller trenes ofte på 16 kHz mono, og det er grunnen til at så mange sanntids-API-er ber om det, mens mikrofoner og nettlesere fanger opp på 44,1 eller 48 kHz og ofte i stereo. Noe må resample lyden og slå sammen kanalene til én før bytene sendes: her er det ffmpeg, og i nettleseren er det SDK-ens mikrofonhjelper. Å sende 48 kHz-lyd og deklarere 16 kHz feiler ikke; det transkriberer tale i sakte film, og dårlig.
I Node er ffmpeg den enkleste lydkilden:
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 blokkerer aldri; den legger i kø. bufferedAmount er størrelsen på den køen, og på en frisk forbindelse ligger den nær null. Direkte lyd kan ikke settes på pause, så den nyttige responsen på en voksende kø er å legge merke til den, ikke å droppe rammer. Serveren bruker også backtrykk. Den aksepterer lyd noen sekunder foran sanntid (limits.audioBufferS), og slutter så å lese til den har tatt igjen. Tilbake fra serveren køes endelige linjer uten tap, mens utkast slås sammen, slik at en treg leser ser færre, ferskere utkast. En klient som ikke klarer å henge med selv på de endelige linjene, får en error-ramme med slow_consumer og retryable: false, og kobles fra; ser du den, gjør meldingsbehandleren din for mye arbeid.
I en nettleser ber streamMicrophone fra @vocalstack/sdk/mic om mikrofonen, resamplerer til 16 kHz og sender bitene sammen med clock-rammer, som lar serveren markere hull når nettleseren strupper fanen. Siden får økten fra ruten din og ser aldri nøkkelen:
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, '');
}
}
}
Den signaturen, render(committed, tail), er hele UI-mønsteret; neste seksjon forklarer hvorfor.
To rammetyper bygger transkripsjonen, og å blande dem sammen er den vanligste feilen.
Først, hvorfor det er to i det hele tatt. En talemodell avgjør delvis hva et ord var, ut fra ordene som kommer etterpå: «I scream» og «ice cream» høres likt ut til setningen fortsetter. Derfor sender en sanntidsgjenkjenner tidlig en gjetning, reviderer den etter hvert som mer lyd kommer, og stabiliserer seg først når den har hørt nok. Alle strømmende tale-API-er eksponerer dette på en eller annen måte. Noen kaller gjetningene «interim»- eller «partial»-resultater og merker de stabile med is_final; ordforrådet varierer, disiplinen gjør ikke det. Her er gjetningen et utkast, og den stabile teksten er en endelig linje.
transcript.draft er den løpende halen: ordene som er hørt siden forrige endelige linje, og som fortsatt endrer seg. Hvert utkast er en full erstatning av det forrige. Tegn halen på nytt med dens segments; legg den aldri til. Utkast har ingen seq med vilje, så ikke prøv å matche dem med de endelige linjene de blir til.
transcript.committed er endelig: ett segment med seq (0, 1, 2, uten hull), start, end, text, speaker og words med tidspunkter per ord. Legg den til og fjern utkastet den dekker; neste utkast starter der denne endelige linjen sluttet.
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
Et UI som legger til utkast, viser duplisert, flimrende tekst. Undertekstvisningen din får et hakk som taleren aldri hadde. Vis alle endelige segmenter, deretter det siste utkastet etter dem, og tøm utkastet når en endelig linje kommer. En endelig linje kan inneholde ord som aldri dukket opp i noe utkast.
Her er noen sekunder med tale på ledningen, fra fetch-eksemplet over (translate: ["es"], setningsassemblering av):
{"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 til ligger rundt disse. transcript.amended korrigerer et endelig segment på plass (samme seq, ny text eller speaker) når et ord i det er rettet; bruk den på linjen du allerede har tegnet. transcript.words strømmer tidspunkter for ord i halen rett før den blir endelig. Den krever events.words, som er på som standard; en endelig linje har alltid med sine words, uansett hva hendelsesinnstillingene sier. Til slutt: ignorer rammetyper du ikke kjenner, for nye legges til uten varsel.
Her er Python-SDK-ens leseløkke, med terminalen som undertekstvisning. Setningsassemblering er slått av her også, så oversettelser kommer som transcript.translated, og error-rammen dukker opp som en VocalStackError som kastes ut av 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())
Oversettelse per segment, slik som over, er den enkle formen. Formen leserne foretrekker, får en seksjon for seg selv.
translate: ["es", "fr"] ber om oversettelser etter hvert som økten går. Hvordan de kommer, avhenger av setningsassemblering (sentences: { enabled: true }), som er på som standard.
Med setninger av kommer hver endelige linje etter kort tid med en transcript.translated-ramme som oppgir dens seq og bærer translations nøklet på språk; fest dem til det segmentet. Det er det eksemplene så langt har brukt. Det er enkelt, og det leser seg som en endringslogg: ett segment, én oversettelse, og setningsgrensen der taleren tok pusten.
Med setninger på setter serveren endelige linjer sammen til hele setninger og oversetter dem, noe som leser seg langt bedre. Hver transcript.sentence-ramme er en full erstatning av én setning, nøklet på sentenceId. Dens status går fra open til closed (eller dropped, som betyr at den skal kastes), og translations er foreløpige mens den er open og endelige når den lukkes. Oppdater etter sentenceId (sett inn hvis den mangler), aldri legg til. TypeScript-SDK-en leverer applySentence nettopp 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}`);
}
Oversettelser følger transkripsjonen et sekund eller to etter. Vil du ha dem talt også, legg til voice: ["es"] (det krever setninger). Den spanske teksten for hver lukket setning kommer da som en transcript.voice-ramme, med klippet som base64 Ogg/Opus på 24 kHz mono. En lang setning kommer i biter (part av parts) som spilles av etter hverandre. Foreløpig tekst leses aldri opp. Veiledningen for direkte oversettelse dekker stemmer i detalj, og hvor sekundene blir av forklarer forsinkelseskjeden.
Målspråk er ikke fastsatt ved opprettelse.
En session.update-tekstramme endrer spokenLanguage, translate, speakerLabels, latencyMode, voice eller voiceByLang fra det punktet og videre; felt du utelater, er uendret.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK-ene eksponerer det som session.update(…). translate erstatter hele listen, og [] slår av oversettelse. Segmenter som allerede er levert, transkriberes eller oversettes ikke på nytt, og om du slår på talermerking, merkes talere bare fra da av. Det som er sagt, er sagt; innstillingene styrer bare det som kommer.
Når lyden er slutt, send {"type":"eof"} (session.eof() i SDK-ene). Serveren transkriberer det den fortsatt holder på, sender de siste endelige linjene, deretter session.done, og lukker. Sender du i stedet cancel, stopper den med én gang og dropper resten.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason er eof, max_duration (økten nådde planens maksimale lengde og kjørte den samme tømmingen) eller cancelled. stats måles av serveren: audioS er lyden du sendte, processingS er veggklokketiden økten kjørte, og realtimeFactor er forholdet mellom dem. En direkteøkt ender like under 1, fordi veggklokketiden inkluderer den siste tømmingen etter eof. Et tall langt under det betyr at økten tok merkbart lengre tid enn lyden varte; det første stedet å se er hvor raskt lyden kom inn. For en fil sier samme tall hvor mange ganger raskere enn avspillingen transkripsjonen kom tilbake.
En økt varer høyst limits.maxDurationS fra GET /v1/realtime/capabilities, og lyden bruker kreditten din per sekund til planens direktesats, med samme minimum på 5 minutter som enhver transkripsjon: hver økt som mottar lyd, faktureres i minst 5 minutter (priser). Den samme socketen tar også en hel fil: opprett økten med input: { kind: "file" }, send filens egne byte (mp3, wav, flac, ogg, m4a) og eof, så kommer transkripsjonen tilbake mange ganger raskere enn lyden spilles av, med transcript.progress-rammer underveis.
Det finnes ingen gjenopptagelse. En brutt forbindelse avslutter økten; å koble til igjen betyr å opprette en ny økt, som faktureres med sitt eget minimum, og å strømme på nytt fra din direkte kilde, med seq tilbake på 0. Selve tilkoblingen er standardmønsteret for alle socket-API-er, og den er verdt å få riktig én gang:
retryable: true på en error-ramme, en 503 med sin Retry-After, eller 30 sekunder uten en ramme, som står for capacity_exceeded. invalid_token og session_expired betyr en ny streamUrl fra serveren din, ikke et nytt forsøk med den gamle.Hold ruten som oppretter økten, billig å kalle, og behandle en lukking som en ny start. Fraværet av gjenopptagelse er prisen for å ikke lagre noe; hvis transkripsjonen må overleve socketen, må du lagre den selv.
Når du vil at den samme strømmen skal lagres som et opptak, rettes etterpå, eksporteres og leses av et publikum på deres egne språk, publiserer du den til en direktetranskripsjon i stedet. Veiledningen for direktesending viser samme PCM-socket gjennom en transkripsjon og en sendingslenke, og lytteveiledningen viser abonnentsiden, der hver lytter velger et språk, og en sen deltaker henter inn det de gikk glipp av fra en lastSeq-markør. Hvordan det ser ut for et rom, er temaet for én lenke, alle språk.
For en fil du vil beholde, er den lagrede ruten transkriber en fil med API-et. Den fullstendige rammereferansen, med hvert felt, er Strøm en mikrofon-veiledningen i API-referansen.