Prepis v reálnom čase prichádza dvakrát: najprv ako predbežný text, ktorý sa stále mení, potom ako konečný riadok, ktorý sa už nemení. Predbežný text vykreslite ako nahraditeľný koniec a konečný riadok ako históriu; zvyšok zobrazenia živých titulkov je už len infraštruktúra.
Relácia v reálnom čase je najmenšia vec, ktorú API VocalStack robí. Váš server urobí jedno HTTP volanie a dostane podpísané streamUrl. Váš klient otvorí WebSocket na túto URL, posiela surový zvuk podľa toho, ako sa zachytáva, a prepis číta späť ako JSON textové rámce tesne za rečníkom. Keď zvuk skončí, pošlete eof, server dokončí spracovanie, oznámi, čo relácia namerala, a zatvorí spojenie.
Nič sa neukladá. Prepis existuje len na drôte, a to je presne to, čo chce zobrazenie titulkov, prekryv na stretnutí alebo hlasová funkcia vo vašej vlastnej aplikácii. Ak chcete slová uchovať, zdieľať a exportovať až potom, potrebujete namiesto toho prepis naživo a vysielanie; posledná sekcia tam odkazuje.
Tvar je ten, ktorý zdieľa väčšina streamovacích rozhraní pre rozpoznávanie reči: výmena poverení cez HTTP, dlhodobý socket so zvukom idúcim dnu a JSON von a nejaký spôsob, ako odlíšiť predbežný text od konečného. Názvy sa medzi jednotlivými API líšia; pohyblivé časti sa málokedy. Tu sú v poradí:
POST /v1/realtime/sessions s vaším kľúčom API na vašom serveri. Odpoveď je { sessionId, streamUrl, expiresAt }.streamUrl.session.loading, kým nepríde session.ready.transcript.draft (nahraďte koniec) a transcript.committed (pripojte konečný riadok).{"type":"eof"}; čítajte session.done.Iba krok 1 potrebuje váš kľúč, takže sa deje na vašom serveri.
Vytvorte kľúč v sekcii Developers, API keys s oprávnením Write. Kľúč je určený pre servery a skripty: API odmietne kľúč poslaný z webovej stránky alebo v URL, takže reláciu vytvárate za trasou vlastnej a stránka vidí iba streamUrl.
URL nesie vlastný token kvôli obmedzeniu prehliadača, ktoré stojí za to zapamätať si všeobecne. Konštruktor prehliadačového WebSocket nemôže nastaviť hlavičky požiadavky, takže neexistuje hlavička Authorization, do ktorej by sa dal kľúč vložiť; každé rozhranie reči určené pre prehliadač overuje socket krátkodobým tokenom v URL (alebo ho pretlačí do názvu subprotokolu). Krátkodobosť je podstatná: URL sa dostane do logov a histórie, takže to, čo nesie, musí vypršať. Pripojte sa pred expiresAt.
SDK sa sťahujú zo sekcie Developers, nie z registra balíkov; sekcia Inštalácia v rýchlom štarte ukazuje príkazy pre toto prostredie. S TypeScript SDK vyzerá trasa na serveri takto:
// 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 je to jedno fetch. Tento príklad tiež žiada španielčinu a vypína skladanie viet, čo zodpovedá ukážke drôtu nižšie:
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 };
Každá voľba má predvolenú hodnotu. Tie, ktoré stoja za pozornosť hneď na začiatku, sú spokenLanguage (ak ju vynecháte, jazyk sa zistí automaticky), translate (zoznam cieľových jazykov), speakerLabels a latencyMode; zvyšok sa objaví, keď bude potrebný nižšie.
Odovzdajte streamUrl tomu, čo zachytáva zvuk; odtiaľto už nie je potrebný žiadny kľúč.
Pripojte sa na streamUrl tak, ako je, bez hlavičky. Kým sa relácia spúšťa, server posiela správy session.loading. Potom príde session.ready. Pred ním neposielajte žiadny zvuk. Zvuk poslaný predčasne sa neuchováva na neskôr; server to považuje za chybu, pošle rámec error a zatvorí socket.
Keď je socket otvorený, každé zlyhanie má tvar toho rámca: {"type":"error","code":"…","message":"…","retryable":true|false}. code je uzavretý zoznam (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message je určená pre vaše logy a retryable hovorí, či má zmysel skúsiť novú reláciu. Spracujte ho už od prvého rámca:
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;
}
});
Surový klient musí zvládnuť ešte dve veci sám; SDK ich už zvládajú. Po prvé, odmietnutý upgrade nesie obvyklú chybovú obálku HTTP API: 401 invalid_token, 410 session_expired alebo 503 unavailable s hlavičkou Retry-After, ktorú treba rešpektovať.
Po druhé, nasýtenie vyzerá ako ticho, nie ako chyba. Ak do 30 sekúnd nepríde žiadny rámec, berte to ako capacity_exceeded a pripojte sa znova; nová relácia zvyčajne prejde. Ak správy načítania chodia ďalej, ale session.ready nikdy nepríde, berte to ako engine_unavailable, čo je tiež opakovateľné. V SDK RealtimeSession.connect skončí úspešne pri session.ready a po readyDeadlineMs zlyhá so správnym kódom.
Keď príde session.ready, server očakáva zvuk tempom, akým sa hovorí.
Zvuk ide dnu ako binárne rámce surového PCM: 32-bitový float, little-endian, 16 kHz, mono (pcm_f32le, predvolený formát audio relácie). Posielajte ho podľa toho, ako sa zachytáva, po častiach približne 100 až 250 ms, bez čakania na prepis medzi jednotlivými časťami.
Výpočet bajtov stojí za to urobiť raz. 16 000 vzoriek za sekundu po 4 bajtoch je 64 000 bajtov za sekundu, takže 100 ms je 6 400 bajtov a 250 ms je 16 000. Modely reči sa bežne trénujú na 16 kHz mono, a preto to toľko rozhraní v reálnom čase žiada, zatiaľ čo mikrofóny a prehliadače zachytávajú na 44,1 alebo 48 kHz a často v stereo. Niečo musí zvuk prevzorkovať a spojiť kanály do jedného, skôr než sa bajty odošlú: tu je to ffmpeg, v prehliadači je to pomocník SDK pre mikrofón. Poslanie 48 kHz zvuku s deklarovaným 16 kHz nezlyhá; prepis reči bude znieť spomalene a zle.
V Node je ffmpeg najjednoduchšie zachytávacie zariadenie:
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 nikdy neblokuje; zaraďuje do fronty. bufferedAmount je veľkosť tej fronty a na zdravom spojení sa drží takmer na nule. Živý zvuk sa nedá pozastaviť, takže užitočná reakcia na rastúcu frontu je si ju všimnúť, nie zahadzovať rámce. Server uplatňuje spätný tlak tiež. Prijíma zvuk niekoľko sekúnd dopredu oproti skutočnému času (limits.audioBufferS) a potom prestane čítať, kým nedobehne. Smerom späť sa potvrdenia radia do fronty bez straty, kým sa predbežné texty zlučujú, takže pomalší čitateľ vidí menej, ale čerstvejších predbežných textov. Klient, ktorý nestíha ani s potvrdeniami, dostane rámec error s slow_consumer a retryable: false a je odpojený; ak to vidíte, váš obslužný kód správ robí príliš veľa práce.
V prehliadači streamMicrophone z @vocalstack/sdk/mic si vypýta mikrofón, prevzorkuje na 16 kHz a posiela časti spolu s rámcami clock, ktoré serveru umožnia označiť medzery, keď prehliadač spomalí kartu. Stránka dostane reláciu od vašej trasy a kľúč nikdy nevidí:
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, '');
}
}
}
Ten podpis render(committed, tail) je celý vzor používateľského rozhrania; ďalšia sekcia vysvetľuje prečo.
Dva typy rámcov tvoria prepis a ich zamieňanie je najčastejšia chyba.
Najprv prečo sú vôbec dva. Model reči rozhoduje, čo bolo slovo, sčasti podľa slov, ktoré nasledujú: „I scream“ a „ice cream“ znejú rovnako, kým sa veta nedopovie. Preto rozpoznávač v reálnom čase vydá skorý odhad a potom ho revidujte, ako prichádza ďalší zvuk, a ustáli sa až vtedy, keď započul dosť. Každé streamovacie rozhranie pre reč to nejako sprístupňuje. Niektoré tieto odhady nazývajú „interim“ alebo „partial“ výsledky a ustálené označujú is_final; slovník sa líši, disciplína nie. Tu je odhad predbežný text a ustálený text je potvrdenie.
transcript.draft je živý koniec: slová počuté od posledného potvrdenia, stále sa meniace. Každý predbežný text úplne nahrádza predchádzajúci. Koniec prekreslite jeho segments; nikdy ho nepripájajte. Predbežné texty dizajnovo nemajú seq, takže sa nesnažte priradiť ich k potvrdeniam, do ktorých sa premenia.
transcript.committed je konečné: jeden segment s seq (0, 1, 2, bez medzier), start, end, text, speaker a words s časovaním jednotlivých slov. Pripojte ho a zahoďte predbežný text, ktorý pokrýva; ďalší predbežný text začína tam, kde skončilo toto potvrdenie.
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
Používateľské rozhranie, ktoré pripája predbežné texty, ukazuje duplicitný, blikajúci text. Vaše titulky začnú koktať, hoci rečník nekoktá. Vykreslite všetky potvrdené segmenty, potom najnovší predbežný text za nimi, a predbežný text vymažte, keď príde potvrdenie. Potvrdenie môže obsahovať slová, ktoré sa v žiadnom predbežnom texte neobjavili.
Tu je niekoľko sekúnd reči na drôte, z príkladu fetch vyššie (translate: ["es"], skladanie viet vypnuté):
{"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}]}
Okolo týchto rámcov sú ešte dva. transcript.amended opraví potvrdený segment na mieste (rovnaký seq, nový text alebo speaker), keď bolo opravené slovo v ňom; použite ho na riadok, ktorý ste už vykreslili. transcript.words streamuje časovanie slov pre koniec tesne pred jeho potvrdením. Potrebuje events.words, čo je predvolene zapnuté; potvrdenie vždy obsahuje svoje words, nech hovoria možnosti udalostí čokoľvek. Nakoniec ignorujte typy rámcov, ktoré nepoznáte: nové sa pridávajú bez upozornenia.
Tu je čítacia slučka Python SDK, s terminálom ako zobrazením titulkov. Skladanie viet je aj tu vypnuté, takže preklady prichádzajú ako transcript.translated a rámec error sa objaví ako VocalStackError vyhodený zo slučky:
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())
Preklad po segmentoch, ako vyššie, je jednoduchá forma. Forma, ktorú čitatelia uprednostňujú, si zaslúži vlastnú sekciu.
translate: ["es", "fr"] žiada preklady počas behu relácie. Ako prichádzajú, závisí od skladania viet (sentences: { enabled: true }), ktoré je predvolene zapnuté.
S vypnutými vetami každé potvrdenie nasleduje chvíľu po ňom rámec transcript.translated, ktorý uvádza jeho seq a nesie translations kľúčované jazykom; pripojte ich k tomuto segmentu. Takto to používajú doterajšie príklady. Je to jednoduché a číta sa ako záznam potvrdení: jeden segment, jeden preklad, hranica vety tam, kde rečník nabral dych.
S vetami zapnutými server skladá potvrdenia do celých viet a prekladá tie, čo je oveľa lepšie čitateľné. Každý rámec transcript.sentence úplne nahrádza jednu vetu, identifikovanú sentenceId. Jej status prechádza z open na closed (alebo dropped, čo znamená zahodiť ju), a jej translations sú predbežné, kým je veta otvorená, a konečné po jej uzavretí. Aktualizujte podľa sentenceId, nikdy nepripájajte. TypeScript SDK obsahuje applySentence práve pre toto:
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}`);
}
Preklady idú za prepisom o sekundu alebo dve. Ak ich chcete aj nahlas, pridajte voice: ["es"] (vyžaduje vety). Španielčina pre každú uzavretú vetu potom prichádza ako rámec transcript.voice, ktorý nesie klip ako base64 Ogg/Opus pri 24 kHz mono. Dlhá veta príde po častiach (part z parts), aby sa prehrávali za sebou. Predbežný text sa nikdy nehovorí. Sprievodca živým prekladom podrobne pokrýva hlasy a kam miznú sekundy vysvetľuje reťaz oneskorenia.
Cieľové jazyky sa pri vytváraní nepevne určujú.
Textový rámec session.update mení spokenLanguage, translate, speakerLabels, latencyMode, voice alebo voiceByLang od tohto bodu ďalej; polia, ktoré vynecháte, zostávajú nezmenené.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK to sprístupňujú ako session.update(…). translate nahrádza celý zoznam a [] preklad vypne. Už doručené segmenty sa znova neprepisujú ani neprekladajú, a ak zapnete označovanie rečníkov, rečníci sa označia len od tohto bodu. Čo bolo povedané, zostáva povedané; nastavenia riadia iba to, čo príde ďalej.
Keď zvuk skončí, pošlite {"type":"eof"} (session.eof() v SDK). Server prepíše, čo ešte drží, pošle posledné potvrdenia, potom session.done a zatvorí spojenie. Ak namiesto toho pošlete cancel, zastaví okamžite a zvyšok zahodí.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason je eof, max_duration (relácia dosiahla maximálnu dĺžku plánu a prebehlo rovnaké dokončenie) alebo cancelled. stats meria server: audioS je zvuk, ktorý ste poslali, processingS je čas podľa nástenných hodín, počas ktorých relácia bežala, a realtimeFactor je ich pomer. Živá relácia skončí tesne pod 1, pretože čas podľa hodín zahŕňa posledné dokončenie po vašom eof. Číslo výrazne nižšie znamená, že relácia trvala podstatne dlhšie, než trval zvuk; prvé miesto na kontrolu je, ako rýchlo zvuk prichádzal. Pri súbore to isté číslo hovorí, koľkokrát rýchlejšie než prehrávanie sa prepis vrátil.
Relácia trvá najviac limits.maxDurationS z GET /v1/realtime/capabilities a jej zvuk míňa váš kredit za sekundu podľa živej sadzby vášho plánu, s rovnakým 5-minútovým minimom ako pri každom prepise: každá relácia, ktorá dostane zvuk, je účtovaná aspoň 5 minút (cenník). Ten istý socket zvládne aj celý súbor: vytvorte reláciu s input: { kind: "file" }, pošlite vlastné bajty súboru (mp3, wav, flac, ogg, m4a) a eof, a prepis sa vráti mnohonásobne rýchlejšie, než zvuk hrá, s rámcami transcript.progress počas cesty.
Neexistuje obnovenie. Prerušené spojenie reláciu ukončí; opätovné pripojenie znamená vytvoriť novú reláciu, účtovanú s vlastným minimom, a streamovať znova z vášho živého zdroja, s seq znova od 0. Samotné opätovné pripojenie je štandardný vzor pre každé socketové API a stojí za to ho raz správne spraviť:
retryable: true v rámci error, 503 s jeho Retry-After, alebo 30 sekúnd bez rámca, čo znamená capacity_exceeded. invalid_token a session_expired znamenajú nové streamUrl z vášho servera, nie ďalší pokus so starou URL.Zachovajte trasu, ktorá vytvára reláciu, lacnú na volanie, a s odpojením zaobchádzajte ako so začiatkom od nuly. Absencia obnovenia je cena za to, že sa nič neukladá; ak musí prepis prežiť socket, uchovajte ho.
Keď chcete ten istý stream uložiť ako nahrávku, dodatočne opraviť, exportovať a prečítať publikom v ich jazykoch, publikujte ho namiesto toho ako prepis naživo. Sprievodca spustením živého vysielania prejde ten istý socket PCM cez prepis a odkaz na vysielanie a sprievodca počúvaním ukazuje stranu odberateľa, kde si každý poslucháč vyberie jazyk a neskorý pripojený dobehne z kurzora lastSeq. Ako to vyzerá pre miestnosť, rieši jeden odkaz, každý jazyk.
Pre súbor, ktorý chcete uchovať, je uložená trasa prepis súboru cez API. Úplný referenčný prehľad rámcov, každé pole vrátane, je sprievodca Streamovanie mikrofónu v referencii API.