Přepis v reálném čase přichází dvakrát: nejprve jako koncept, který se stále mění, a potom jako finální řádek, který se už nemění. Koncept vykreslete jako nahraditelný konec textu a finální řádek jako historii a zbytek zobrazení živých titulků je už jen technické zapojení.
Relace v reálném čase je nejmenší věc, kterou API VocalStack dělá. Váš server provede jedno volání HTTP a dostane podepsané streamUrl. Klient otevře WebSocket na tuto adresu, posílá surový zvuk, jak je zachycován, a přepis čte zpět jako textové rámce JSON jen okamžik za mluvčím. Když zvuk skončí, pošlete eof; server dokončí zpracování, nahlásí, co relace změřila, a spojení ukončí.
Nic se neukládá. Přepis existuje jen na drátě, a právě to chce zobrazení titulků, překryv schůzky nebo hlasová funkce ve vaší vlastní aplikaci. Pokud chcete slova dodatečně uchovat, sdílet a exportovat, potřebujete místo toho živý přepis a vysílání; poslední oddíl na to odkazuje.
Tvar je ten, který sdílí většina streamovacích API pro rozpoznávání řeči: výměna pověření přes HTTP, dlouhotrvající socket, do něhož jde zvuk a z něhož vychází JSON, a nějaký způsob, jak odlišit předběžný text od finálního. Názvy se u jednotlivých API liší; pohyblivé části se téměř nemění. Zde v pořadí:
POST /v1/realtime/sessions s vaším klíčem API na vašem serveru. Odpověď je { sessionId, streamUrl, expiresAt }.streamUrl.session.loading, dokud nepřijde session.ready.transcript.draft (nahraďte konec textu) a transcript.committed (připojte finální řádek).{"type":"eof"}; přečtěte session.done.Jen krok 1 potřebuje váš klíč, takže probíhá na vašem serveru.
Vytvořte klíč v sekci Developers, API keys s oprávněním Write. Klíč je určen pro servery a skripty: API odmítne klíč odeslaný z webové stránky nebo v URL, takže relaci vytvoříte za vlastní trasou na serveru a stránka vidí jen streamUrl.
URL nese vlastní token kvůli omezení prohlížeče, které stojí za to znát obecně. Konstruktor WebSocket v prohlížeči nedokáže nastavit hlavičky požadavku, takže do něj nelze vložit hlavičku Authorization s klíčem; každé rozhraní řeči určené pro prohlížeč proto socket ověřuje krátkodobým tokenem v URL (nebo vloženým do názvu subprotokolu). Krátkodobost je podstatou: URL skončí v logách a v historii, takže to, co nese, musí vypršet. Připojte se před expiresAt.
Sady SDK se stahují z oblasti Developers, ne z balíčkového registru; sekce Install v rychlém startu ukazuje příkazy pro toto prostředí. S TypeScript SDK vypadá trasa na serveru 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 stačí jeden fetch. Tento také žádá španělštinu a vypíná sestavování vět, aby odpovídal ukázce drátu níž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 };
Každá volba má výchozí hodnotu. Ty, na které stojí za to myslet hned na začátku, jsou spokenLanguage (když ji vynecháte, jazyk se detekuje), translate (seznam cílových jazyků), speakerLabels a latencyMode; ostatní se objeví, až budou potřeba níže.
streamUrl předejte tomu, co zachycuje zvuk; od této chvíle už žádný klíč nevstupuje do hry.
Připojte se na streamUrl tak, jak je, bez hlavičky. Zatímco se relace spouští, server posílá heartbeaty session.loading. Potom přijde session.ready. Před ním neposílejte žádný zvuk. Zvuk poslaný předčasně se neukládá pro později; server to bere jako chybu, pošle rámec error a socket uzavře.
Jakmile je socket otevřený, každé selhání má tvar tohoto rámce: {"type":"error","code":"…","message":"…","retryable":true|false}. code je uzavřený seznam (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message je určena pro vaše logy a retryable říká, zda stojí za to zkusit novou relaci. Ošetřete to už od prvního rámce:
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í řešit ještě dvě věci sám; sady SDK je řeší za vás. Za prvé, odmítnutý upgrade nese obvyklou chybovou obálku HTTP API: 401 invalid_token, 410 session_expired nebo 503 unavailable s hlavičkou Retry-After, kterou je třeba respektovat.
Za druhé, přetížení vypadá jako ticho, ne jako chyba. Pokud do 30 sekund nepřijde žádný rámec, berte to jako capacity_exceeded a připojte se znovu; nová relace obvykle projde. Pokud rámce loading chodí, ale session.ready nikdy nepřijde, berte to jako engine_unavailable, což je také opakovatelné. V SDK se RealtimeSession.connect vrátí při session.ready a po readyDeadlineMs selže se správným kódem.
Jakmile přijde session.ready, server očekává zvuk tempem, jakým se mluví.
Zvuk jde jako binární rámce surového PCM: 32bitová čísla v plovoucí řádové čárce, little-endian, 16 kHz, mono (pcm_f32le, výchozí formát audio relace). Posílejte jej, jak je zachycován, po částech zhruba 100 až 250 ms, aniž byste mezi částmi čekali na přepis.
Výpočet bajtů stojí za to udělat jednou. 16 000 vzorků za sekundu po 4 bajtech je 64 000 bajtů za sekundu, takže 100 ms je 6 400 bajtů a 250 ms je 16 000. Modely řeči se běžně trénují na 16 kHz mono, a proto si tak mnoho API v reálném čase žádá právě tento formát, zatímco mikrofony a prohlížeče zachycují na 44,1 nebo 48 kHz a často ve stereu. Něco musí zvuk přepočítat na jinou vzorkovací frekvenci a sloučit kanály do jednoho, než odejdou bajty: zde je to ffmpeg, v prohlížeči pomocník mikrofonu v SDK. Odeslání 48kHz zvuku při deklaraci 16 kHz nespadne; přepisuje řeč jako zpomalený záznam, a to špatně.
V Node je ffmpeg nejjednodušší zdroj zachytávání:
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 nečeká; řadí do fronty. bufferedAmount je velikost té fronty a na zdravém spojení se drží blízko nuly. Živý zvuk nejde pozastavit, takže užitečnou reakcí na rostoucí frontu je ji všimnout si, ne zahazovat rámce. Server uplatňuje zpětný tlak také. Přijímá zvuk o několik sekund napřed před reálným časem (limits.audioBufferS) a potom přestane číst, dokud nedožene. Na zpáteční cestě se potvrzení řadí bez ztráty, zatímco koncepty se slučují, takže pomalý čtenář vidí méně, ale čerstvějších konceptů. Klient, který nestíhá ani potvrzení, dostane rámec error s slow_consumer a retryable: false a je odpojen; pokud to vidíte, váš handler zpráv dělá příliš mnoho práce.
V prohlížeči se streamMicrophone z @vocalstack/sdk/mic zeptá na mikrofon, převzorkuje na 16 kHz a odešle části spolu s rámci clock, které serveru umožní označit mezery, když prohlížeč omezí kartu. Stránka dostane relaci z vaší trasy a klíč 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, '');
}
}
}
Tato signatura render(committed, tail) je celý vzor UI; další oddíl vysvětluje proč.
Přepis tvoří dva typy rámců a jejich záměna je nejčastější chyba.
Nejprve proč jsou vůbec dva. Model řeči rozhoduje, co slovo bylo, částečně podle slov, která následují: „I scream“ a „ice cream“ znějí stejně, dokud věta nepokračuje. Proto realtimový rozpoznávač vydá tip brzy a potom ho upravuje, jak přichází další zvuk, a ustálí se až tehdy, když slyšel dost. Každé streamovací API pro řeč to nějak vystavuje. Některá nazývají tipy „interim“ nebo „partial“ a ustálené označují is_final; slovník se liší, kázeň ne. Zde je tip koncept a ustálený text je potvrzení.
transcript.draft je živý konec: slova slyšená od posledního potvrzení, stále se měnící. Každý koncept nahrazuje celý předchozí. Překreslete konec pomocí jeho segments; nikdy jej nepřipojujte. Koncepty záměrně nenesou seq, takže se nepokoušejte je párovat s potvrzeními, do kterých se promění.
transcript.committed je finální: jeden segment s seq (0, 1, 2, bez mezer), start, end, text, speaker a words s časováním každého slova. Připojte jej a zahoďte koncept, který pokrývá; další koncept začíná tam, kde toto potvrzení skončilo.
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, které koncepty připojuje, ukazuje zdvojený, blikající text. Vaše zobrazení titulků dostane záseky, které mluvčí nikdy neměl. Vykreslete všechny potvrzené segmenty a za nimi nejnovější koncept, a koncept smažte, když přijde potvrzení. Potvrzení může obsahovat slova, která se v žádném konceptu nikdy neobjevila.
Zde je pár sekund řeči na drátě z příkladu fetch výše (translate: ["es"], sestavování vět 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}]}
Kolem těchto rámců jsou ještě dva. transcript.amended opraví potvrzený segment na místě (stejný seq, nový text nebo speaker), když bylo opraveno slovo v něm; použijte jej na řádek, který jste už vykreslili. transcript.words streamuje časování slov pro konec těsně před jeho potvrzením. Vyžaduje events.words, což je ve výchozím stavu zapnuto; potvrzení vždy nese vložená words, ať říkají cokoli možnosti událostí. Nakonec ignorujte typy rámců, které neznáte: nové se přidávají bez upozornění.
Zde je smyčka čtení Python SDK s terminálem jako zobrazením titulků. Sestavování vět je také vypnuto, takže překlady přicházejí jako transcript.translated, a rámec error se z této smyčky vynoří jako 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())
Překlad po segmentech, jak je výše, je jednoduchá forma. Forma, kterou čtenáři dávají přednost, dostane vlastní oddíl.
translate: ["es", "fr"] si vyžádá překlady průběžně. Jak přicházejí, závisí na sestavování vět (sentences: { enabled: true }), které je ve výchozím stavu zapnuto.
Při vypnutých větách za každým potvrzením o chvíli později následuje rámec transcript.translated, který uvádí jeho seq a nese translations klíčované podle jazyka; připojte je k tomuto segmentu. Tak to dělaly dosavadní příklady. Je to jednoduché a čte se jako záznam potvrzení: jeden segment, jeden překlad, hranice věty tam, kde mluvčí nabral dech.
Při zapnutých větách server skládá potvrzení do celých vět a ty překládá, což čte se mnohem lépe. Každý rámec transcript.sentence je úplnou náhradou jedné věty, identifikované sentenceId. Jeho status přechází z open na closed (nebo dropped, což znamená zahodit), a jeho translations jsou předběžné, dokud je věta otevřená, a konečné po uzavření. Vkládejte nebo aktualizujte podle sentenceId, nikdy nepřipojujte. TypeScript SDK pro přesně tento účel obsahuje 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}`);
}
Překlady sledují přepis o sekundu či dvě zpět. Pokud je chcete také namluvit, přidejte voice: ["es"] (vyžaduje věty). Španělština pro každou uzavřenou větu pak přijde jako rámec transcript.voice s klipem jako base64 Ogg/Opus, 24 kHz mono. Dlouhá věta přichází po kouscích (part z parts), které se přehrají za sebou. Předběžný text se nikdy nenamlouvá. Průvodce živým překladem podrobně popisuje hlasy a kam mizí sekundy vysvětluje řetězec zpoždění.
Cílové jazyky se při vytváření neurčují napevno.
Textový rámec session.update změní spokenLanguage, translate, speakerLabels, latencyMode, voice nebo voiceByLang od tohoto bodu dál; pole, která vynecháte, zůstanou beze změny.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK to zpřístupňují jako session.update(…). translate nahradí celý seznam a [] překlad vypne. Už doručené segmenty se znovu nepřepisují ani nepřekládají, a pokud zapnete označování mluvčích, mluvčí jsou označeni jen od té chvíle. Co bylo řečeno, zůstává řečeno; nastavení řídí jen to, co přijde dál.
Když zvuk skončí, pošlete {"type":"eof"} (session.eof() v SDK). Server přepíše, co ještě drží, pošle poslední potvrzení, pak session.done a uzavře spojení. Pokud místo toho pošlete cancel, zastaví se okamžitě a zbytek zahodí.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason je eof, max_duration (relace dosáhla maximální délky tarifu a proběhlo stejné dokončení) nebo cancelled. stats měří server: audioS je zvuk, který jste poslali, processingS je doba běhu relace podle nástěnných hodin a realtimeFactor je jejich poměr. Živá relace skončí těsně pod 1, protože čas podle nástěnných hodin zahrnuje závěrečné dokončení po vašem eof. Číslo výrazně nižší znamená, že relace trvala znatelně déle, než trvalo samotné audio; prvním místem, kam se podívat, je, jak rychle zvuk přicházel. U souboru totéž číslo říká, kolikrát rychleji než přehrávání se přepis vrátil.
Relace trvá nejvýše limits.maxDurationS z GET /v1/realtime/capabilities a její zvuk čerpá váš kredit za sekundu podle živé sazby vašeho tarifu, se stejným minimem 5 minut jako každý přepis: každá relace, která přijme zvuk, se účtuje alespoň 5 minut (ceník). Stejný socket přijme i celý soubor: vytvořte relaci s input: { kind: "file" }, pošlete vlastní bajty souboru (mp3, wav, flac, ogg, m4a) a eof, a přepis se vrátí mnohonásobně rychleji, než audio hraje, s rámci transcript.progress cestou.
Neexistuje obnovení. Přerušené spojení relaci ukončí; opětovné připojení znamená vytvořit novou relaci, která se účtuje vlastním minimem, a znovu streamovat z vašeho živého zdroje, s seq od 0. Samotné opětovné připojení je běžný vzor pro každé socketové API a stojí za to ho zvládnout jednou provždy:
retryable: true v rámci error, 503 s Retry-After, nebo 30 sekund bez rámce, což platí za capacity_exceeded. invalid_token a session_expired znamenají nové streamUrl z vašeho serveru, ne další pokus se starou adresou.Trasu vytvářející relace udržujte levnou na volání a s uzavřením zacházejte jako s novým startem. Absence obnovení je cena za to, že nic neukládáme; pokud musí přepis přežít socket, uchovejte jej.
Pokud chcete stejný stream uložit jako nahrávku, dodatečně opravit, exportovat a číst publikem v jeho vlastních jazycích, publikujte jej místo toho jako živý přepis. Průvodce živým vysíláním provádí týž socket PCM přes přepis a odkaz na vysílání a průvodce poslechem ukazuje stranu posluchače, kde si každý posluchač vybere jazyk a pozdě připojený posluchač se dohoní z kurzoru lastSeq. Jak to vypadá pro místnost, je námětem článku jeden odkaz, každý jazyk.
Pro soubor, který chcete uchovat, vede uložená cesta přes přepis souboru pomocí API. Úplný referenční přehled rámců, každé pole včetně, je v příručce Streamování mikrofonu v referenci API.