O transcriere în timp real sosește de două ori: întâi ca o ciornă care se tot schimbă, apoi ca un rând final care nu se mai schimbă. Desenează ciorna ca pe o coadă care poate fi înlocuită și rândul final ca istoric, iar restul unei vizualizări de subtitrări live este doar conectare.
O sesiune în timp real este cel mai mic lucru pe care îl face API-ul VocalStack. Serverul tău face un singur apel HTTP și primește înapoi un streamUrl semnat. Clientul tău deschide un WebSocket către acel URL, trimite audio brut pe măsură ce este captat și citește transcrierea înapoi, sub formă de cadre text JSON, cu câteva clipe în urma vorbitorului. Când audio se termină, trimiți eof; serverul golește ce a rămas, raportează ce a măsurat sesiunea și închide conexiunea.
Nu se stochează nimic. Transcrierea există doar în timpul transmisiei, ceea ce au nevoie o vizualizare de subtitrări, un overlay pentru întâlniri sau o funcție vocală din propria ta aplicație. Dacă vrei ca cuvintele să fie păstrate, partajate și exportate ulterior, ai nevoie de o transcriere live și de o transmisiune; ultima secțiune indică încotro.
Forma este cea pe care o au majoritatea API-urilor de vorbire în streaming: un schimb de credențiale prin HTTP, un socket cu durată lungă, în care intră audio și iese JSON, și o modalitate de a deosebi textul provizoriu de cel final. Denumirile diferă de la un API la altul; piesele în mișcare rareori. Iată-le, în ordine:
POST /v1/realtime/sessions cu cheia ta API, pe serverul tău. Răspunsul este { sessionId, streamUrl, expiresAt }.streamUrl.session.loading până la session.ready.transcript.draft (înlocuiește coada) și transcript.committed (adaugă un rând final).{"type":"eof"}; citește session.done.Doar pasul 1 are nevoie de cheia ta, așa că se întâmplă pe serverul tău.
Creează o cheie în Developers, API keys cu permisiunea Write. Cheia este pentru servere și scripturi: API-ul refuză o cheie trimisă dintr-o pagină web sau dintr-un URL, așa că sesiunea o creezi în spatele unei rute proprii, iar pagina vede doar streamUrl.
URL-ul poartă propriul token din cauza unei limitări a browserului, care merită cunoscută în general. Constructorul WebSocket din browser nu poate seta anteturi de cerere, deci nu există un antet Authorization în care să pui o cheie; orice API de vorbire destinat browserului își autentifică socketul cu un token de scurtă durată în URL (sau strecurat într-un nume de subprotocol). Scurta durată este esențială: un URL ajunge în jurnale și în istoric, deci ceea ce conține trebuie să expire. Conectează-te înainte de expiresAt.
SDK-urile se descarcă din zona Developers, nu dintr-un registru de pachete; secțiunea Install din ghidul de pornire rapidă arată comenzile specifice acestui mediu. Cu SDK-ul TypeScript, o rută de server arată așa:
// 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 };
}
Fără SDK, este un singur fetch. Acesta cere și spaniola și dezactivează asamblarea propozițiilor, pentru a se potrivi cu exemplul de transmisie de mai jos:
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 };
Fiecare opțiune are o valoare implicită. Cele pe care merită să le știi de la început sunt spokenLanguage (dacă o lași deoparte, limba este detectată), translate (o listă de limbi țintă), speakerLabels și latencyMode; restul apar pe măsură ce sunt necesare mai jos.
Predă streamUrl oricărui lucru care captează audio; de aici încolo, nicio cheie nu mai intră în joc.
Conectează-te la streamUrl așa cum este, fără antet. Cât timp pornește sesiunea, serverul trimite pulsuri session.loading. Apoi vine session.ready. Nu trimite audio înainte. Audio trimis devreme nu este păstrat pentru mai târziu; serverul îl tratează ca pe o eroare, trimite un cadru error și închide socketul.
Odată ce socketul este deschis, orice eșec ia forma acelui cadru: {"type":"error","code":"…","message":"…","retryable":true|false}. code este o listă închisă (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message este pentru jurnalele tale, iar retryable îți spune dacă merită încercată o sesiune nouă. Tratează-l din primul cadru:
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;
}
});
Un client brut trebuie să se ocupe singur de două lucruri în plus; SDK-urile le fac deja. În primul rând, o actualizare refuzată poartă plicul obișnuit de eroare HTTP al API-ului: 401 invalid_token, 410 session_expired sau 503 unavailable cu antetul Retry-After, care trebuie respectat.
În al doilea rând, saturarea arată ca tăcere, nu ca o eroare. Dacă în 30 de secunde nu sosește niciun cadru, tratează-l drept capacity_exceeded și conectează-te din nou; o sesiune nouă trece de obicei. Dacă pulsurile session.loading continuă să sosească, dar session.ready nu sosește niciodată, tratează-l drept engine_unavailable, care de asemenea poate fi reîncercată. În SDK-uri, RealtimeSession.connect se încheie cu succes la session.ready și eșuează după readyDeadlineMs, cu codul potrivit.
Odată ce sosește session.ready, serverul așteaptă audio în ritmul în care este rostit.
Audio intră sub formă de cadre binare PCM brut: float pe 32 de biți, little-endian, 16 kHz, mono (pcm_f32le, formatul implicit audio al sesiunii). Trimite-l pe măsură ce este captat, în bucăți de circa 100–250 ms, fără să aștepți transcrierea între bucăți.
Calculul cu octeți merită făcut o dată. 16.000 de eșantioane pe secundă, a câte 4 octeți fiecare, înseamnă 64.000 de octeți pe secundă, deci 100 ms înseamnă 6.400 de octeți, iar 250 ms înseamnă 16.000. Modelele de vorbire sunt de obicei antrenate pe mono la 16 kHz, de aceea atât de multe API-uri de timp real o cer, în timp ce microfoanele și browserele captează la 44,1 sau 48 kHz și adesea stereo. Ceva trebuie să reeșantioneze audio și să transforme canalele într-unul singur înainte ca octeții să plece: aici este ffmpeg, iar în browser este utilitarul de microfon al SDK-ului. Trimiterea audio la 48 kHz declarând 16 kHz nu eșuează; transcrie vorbirea ca în slow-motion, prost.
În Node, ffmpeg este cel mai simplu dispozitiv de captare:
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 nu blochează niciodată; pune în coadă. bufferedAmount este dimensiunea acelei cozi, iar pe o legătură sănătoasă rămâne aproape de zero. Audio live nu poate fi pus pe pauză, deci răspunsul util la o coadă care crește este să o observi, nu să renunți la cadre. Serverul aplică și el contrapresiune. Acceptă audio cu câteva secunde înainte de timpul real (limits.audioBufferS), apoi nu mai citește până ce ajunge din urmă. În sens invers, confirmările sunt puse în coadă fără pierderi, iar ciornele sunt comasate, astfel încât un cititor lent vede mai puține ciorne, dar mai proaspete. Un client care nu ține pasul nici cu confirmările primește un cadru error cu slow_consumer și retryable: false și este deconectat; dacă îl vezi, handlerul tău de mesaje face prea multă muncă.
Într-un browser, streamMicrophone din @vocalstack/sdk/mic cere acces la microfon, reeșantionează la 16 kHz și trimite bucățile împreună cu cadrele clock, care îi permit serverului să marcheze golurile atunci când browserul îngrădește fila. Pagina primește sesiunea de la ruta ta și nu vede niciodată cheia:
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, '');
}
}
}
Semnătura render(committed, tail) este întregul model de interfață; secțiunea următoare explică de ce.
Două tipuri de cadre construiesc transcrierea, iar confuzia dintre ele este cea mai frecventă greșeală.
Mai întâi, de ce există două deloc. Un model de vorbire decide ce a fost un cuvânt parțial după cuvintele care urmează: „I scream” și „ice cream” (strig și înghețată) sună identic până când propoziția continuă. Așadar, un recunoscător în timp real emite o presupunere devreme, apoi o revizuiește pe măsură ce sosește mai mult audio și se stabilizează abia când a auzit suficient. Orice API de vorbire în streaming expune asta cumva. Unele numesc presupunerile rezultate „interimare” sau „parțiale” și marchează cele stabilizate cu is_final; vocabularul variază, disciplina nu. Aici presupunerea este o ciornă, iar textul stabilizat este o confirmare.
transcript.draft este coada live: cuvintele auzite de la ultima confirmare, care încă se schimbă. Fiecare ciornă înlocuiește integral pe cea anterioară. Redesenează coada cu segments; nu o adăuga niciodată. Ciornele nu au seq prin design, deci nu încerca să le potrivești cu confirmările în care devin.
transcript.committed este final: un segment cu seq (0, 1, 2, fără goluri), start, end, text, speaker și words, cu timpii fiecărui cuvânt. Adaugă-l și renunță la ciorna pe care o acoperă; următoarea ciornă începe unde s-a încheiat această confirmare.
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
O interfață care adaugă ciorne afișează text duplicat, care pâlpâie. Vizualizarea ta de subtitrări capătă o bâlbâială pe care vorbitorul nu a avut-o niciodată. Redă toate segmentele confirmate, apoi ultima ciornă după ele, și golește ciorna când sosește o confirmare. O confirmare poate conține cuvinte care nu au apărut în nicio ciornă.
Iată câteva secunde de vorbire pe fir, din exemplul fetch de mai sus (translate: ["es"], asamblarea propozițiilor dezactivată):
{"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}]}
În jurul acestora mai stau două cadre. transcript.amended corectează pe loc un segment confirmat (același seq, text sau speaker nou) atunci când un cuvânt din el a fost corectat; aplică-l pe rândul pe care l-ai desenat deja. transcript.words transmite timpii cuvintelor pentru coada chiar înainte de a fi confirmată. Are nevoie de events.words, care este activat implicit; o confirmare include întotdeauna words, oricare ar fi opțiunile de evenimente. În final, ignoră tipurile de cadre pe care nu le cunoști: cele noi sunt adăugate fără preaviz.
Iată bucla de citire a SDK-ului Python, cu terminalul drept vizualizare de subtitrări. Asamblarea propozițiilor este dezactivată și aici, deci traducerile sosesc ca transcript.translated, iar cadrul error apare ca o VocalStackError ridicată din buclă:
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())
Traducerea pe segmente, ca mai sus, este forma simplă. Forma pe care o preferă cititorii primește o secțiune proprie.
translate: ["es", "fr"] cere traduceri pe parcursul sesiunii. Modul în care sosesc depinde de asamblarea propozițiilor (sentences: { enabled: true }), care este activată implicit.
Cu propozițiile dezactivate, fiecare confirmare este urmată puțin mai târziu de un cadru transcript.translated care numește al său seq și poartă translations, indexate după limbă; atașează-le segmentului respectiv. Asta au folosit până acum exemplele. Este simplu și se citește ca un jurnal de confirmări: un segment, o traducere, limita de propoziție oriunde a respirat vorbitorul.
Cu propozițiile activate, serverul asamblează confirmările în propoziții întregi și le traduce pe acestea, ceea ce se citește mult mai bine. Fiecare cadru transcript.sentence înlocuiește integral o propoziție, identificată prin sentenceId. Starea sa trece de la open la closed (sau dropped, ceea ce înseamnă să o arunci), iar translations sunt provizorii cât timp este deschisă și finale odată ce se închide. Actualizează prin sentenceId, nu adăuga niciodată. SDK-ul TypeScript include applySentence tocmai pentru asta:
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}`);
}
Traducerile urmează transcrierea cu una-două secunde. Dacă vrei să fie și rostite, adaugă voice: ["es"] (necesită propoziții). Spaniola fiecărei propoziții închise sosește apoi ca un cadru transcript.voice, care poartă clipul ca Ogg/Opus în base64, la 24 kHz mono. O propoziție lungă vine în bucăți (part din parts), care se redau una după alta. Textul provizoriu nu este rostit niciodată. Ghidul de traducere live tratează vocile în detaliu, iar unde se duc secundele explică lanțul de întârziere.
Limbile țintă nu sunt fixate la creare.
Un cadru text session.update modifică spokenLanguage, translate, speakerLabels, latencyMode, voice sau voiceByLang de la acel punct încolo; câmpurile pe care le lași deoparte rămân neschimbate.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK-urile îl expun ca session.update(…). translate înlocuiește întreaga listă, iar [] oprește traducerea. Segmentele deja livrate nu sunt retranscrise sau retraduse, iar dacă activezi etichetele de vorbitor, vorbitorii sunt etichetați doar de la acel punct. Ce s-a spus rămâne spus; setările guvernează doar ce urmează.
Când audio se termină, trimite {"type":"eof"} (session.eof() în SDK-uri). Serverul transcrie ce mai are, trimite ultimele confirmări, apoi session.done și închide. Trimiterea cancel oprește imediat și aruncă restul.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason este eof, max_duration (sesiunea a atins lungimea maximă a planului și a rulat aceeași golire) sau cancelled. stats este măsurat de server: audioS este audio-ul pe care l-ai trimis, processingS este timpul real în care a rulat sesiunea, iar realtimeFactor este raportul dintre ele. O sesiune live ajunge puțin sub 1, pentru că timpul real include golirea finală de după eof. O cifră mult mai mică înseamnă că sesiunea a durat semnificativ mai mult decât a durat audio-ul; primul loc de verificat este cât de repede a sosit audio-ul. Pentru un fișier, aceeași cifră arată de câte ori mai repede decât redarea a revenit transcrierea.
O sesiune durează cel mult limits.maxDurationS din GET /v1/realtime/capabilities, iar audio-ul ei consumă creditul pe secundă, la tariful live al planului tău, cu același minim de 5 minute ca orice transcriere: fiecare sesiune care primește audio se facturează cel puțin 5 minute (prețuri). Același socket primește și un fișier întreg: creezi sesiunea cu input: { kind: "file" }, trimiți octeții fișierului (mp3, wav, flac, ogg, m4a) și eof, iar transcrierea revine de multe ori mai repede decât se redă audio-ul, cu cadre transcript.progress pe parcurs.
Nu există reluare. O conexiune căzută încheie sesiunea; reconectarea înseamnă crearea uneia noi, facturată cu propriul minim, și retransmiterea de la sursa ta live, cu seq din nou de la 0. Reconectarea în sine este modelul standard pentru orice API cu socket și merită făcută corect o dată:
retryable: true pe un cadru error, un 503 cu Retry-After sau 30 de secunde fără niciun cadru, care ține loc de capacity_exceeded. invalid_token și session_expired înseamnă un streamUrl nou de la serverul tău, nu o altă încercare cu cel vechi.Păstrează ruta care creează sesiuni ieftină de apelat și tratează o închidere ca pe un nou început. Lipsa reluării este prețul pentru a nu stoca nimic; dacă transcrierea trebuie să supraviețuiască socketului, păstreaz-o.
Când vrei ca același flux să fie stocat ca înregistrare, corectat ulterior, exportat și citit de un public în propriile limbi, publică-l în schimb ca transcriere live. Ghidul pentru transmis live parcurge același socket PCM printr-o transcriere și un link de transmisiune, iar ghidul de ascultare arată partea abonatului, unde fiecare ascultător alege o limbă, iar un participant întârziat recuperează de la un cursor lastSeq. Cum arată asta pentru o sală este subiectul un singur link, toate limbile.
Pentru un fișier pe care vrei să-l păstrezi, ruta stocată este transcrierea unui fișier cu API-ul. Referința completă a cadrelor, cu fiecare câmp, este ghidul Transmiterea audio de la microfon din referința API.