Stvarni transkript stiže dvaput: najprije kao nacrt koji se stalno mijenja, zatim kao konačni redak koji se više ne mijenja. Nacrt prikažite kao zamjenjivi rep, a konačni redak kao povijest; ostatak pregleda titlova uživo je samo spojni kod.
Sesija u stvarnom vremenu najmanja je stvar koju VocalStack API radi. Vaš poslužitelj napravi jedan HTTP poziv i dobije natrag potpisani streamUrl. Klijent otvori WebSocket na taj URL, šalje sirovi zvuk čim se snimi i čita transkript natrag kao JSON tekstualne okvire, nekoliko trenutaka iza govornika. Kad zvuk završi, pošaljete eof; poslužitelj dovrši preostalu obradu, javi izmjerene vrijednosti sesije i zatvori vezu.
Ništa se ne pohranjuje. Transkript postoji samo u prijenosu, što je upravo ono što treba pregledu titlova, preklopu na sastanku ili glasovnoj značajci u vlastitoj aplikaciji. Ako želite da se riječi spreme, dijele i izvoze naknadno, trebate transkripciju uživo i prijenos; posljednji odjeljak upućuje na to.
Oblik je isti kod većine API-ja za streaming govor: razmjena vjerodajnica preko HTTP-a, dugotrajan socket s audiom koji ide unutra i JSON koji izlazi van, te neki način da se privremeni tekst razlikuje od konačnog. Nazivi se od API-ja do API-ja razlikuju; pokretni dijelovi gotovo nikad. Evo ih redom:
POST /v1/realtime/sessions s vašim API ključem, na vašem poslužitelju. Odgovor je { sessionId, streamUrl, expiresAt }.streamUrl.session.loading otkucaje dok ne stigne session.ready.transcript.draft (zamijenite rep) i transcript.committed (dodajte konačni redak).{"type":"eof"}; pročitajte session.done.Samo korak 1 treba vaš ključ, pa se odvija na vašem poslužitelju.
Napravite ključ pod Developers, API keys s dozvolom Write. Ključ je namijenjen poslužiteljima i skriptama: API odbija ključ poslan s web stranice ili u URL-u, pa sesiju stvarate iza vlastite rute, a stranica vidi samo streamUrl.
URL nosi vlastiti token zbog ograničenja preglednika koje je vrijedno znati općenito. Konstruktor WebSocketa u pregledniku ne može postaviti zaglavlja zahtjeva, pa nema zaglavlja Authorization u koje bi se stavio ključ; svaki API za govor koji se koristi u pregledniku ovjerava socket kratkotrajnim tokenom u URL-u (ili ubačenim u naziv podprotokola). Kratkotrajnost je bitna: URL završava u logovima i povijesti, pa ono što nosi mora isteći. Povežite se prije expiresAt.
SDK-ovi se preuzimaju iz područja Developers, a ne iz registra paketa; odjeljak Install u vodiču za brzi početak 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 dovoljan je jedan fetch. Ovaj također traži španjolski i isključuje sastavljanje rečenica, kao u primjeru žičnog prometa dalje u tekstu:
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 zadanu vrijednost. One koje vrijedi znati na početku jesu spokenLanguage (ako ga izostavite, jezik se detektira), translate (popis ciljnih jezika), speakerLabels i latencyMode; ostale se uvode kako budu potrebne u nastavku.
Predajte streamUrl onome što hvata zvuk; od ovdje nadalje ne sudjeluje nikakav ključ.
Povežite se na streamUrl kakav jest, bez zaglavlja. Dok se sesija pokreće, poslužitelj šalje session.loading otkucaje. Zatim dolazi session.ready. Prije njega ne šaljite zvuk. Zvuk poslan prerano ne čuva se za kasnije; poslužitelj to tretira kao grešku, šalje okvir error i zatvara socket.
Kad je socket otvoren, svaka greška ima oblik tog okvira: {"type":"error","code":"…","message":"…","retryable":true|false}. code je zatvoren popis (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message je za vaše logove, a retryable kaže isplati li se pokušati s novom sesijom. Obradite to od prvog okvira nadalje:
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 s zaglavljem Retry-After koje treba poštovati.
Drugo, zasićenost izgleda kao tišina, a ne kao greška. Ako ni jedan okvir ne stigne u roku od 30 sekundi, tretirajte to kao capacity_exceeded i ponovno se povežite; nova sesija obično prođe. Ako i dalje stižu loading okviri, ali session.ready nikad ne dolazi, tretirajte to kao engine_unavailable, što je također moguće ponoviti. U SDK-ovima se RealtimeSession.connect vraća na session.ready i nakon readyDeadlineMs javlja odgovarajući kod.
Kad stigne session.ready, poslužitelj očekuje zvuk u tempu kojim se govori.
Zvuk ide u binarnim okvirima sirovog PCM-a: 32-bitni float, little-endian, 16 kHz, mono (pcm_f32le, zadani format audio sesije). Šaljite ga čim se snimi, u dijelovima od otprilike 100 do 250 ms, bez čekanja transkripta između dijelova.
Vrijedi jednom izračunati bajtove. 16,000 uzoraka u sekundi po 4 bajta daje 64,000 bajtova u sekundi, pa je 100 ms 6,400 bajtova, a 250 ms je 16,000. Modeli za govor obično se treniraju na 16 kHz mono, zbog čega toliko API-ja u stvarnom vremenu traži upravo to, dok mikrofoni i preglednici snimaju na 44.1 ili 48 kHz, često u stereu. Nešto mora ponovno uzorkovati zvuk i spojiti kanale u jedan prije nego što bajtovi krenu: ovdje je to ffmpeg, a u pregledniku to radi mikrofonski pomoćnik SDK-a. Slanje 48 kHz zvuka uz deklariranje 16 kHz ne javlja grešku; transkribira govor u usporenom snimanju, i to loše.
U Node.js-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 nikad ne blokira; stavlja u red čekanja. bufferedAmount je veličina tog reda, a na zdravoj vezi se drži blizu nule. Zvuk uživo ne može se pauzirati, pa je koristan odgovor na rastući red to primijetiti, a ne ispuštati okvire. I poslužitelj primjenjuje povratni pritisak. Prihvaća zvuk nekoliko sekundi unaprijed u odnosu na stvarno vrijeme (limits.audioBufferS), a zatim prestaje čitati dok ne sustigne. Na povratnom putu potvrde se stavljaju u red bez gubitka, dok se nacrti spajaju, pa spor čitač vidi manje, ali svježijih nacrta. Klijent koji ni uz potvrde ne može držati korak dobiva okvir error s slow_consumer i retryable: false te se odspaja; ako to vidite, vaš rukovatelj porukama radi previše posla.
U pregledniku streamMicrophone iz @vocalstack/sdk/mic traži mikrofon, ponovno uzorkuje na 16 kHz i šalje dijelove zajedno s clock okvirima, koji poslužitelju omogućuju da označi praznine kad preglednik uspori karticu. Stranica dobiva sesiju s vaše rute i nikad 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) cijeli je obrazac sučelja; sljedeći odjeljak objašnjava zašto.
Dva tipa okvira grade transkript, a njihovo miješanje najčešća je greška.
Najprije, zašto uopće postoje dva. Model za govor odlučuje što je neka riječ bila dijelom i prema riječima koje slijede: „I scream” i „ice cream” zvuče isto dok se rečenica ne nastavi. Zato realtime prepoznavač najprije emitira pogodak, pa ga revidira kako stiže više zvuka, i tek kad čuje dovoljno, to se učvrsti. Svaki API za streaming govor to nekako izlaže. Neki pogotke zovu „interim” ili „partial” rezultatima i učvršćene označavaju s is_final; nazivlje se razlikuje, disciplina ne. Ovdje je pogodak nacrt, a učvršćeni tekst potvrda.
transcript.draft je živi rep: riječi čute od posljednje potvrde, koje se još mijenjaju. Svaki nacrt potpuno zamjenjuje prethodni. Ponovno iscrtajte rep iz njegovih segments; nikad ga ne dodajte. Nacrti namjerno ne nose seq, pa ne pokušavajte ih uskladiti s potvrdama u koje postaju.
transcript.committed je konačan: jedan segment s seq (0, 1, 2, bez praznina), start, end, text, speaker i words s vremenima po riječi. Dodajte ga i odbacite nacrt koji pokriva; sljedeći nacrt počinje tamo gdje je ova potvrda završila.
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
UI koji dodaje nacrte prikazuje treperav i udvostručen tekst. Vaš pregled titlova dobije mucanje koje govornik nikad nije imao. Prikažite sve potvrđene segmente, pa zadnji nacrt nakon njih, i obrišite nacrt kad stigne potvrda. Potvrda može sadržavati riječi koje se ni u jednom nacrtu nisu pojavile.
Evo nekoliko sekundi govora na žici, 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 okvira stoje još dva. transcript.amended ispravlja potvrđeni segment na mjestu (isti seq, novi text ili speaker) kad je neka riječ u njemu ispravljena; primijenite ga na redak koji ste već iscrtali. transcript.words šalje vremena riječi za rep neposredno prije potvrde. Treba events.words, što je uključeno prema zadanim postavkama; potvrda uvijek ugrađuje svoje words, bez obzira na postavke događaja. Na kraju, ignorirajte tipove okvira koje ne poznajete: novi se dodaju bez najave.
Evo petlje za čitanje iz Python SDK-a, s terminalom kao pregledom titlova. I ovdje je sastavljanje rečenica isključeno, pa prijevodi stižu kao transcript.translated, a okvir error izlazi iz petlje kao VocalStackError:
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 čitatelji preferiraju dobiva vlastiti odjeljak.
translate: ["es", "fr"] traži prijevode dok sesija teče. Kako stižu, ovisi o sastavljanju rečenica (sentences: { enabled: true }), koje je uključeno prema zadanim postavkama.
Uz isključene rečenice, svakoj potvrdi malo nakon toga slijedi okvir transcript.translated koji navodi njezin seq i nosi translations ključane po jeziku; vežite ih uz taj segment. Tako su primjeri do sada i radili. Jednostavno je i čita se kao dnevnik potvrda: jedan segment, jedan prijevod, granica rečenice tamo gdje je govornik udahnuo.
Uz uključene rečenice, poslužitelj sastavlja potvrde u cijele rečenice i prevodi njih, što se čita znatno bolje. Svaki okvir transcript.sentence potpuno zamjenjuje jednu rečenicu, identificiranu s sentenceId. Njezin status ide od open do closed (ili dropped, što znači odbaciti je), a translations su privremeni dok je rečenica otvorena i konačni kad se zatvori. Ažurirajte prema sentenceId, nikad ne dodajte. TypeScript SDK sadrži applySentence upravo za ovo:
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 za sekundu ili dvije. Ako ih želite i izgovorene, dodajte voice: ["es"] (treba rečenice). Prijevod na španjolski za svaku zatvorenu rečenicu tada stiže kao okvir transcript.voice, a nosi isječak kao base64 Ogg/Opus na 24 kHz mono. Duga rečenica dolazi u dijelovima (part od parts) koje treba reproducirati jedan za drugim. Privremeni tekst nikad se ne izgovara. Vodič za prijevod uživo detaljno pokriva glasove, a kamo odlaze sekunde objašnjava lanac kašnjenja.
Ciljni jezici nisu zadani pri stvaranju.
Tekstualni okvir session.update mijenja spokenLanguage, translate, speakerLabels, latencyMode, voice ili voiceByLang od te točke nadalje; polja koja izostavite ostaju nepromijenjena.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK-ovi to izlažu kao session.update(…). translate zamjenjuje cijeli popis, a [] isključuje prijevod. Već isporučeni segmenti ne transkribiraju se ni prevode ponovno, a ako uključite oznake govornika, govornici se označavaju samo od tog trenutka. Što je rečeno, ostaje rečeno; postavke upravljaju samo onim što slijedi.
Kad zvuk završi, pošaljite {"type":"eof"} (session.eof() u SDK-ovima). Poslužitelj transkribira što još drži, šalje posljednje potvrde, zatim session.done i zatvara vezu. Slanje cancel umjesto toga zaustavlja odmah 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 dosegnula maksimalnu duljinu paketa i prošla istu završnu obradu) ili cancelled. stats mjeri poslužitelj: audioS je zvuk koji ste poslali, processingS je stvarno proteklo vrijeme sesije, a realtimeFactor je njihov omjer. Živa sesija završi malo ispod 1, jer stvarno vrijeme uključuje završno ispisivanje nakon vašeg eof. Znatno niža vrijednost znači da je sesija trajala primjetno dulje od trajanja zvuka; prvo mjesto za provjeru je koliko brzo je zvuk stizao. Za datoteku isti broj pokazuje koliko je puta transkript stigao brže od reprodukcije.
Sesija traje najviše limits.maxDurationS iz GET /v1/realtime/capabilities, a njezin zvuk troši vaš kredit po sekundi po živoj stopi vašeg paketa, uz istu minimalnu naplatu 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: stvorite sesiju s input: { kind: "file" }, pošaljite vlastite bajtove datoteke (mp3, wav, flac, ogg, m4a) i eof, a transkript se vraća mnogo puta brže od reprodukcije zvuka, uz okvire transcript.progress usput.
Nema nastavljanja. Prekinuta veza završava sesiju; ponovno povezivanje znači stvaranje nove sesije, koja se naplaćuje vlastitim minimumom, i ponovni streaming iz vašeg živog izvora, s seq opet od 0. Ponovno povezivanje standardni je obrazac za svaki socket API i vrijedi ga ispravno napraviti jednom:
retryable: true na okviru error, 503 s Retry-After, ili 30 sekundi bez okvira, što znači capacity_exceeded. invalid_token i session_expired znače novi streamUrl s vašeg poslužitelja, a ne još jedan pokušaj sa starim.Ruta koja stvara sesiju neka bude jeftina za poziv, a zatvaranje tretirajte kao novi početak. Nepostojanje nastavljanja cijena je za to da se ništa ne pohranjuje; ako transkript mora preživjeti socket, sami ga spremite.
Kad želite istu snimku spremljenu kao zapis, naknadno ispravljenu, izvezenu i čitanu od publike na njihovim jezicima, objavite je kao transkripciju uživo. Vodič za prijenos uživo prolazi kroz isti PCM socket preko transkripcije i poveznice za prijenos, a vodič za slušanje prikazuje stranu pretplatnika, gdje svaki slušatelj bira jezik, a onaj tko kasno uđe sustiže iz kursora lastSeq. O tome kako to izgleda za jednu prostoriju piše jedna poveznica, svaki jezik.
Za datoteku koju želite zadržati, trajni put je transkribirajte datoteku putem API-ja. Puni referentni popis okvira, sa svakim poljem, nalazi se u vodiču Stream a microphone u API referenci.