Transkript u realnom vremenu stiže dvaput: prvo kao nacrt koji se stalno mijenja, a zatim kao konačni red koji ostaje. Nacrt prikažite kao zamjenjiv rep, a konačni red kao historiju; sve ostalo u prikazu titlova uživo je tehnička pozadina.
Sesija u realnom vremenu je najmanja stvar koju VocalStack API radi. Vaš server šalje jedan HTTP zahtjev i dobija potpisani streamUrl. Vaš klijent otvara WebSocket na tu adresu, šalje sirov zvuk čim se snimi i čita transkript nazad kao JSON tekstualne okvire, nekoliko trenutaka iza govornika. Kada se zvuk završi, šaljete eof, server isprazni podatke, prijavi što je sesija izmjerila i zatvara vezu.
Ništa se ne pohranjuje. Transkript postoji samo u prijenosu, a to je ono što treba prikazu titlova, preklapanju za sastanke ili glasovnoj funkciji u vašoj aplikaciji. Ako želite da se riječi sačuvaju, dijele i kasnije izvezu, potrebni su vam transkripcija uživo i prijenos; posljednji odjeljak upućuje na to.
Oblik je isti kod većine streaming API-ja za govor: razmjena vjerodajnica preko HTTP-a, dugotrajan socket kroz koji zvuk ulazi, a JSON izlazi, i neki način da se privremeni tekst razlikuje od konačnog. Nazivi se razlikuju od jednog API-ja do drugog; pokretni dijelovi rijetko. Evo ih, redom:
POST /v1/realtime/sessions s vašim API ključem, na vašem serveru. Odgovor je { sessionId, streamUrl, expiresAt }.streamUrl.session.loading otkucaje srca dok ne stigne session.ready.transcript.draft (zamijenite rep) i transcript.committed (dodajte konačan red).{"type":"eof"}; pročitajte session.done.Samo korak 1 treba vaš ključ, zato se izvršava na vašem serveru.
Kreirajte ključ pod Developers, API keys s dozvolom Write. Ključ je namijenjen serverima i skriptama: API odbija ključ poslan sa web stranice ili u URL-u, pa sesiju kreirate iza vlastite rute, a stranica nikada ne vidi ništa osim streamUrl.
URL nosi vlastiti token zbog ograničenja preglednika koje je korisno znati uopće. Konstruktor preglednika WebSocket ne može postaviti zaglavlja zahtjeva, pa nema zaglavlja Authorization u koje bi se stavio ključ; svaki API za govor namijenjen pregledniku autentifikuje svoj socket kratkotrajnim tokenom u URL-u (ili ga krije u nazivu podprotokola). Kratkotrajnost je poenta: URL završava u logovima i historiji, pa ono što nosi mora isteći. Povežite se prije expiresAt.
SDK-ovi se preuzimaju iz odjeljka Developers, a ne iz registra paketa; odjeljak Install u vodiču za brzi start prikazuje naredbe za ovo okruženje. S TypeScript SDK-om serverska ruta izgleda ovako:
// 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 };
}
Bez SDK-a to je jedan fetch. Ovaj poziv također traži španski i isključuje sastavljanje rečenica, kao i uzorak prijenosa niže:
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 };
Svaka opcija ima podrazumijevanu vrijednost. Na početku vrijedi znati spokenLanguage (ako ga izostavite, jezik se detektuje), translate (lista ciljnih jezika), speakerLabels i latencyMode; ostale se pojavljuju kako budu potrebne u nastavku.
Predajte streamUrl onome što snima zvuk; od ovog trenutka ključ više nije uključen.
Povežite se na streamUrl kakav jeste, bez zaglavlja. Dok se sesija pokreće, server šalje session.loading otkucaje srca. Zatim dolazi session.ready. Prije njega ne šaljite zvuk. Zvuk poslan ranije se ne čuva za kasnije; server to tretira kao grešku, šalje okvir error i zatvara socket.
Kada je socket otvoren, svaka greška ima oblik tog okvira: {"type":"error","code":"…","message":"…","retryable":true|false}. code je zatvorena lista (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message je namijenjen vašim logovima, a retryable vam kaže da li vrijedi pokušati s novom sesijom. Obradite ga od prvog okvira:
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;
}
});
Sirovi klijent mora sam obraditi još dvije stvari; SDK-ovi to već rade. Prvo, odbijeno nadograđivanje nosi uobičajeni HTTP omotač greške API-ja: 401 invalid_token, 410 session_expired ili 503 unavailable sa zaglavljem Retry-After koje treba poštovati.
Drugo, zasićenost izgleda kao tišina, a ne kao greška. Ako nijedan okvir ne stigne u roku od 30 sekundi, tretirajte to kao capacity_exceeded i ponovo se povežite; nova sesija obično prođe. Ako okviri za učitavanje stižu, ali session.ready nikada ne stigne, tretirajte to kao engine_unavailable, što je također moguće ponoviti. U SDK-ovima se RealtimeSession.connect završava na session.ready, a nakon readyDeadlineMs neuspijeva s odgovarajućim kodom.
Kada stigne session.ready, server očekuje zvuk tempom kojim se govori.
Zvuk ulazi kao binarni okviri sirovog PCM-a: 32-bitni float, little-endian, 16 kHz, mono (pcm_f32le, podrazumijevani format audio sesije). Šaljite ga onako kako se snima, u blokovima od oko 100 do 250 ms, bez čekanja transkripta između blokova.
Računica s bajtovima vrijedi jednom uraditi. 16,000 uzoraka u sekundi po 4 bajta je 64,000 bajtova u sekundi, pa je 100 ms 6,400 bajtova, a 250 ms je 16,000. Modeli za govor se najčešće treniraju na 16 kHz mono, zbog čega toliko API-ja u realnom vremenu to traži, dok mikrofoni i preglednici snimaju na 44.1 ili 48 kHz i često u stereo. Nešto mora da preuzorkuje zvuk i spoji kanale u jedan prije nego što bajtovi krenu: ovdje je to ffmpeg, a u pregledniku je to pomoćnik SDK-a za mikrofon. Slanje zvuka od 48 kHz uz deklarisanih 16 kHz ne javlja grešku; transkribuje govor kao u usporenom snimku, loše.
U Node-u je ffmpeg najjednostavniji uređaj za snimanje:
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 nikada ne blokira; stavlja u red. bufferedAmount je veličina tog reda i na zdravoj vezi se drži blizu nule. Zvuk uživo ne može se pauzirati, pa je korisna reakcija na rastući red da to primijetite, a ne da ispuštate okvire. Server također primjenjuje povratni pritisak. Prihvata zvuk nekoliko sekundi unaprijed u odnosu na stvarno vrijeme (limits.audioBufferS), zatim prestaje čitati dok ne sustigne. Na povratnom putu, potvrde se stavljaju u red bez gubitka, dok se nacrti spajaju, pa spor čitalac vidi manje, ali svježijih nacrta. Klijent koji ne može pratiti čak ni potvrde dobija okvir error sa slow_consumer i retryable: false i biva isključen; ako ga vidite, vaš handler poruka radi previše posla.
U pregledniku, streamMicrophone iz @vocalstack/sdk/mic traži mikrofon, preuzorkuje na 16 kHz i šalje blokove zajedno s okvirima clock, koji serveru omogućavaju da označi praznine kada pregledač uspori karticu. Stranica dobija sesiju od vaše rute i nikada ne vidi ključ:
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, '');
}
}
}
Taj potpis render(committed, tail) je cijeli obrazac korisničkog sučelja; sljedeći odjeljak objašnjava zašto.
Dva tipa okvira grade transkript, a najčešća greška je da ih se pomiješa.
Prvo, zašto uopće postoje dva. Model za govor dio odluke o tome šta je riječ donosi na osnovu riječi koje slijede: „I scream“ i „ice cream“ zvuče isto dok rečenica ne ode dalje. Zato prepoznavač u realnom vremenu rano daje pretpostavku, zatim je revidira kako stiže više zvuka i tek se smiri kada je čuo dovoljno. Svaki streaming API za govor to na neki način izlaže. Neki pretpostavke nazivaju „privremenim“ ili „parcijalnim“ rezultatima, a završene označavaju sa is_final; nazivi variraju, disciplina ne. Ovdje je pretpostavka nacrt, a ustaljeni tekst potvrda.
transcript.draft je živi rep: riječi čute od posljednje potvrde, koje se još mijenjaju. Svaki nacrt u potpunosti zamjenjuje prethodni. Iscrtajte rep ponovo s njegovim segments; nikada ga ne dodajte. Nacrti po dizajnu ne nose seq, pa ne pokušavajte da ih uskladite s potvrdama u koje postaju.
transcript.committed je konačan: jedan segment sa seq (0, 1, 2, bez praznina), start, end, text, speaker i words s vremenima za svaku riječ. Dodajte ga i odbacite nacrt koji pokriva; sljedeći nacrt počinje tamo gdje se završila ova potvrda.
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
Sučelje koje dodaje nacrte prikazuje dupliran, treperav tekst. Vaš prikaz titlova dobija štucanje koje govornik nikada nije imao. Iscrtajte sve potvrđene segmente, zatim najnoviji nacrt iza njih, i očistite nacrt kada stigne potvrda. Potvrda može sadržavati riječi koje se nisu pojavile ni u jednom nacrtu.
Evo nekoliko sekundi govora na prijenosu, iz fetch primjera iznad (translate: ["es"], sastavljanje rečenica isključeno):
{"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}]}
Oko ovih stoje još dva okvira. transcript.amended ispravlja potvrđeni segment na mjestu (isti seq, novi text ili speaker) kada je neka riječ u njemu ispravljena; primijenite ga na red koji ste već iscrtali. transcript.words šalje vremena riječi za rep neposredno prije potvrde. Treba mu events.words, što je uključeno podrazumijevano; potvrda uvijek sadrži svoje words, bez obzira na opcije događaja. Na kraju, zanemarite tipove okvira koje ne poznajete: novi se dodaju bez najave.
Evo petlje za čitanje Python SDK-a, s terminalom kao prikazom titlova. I ovdje je sastavljanje rečenica isključeno, pa prijevodi stižu kao transcript.translated, a okvir error se pojavljuje kao VocalStackError izbačen iz petlje:
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())
Prijevod po segmentu, kao gore, jednostavan je oblik. Oblik koji čitaoci više vole dobija vlastiti odjeljak.
translate: ["es", "fr"] traži prijevode dok sesija teče. Kako stižu, zavisi od sastavljanja rečenica (sentences: { enabled: true }), koje je podrazumijevano uključeno.
Kada su rečenice isključene, svakoj potvrdi malo kasnije slijedi okvir transcript.translated koji navodi njen seq i nosi translations s jezicima kao ključevima; pridružite ih tom segmentu. To su koristili dosadašnji primjeri. Jednostavno je i čita se kao dnevnik potvrda: jedan segment, jedan prijevod, granica rečenice tamo gdje je govornik udahnuo.
Kada su rečenice uključene, server sastavlja potvrde u cijele rečenice i prevodi njih, što se čita mnogo bolje. Svaki okvir transcript.sentence u potpunosti zamjenjuje jednu rečenicu, a ključ mu je sentenceId. Njegov status ide od open do closed (ili dropped, što znači odbaciti je), a njegovi translations su privremeni dok je rečenica otvorena i konačni kada se zatvori. Radite upsert po sentenceId, nikada ne dodajte. TypeScript SDK za upravo to isporučuje applySentence:
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}`);
}
Prijevodi slijede transkript sekund ili dva kasnije. Ako želite da se i izgovore, dodajte voice: ["es"] (potrebne su rečenice). Španski za svaku zatvorenu rečenicu tada stiže kao okvir transcript.voice, koji nosi isječak kao base64 Ogg/Opus na 24 kHz mono. Duga rečenica stiže u dijelovima (part od parts) za reprodukciju jedan za drugim. Privremeni tekst se nikada ne izgovara. Vodič za prijevod uživo detaljno obrađuje glasove, a kuda idu sekunde objašnjava lanac kašnjenja.
Ciljni jezici se ne zadaju pri kreiranju.
Tekstualni okvir session.update mijenja spokenLanguage, translate, speakerLabels, latencyMode, voice ili voiceByLang od tog trenutka; polja koja izostavite ostaju nepromijenjena.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK-ovi to izlažu kao session.update(…). translate zamjenjuje cijelu listu, a [] isključuje prijevod. Segmenti koji su već isporučeni se ne transkribuju niti prevode ponovo, a ako uključite oznake govornika, govornici se označavaju samo od tog trenutka. Ono što je rečeno ostaje rečeno; postavke upravljaju samo onim što slijedi.
Kada se zvuk završi, pošaljite {"type":"eof"} (session.eof() u SDK-ovima). Server transkribuje ono što još drži, šalje posljednje potvrde, zatim session.done i zatvara vezu. Slanje cancel umjesto toga odmah zaustavlja i odbacuje ostatak.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason je eof, max_duration (sesija je dosegla najveću dužinu paketa i izvršila isto završno ispražnjavanje) ili cancelled. stats mjeri server: audioS je zvuk koji ste poslali, processingS je stvarno vrijeme koje je sesija trajala, a realtimeFactor je njihov odnos. Sesija uživo završi malo ispod 1, jer stvarno vrijeme uključuje završno ispražnjavanje nakon vašeg eof. Broj znatno niži od toga znači da je sesija trajala primjetno duže od trajanja zvuka; prvo mjesto za provjeru je koliko je brzo zvuk stizao. Za datoteku isti broj pokazuje koliko je puta brže od reprodukcije transkript vraćen.
Sesija traje najviše limits.maxDurationS iz GET /v1/realtime/capabilities, a njen zvuk troši vaš kredit po sekundi po živoj stopi vašeg paketa, uz isti minimum od 5 minuta kao i svaka transkripcija: svaka sesija koja primi zvuk naplaćuje se najmanje 5 minuta (cijene). Isti socket prima i cijelu datoteku: kreirajte sesiju s input: { kind: "file" }, pošaljite bajtove same datoteke (mp3, wav, flac, ogg, m4a) i eof, a transkript se vraća mnogo puta brže od trajanja zvuka, uz okvire transcript.progress usput.
Nema nastavljanja. Prekinuta veza završava sesiju; ponovno povezivanje znači kreirati novu, koja se naplaćuje vlastitim minimumom, i ponovo slati tok iz vašeg izvora uživo, s seq opet od 0. Samo ponovno povezivanje je standardan obrazac za svaki socket API i vrijedi ga jednom dobro uraditi:
retryable: true na okviru error, 503 sa svojim Retry-After, ili 30 sekundi bez okvira, što znači capacity_exceeded. invalid_token i session_expired znače novi streamUrl sa vašeg servera, a ne još jedan pokušaj sa starim.Neka ruta za kreiranje sesije bude jeftina za pozivanje i tretirajte zatvaranje kao novi početak. Nemanje nastavljanja je cijena neskladištenja; ako transkript mora nadživjeti socket, sačuvajte ga.
Kada želite da se isti tok sačuva kao snimak, naknadno ispravi, izveze i čita publika na svojim jezicima, objavite ga kao transkripciju uživo. Vodič za prijenos uživo prolazi kroz isti PCM socket preko transkripcije i linka za prijenos, a vodič za slušanje pokazuje stranu pretplatnika, gdje svaki slušalac bira jezik, a kasni pridruženi nadoknađuje preko kursora lastSeq. Kako to izgleda za jednu prostoriju tema je članka jedan link, svaki jezik.
Za datoteku koju želite sačuvati, pohranjena ruta je transkripcija datoteke putem API-ja. Potpuna referenca okvira, sa svakim poljem, je vodič Prijenos mikrofona u API referenci.