A valós idejű átirat kétszer érkezik: előbb változó piszkozatként, majd egy olyan végleges sorként, amely már nem változik. A piszkozatot cserélhető farokként, a végleges sort előzményként jelenítsd meg, a többi élő felirat-nézetben már csak bekötés.
Egy valós idejű munkamenet a legkisebb dolog, amit a VocalStack API csinál. A szervered egyetlen HTTP-hívást intéz, és egy aláírt streamUrl-t kap vissza. A kliensed WebSocketet nyit ezen az URL-en, a rögzítés pillanatában nyers hangot küld, és a beszélő után pillanatokkal JSON szöveges keretekben olvassa vissza az átiratot. Amikor a hang véget ér, elküldöd az eof-ot, a szerver lezárja a puffert, jelenti, amit a munkamenet mért, majd bezárja a kapcsolatot.
Semmi nem tárolódik. Az átirat csak a vezetéken létezik, ami pont az, amire egy feliratnézetnek, egy értekezlet-rávetítésnek vagy a saját alkalmazásod hangfunkciójának szüksége van. Ha a szavakat utólag meg akarod őrizni, megosztani és exportálni, élő átírásra és közvetítésre van szükséged; az utolsó szakasz ezekre mutat.
A felépítés olyan, amilyet a legtöbb streaming beszédfelismerő API közösen használ: hitelesítő adat cseréje HTTP-n, egy hosszú életű socket, amelyen hang megy be és JSON jön ki, valamint valamilyen módszer az előzetes és a végleges szöveg megkülönböztetésére. A nevek API-nként eltérnek, a mozgó alkatrészek ritkán. Itt, sorrendben:
POST /v1/realtime/sessions a kulcsoddal, a szerveren. A válasz { sessionId, streamUrl, expiresAt }.streamUrl-re.session.loading életjelekre a session.ready-ig.transcript.draft olvasása (a farok cseréje) és transcript.committed (végleges sor hozzáfűzése).{"type":"eof"} küldése; session.done olvasása.Csak az 1. lépéshez kell a kulcsod, így az a szerveren történik.
Hozz létre egy kulcsot a Developers, API keys alatt, Write jogosultsággal. A kulcs szervereknek és szkripteknek való: az API visszautasít egy kulcsot, ha weboldalról vagy URL-ben érkezik, ezért a munkamenetet a saját útvonalad mögött hozod létre, a weboldal pedig csak a streamUrl-t látja.
Az URL saját tokent hordoz egy böngészőbeli korlát miatt, amelyet általánosan is érdemes ismerni. A böngészőbeli WebSocket konstruktor nem tud kérési fejléceket beállítani, így nincs Authorization fejléc, amibe a kulcsot tehetnéd; minden böngészőnek szánt beszéd-API rövid élettartamú tokennel hitelesíti a socketet az URL-ben (vagy egy alprotokoll-névbe csempészve). A rövid élettartam a lényeg: az URL-ek naplókba és előzményekbe kerülnek, ezért amit hordoz, annak le kell járnia. Csatlakozz expiresAt előtt.
Az SDK-kat a Developers területről töltöd le, nem csomagkezelőből; a quickstart „Install” szakasza megmutatja ennek a környezetnek a saját parancsait. A TypeScript SDK-val egy szerver-útvonal így néz ki:
// 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 };
}
SDK nélkül ez egyetlen fetch. Ez a példa spanyolt kér, és kikapcsolja a mondatok összerakását, összhangban a lentebbi vezeték-mintával:
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 };
Minden beállításnak van alapértelmezett értéke. A legfontosabbak az elején: spokenLanguage (ha kihagyod, a nyelv felismerésre kerül), translate (célnyelvek listája), speakerLabels és latencyMode; a többi alább, amikor szükség lesz rá, kerül elő.
Add át a streamUrl-t annak, ami a hangot rögzíti; innentől kezdve nem szerepel kulcs.
Csatlakozz a streamUrl-hez úgy, ahogy van, fejléc nélkül. Amíg a munkamenet indul, a szerver session.loading életjeleket küld. Utána jön a session.ready. Addig ne küldj hangot. A korai hang nem marad meg későbbre; a szerver hibaként kezeli, küld egy error keretet, és lezárja a socketet.
Amint a socket nyitva van, minden hiba ennek a keretnek az alakját ölti: {"type":"error","code":"…","message":"…","retryable":true|false}. A code zárt lista (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), a message a naplóidnak szól, a retryable pedig azt mondja meg, érdemes-e új munkamenetet próbálni. Kezeld az első kerettől kezdve:
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;
}
});
A nyers kliensnek két dolgot magának kell kezelnie; az SDK-k ezt már megteszik. Először: az elutasított upgrade az API szokásos HTTP-hibaborítékát hordozza: 401 invalid_token, 410 session_expired, vagy 503 unavailable a tiszteletben tartandó Retry-After fejléccel.
Másodszor: a telítettség csendnek látszik, nem hibának. Ha 30 másodpercig egyetlen keret sem érkezik, kezeld capacity_exceeded-ként, és csatlakozz újra; egy új munkamenet általában átjut. Ha az életjel-keretek továbbra is érkeznek, de a session.ready soha, kezeld engine_unavailable-ként, ami szintén újrapróbálható. Az SDK-kban a RealtimeSession.connect a session.ready-nél tér vissza, és a readyDeadlineMs után a megfelelő kóddal hibázik.
Amint megérkezik a session.ready, a szerver a beszéd tempójában várja a hangot.
A hang nyers PCM-ként bináris keretekben megy be: 32 bites lebegőpontos, little-endian, 16 kHz, mono (pcm_f32le, a munkamenet alapértelmezett audio formátuma). Úgy küldd, ahogy rögzítik, körülbelül 100–250 ms-es darabokban, anélkül hogy a darabok között az átiratra várnál.
Érdemes egyszer elvégezni a bájtszámítást. 16 000 minta másodpercenként, mintánként 4 bájt, ez 64 000 bájt másodpercenként, tehát 100 ms 6 400 bájt, 250 ms 16 000. A beszédmodelleket általában 16 kHz-es monón tanítják, ezért kérik ezt olyan sok valós idejű API, míg a mikrofonok és a böngészők 44,1 vagy 48 kHz-en, gyakran sztereóban rögzítenek. Valaminek át kell mintavételeznie a hangot, és egy csatornába kell összevonnia, mielőtt a bájtok kimennek: itt ez az ffmpeg, a böngészőben pedig az SDK mikrofon-segédje. A 48 kHz-es hang küldése, miközben 16 kHz-et deklarálsz, nem hibázik; lassított beszédet ír át, rosszul.
Node-ban az ffmpeg a legegyszerűbb rögzítőeszköz:
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());
}
A ws.send soha nem blokkol; sorba áll. A bufferedAmount ennek a sornak a mérete, és egészséges kapcsolaton közel nullán ingadozik. Az élő hangot nem lehet szüneteltetni, ezért a növekvő sorra hasznos válasz az észlelése, nem a keretek eldobása. A szerver is alkalmaz visszanyomást. Néhány másodperccel a valós idő előtt fogadja el a hangot (limits.audioBufferS), majd addig nem olvas, amíg be nem utoléri. Visszafelé a véglegesítések veszteség nélkül sorba állnak, míg a piszkozatok összevonódnak, így a lassú olvasó kevesebb, de frissebb piszkozatot lát. Az a kliens, amely a véglegesítésekkel sem tud lépést tartani, slow_consumer kóddal és retryable: false értékű error keretet kap, és bontja a kapcsolatot; ha ezt látod, az üzenetkezelőd túl sokat dolgozik.
Böngészőben a @vocalstack/sdk/mic-ből származó streamMicrophone engedélyt kér a mikrofonra, 16 kHz-re mintavételez át, és a darabokat clock keretekkel együtt küldi, amelyek lehetővé teszik a szervernek, hogy jelölje a réseket, ha a böngésző lefojtja a lapot. Az oldal a munkamenetet az útvonaladtól kapja, és soha nem látja a kulcsot:
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, '');
}
}
}
Ez a render(committed, tail) szignatúra az egész UI-minta; a következő szakasz megmagyarázza, miért.
Két keretfajta építi az átiratot, és ezek összekeverése a leggyakoribb hiba.
Először is: miért van kettő. A beszédmodell részben abból dönti el, mit mondtak egy szó, hogy mi következik utána: az „I scream” és az „ice cream” ugyanúgy hangzik, amíg a mondat tovább nem megy. Ezért a valós idejű felismerő korán ad egy tippet, majd több hang érkezésekor felülvizsgálja, és csak akkor rögzíti, ha már eleget hallott. Minden streaming beszéd-API valahogy kitesz ezt: egyesek „köztes” vagy „részleges” eredményeknek hívják a tippeket, és a rögzítetteket is_final-lal jelölik; a szóhasználat változik, a fegyelem nem. Itt a tipp a piszkozat, a rögzített szöveg pedig a véglegesítés.
A transcript.draft az élő farok: a legutolsó véglegesítés óta hallott szavak, amelyek még változnak. Minden piszkozat az előző teljes cseréje. A farkat a segments-ével rajzold újra; soha ne fűzd hozzá. A piszkozatok szándékosan nem hordoznak seq-t, így ne próbáld őket ahhoz a véglegesítéshez párosítani, amivé válnak.
A transcript.committed végleges: egy segment, benne seq (0, 1, 2, rés nélkül), start, end, text, speaker és a szavak időzítéseivel a words. Fűzd hozzá, és dobd el a piszkozatot, amelyet lefed; a következő piszkozat ott kezdődik, ahol ez a véglegesítés véget ért.
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
Az a UI, amely a piszkozatokat fűzi hozzá, megduplázott, villogó szöveget mutat. A feliratnézeted akadozni kezd, amit a beszélő soha nem tapasztalt. Jeleníts meg minden véglegesített szegmenst, utána a legutolsó piszkozatot, és töröld a piszkozatot, amikor véglegesítés érkezik. Egy véglegesítés olyan szavakat is tartalmazhat, amelyek egyetlen piszkozatban sem szerepeltek.
Íme néhány másodperc beszéd a vezetéken, a fenti fetch példából (translate: ["es"], mondat-összerakás kikapcsolva):
{"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}]}
Két további keret van ezek körül. A transcript.amended egy véglegesített szegmenst javít helyben (ugyanaz a seq, új text vagy speaker), ha egy benne lévő szót javítottak; alkalmazd a már kirajzolt sorra. A transcript.words a szavak időzítéseit streameli a farokra közvetlenül a véglegesítés előtt. Ehhez az events.words kell, ami alapértelmezetten be van kapcsolva; a véglegesítés mindig tartalmazza a words-öt, bármit is mondanak az eseménybeállítások. Végül: az ismeretlen keretfajtákat hagyd figyelmen kívül, mert újak érkeznek értesítés nélkül.
Itt a Python SDK olvasóciklusa, a terminállal mint feliratnézettel. A mondat-összerakás itt is ki van kapcsolva, így a fordítások transcript.translated-ként érkeznek, a error keret pedig VocalStackError-ként dobódik ki a ciklusból:
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())
Az egyszerű forma a szegmensenkénti fordítás, mint fent. Az olvasók által előnyben részesített formának saját szakasz jár.
A translate: ["es", "fr"] fordítást kér a munkamenet közben. Hogy hogyan érkeznek, az a mondat-összerakástól függ (sentences: { enabled: true }), ami alapértelmezetten be van kapcsolva.
Ha a mondatok ki vannak kapcsolva, minden véglegesítést egy kicsivel később egy transcript.translated keret követ, amely megnevezi a seq-ét, és nyelvek szerint kulcsolt translations-t hordoz; ezeket a megfelelő szegmenshez csatold. Eddig a példák ezt használták. Ez egyszerű, és egy eseménynaplóra hasonlít: egy szegmens, egy fordítás, a mondathatár ott van, ahol a beszélő levegőt vett.
Ha a mondatok be vannak kapcsolva, a szerver a véglegesítéseket egész mondatokká rakja össze, és azokat fordítja, ami sokkal jobban olvasható. Minden transcript.sentence keret egy mondat teljes cseréje, a sentenceId azonosítja. A status mezője open-ről closed-ra vált (vagy dropped-ra, ami azt jelenti: dobd el), a translations pedig nyitott állapotban előzetes, lezáráskor végleges. Frissíts a sentenceId alapján, soha ne fűzz hozzá. A TypeScript SDK pontosan erre szállítja az applySentence-t:
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}`);
}
A fordítások másodpercekkel az átirat mögött járnak. Ha ki is szeretnéd mondatni őket, adj hozzá voice: ["es"]-t (ehhez mondatok kellenek). Minden lezárt mondat spanyol fordítása ekkor transcript.voice keretként érkezik, a klipet base64 Ogg/Opus formátumban, 24 kHz monón hordozza. Egy hosszú mondat darabokban érkezik (a parts közül a part), amelyeket egymás után kell lejátszani. Az előzetes szöveget soha nem mondja ki. Az élő fordítási útmutató részletesen tárgyalja a hangokat, a hol folynak el a másodpercek pedig a késleltetési láncot magyarázza.
A célnyelvek nem rögzítettek a létrehozáskor.
Egy session.update szöveges keret a spokenLanguage, translate, speakerLabels, latencyMode, voice vagy voiceByLang értékeit megváltoztatja az adott ponttól kezdve; a kihagyott mezők változatlanok maradnak.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
Az SDK-k ezt session.update(…) néven teszik elérhetővé. A translate a teljes listát cseréli, a [] pedig kikapcsolja a fordítást. A már kézbesített szegmenseket nem írják át és nem fordítják újra, és ha bekapcsolod a beszélőcímkéket, a beszélők csak onnantól lesznek címkézve. Ami elhangzott, az elhangzott; a beállítások csak a következőket szabályozzák.
Amikor a hang véget ér, küldd el a {"type":"eof"}-ot (az SDK-kban session.eof()). A szerver átírja, amit még tart, elküldi az utolsó véglegesítéseket, majd a session.done-t, és lezár. A cancel azonnal leállít és eldobja a maradékot.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
A reason értéke eof, max_duration (a munkamenet elérte a csomag maximális hosszát, és ugyanazt a záró feldolgozást futtatta le) vagy cancelled. A stats-ot a szerver méri: az audioS a te általad küldött hang, a processingS a munkamenet falióra-ideje, a realtimeFactor pedig ezek aránya. Egy élő munkamenet valamivel 1 alatt zár, mert a falióra-idő tartalmazza az eof utáni záró lezárást. Ennél jóval kisebb szám azt jelenti, hogy a munkamenet érezhetően tovább tartott, mint maga a hang; az első hely, ahol érdemes nézni, az, hogy milyen gyorsan érkezett a hang. Fájl esetén ugyanez a szám azt mutatja, hányszor gyorsabban jött vissza az átirat, mint ahogy a felvétel lejátszódik.
Egy munkamenet legfeljebb a GET /v1/realtime/capabilities limits.maxDurationS értékéig tart, a hangja pedig másodpercenként költi a kreditedet az élő csomagod díja szerint, ugyanazzal az 5 perces minimummal, mint bármely átírás: minden munkamenet, amely kap hangot, legalább 5 percre számláz (árak). Ugyanez a socket egy teljes fájlt is elfogad: hozd létre a munkamenetet input: { kind: "file" }-lal, küldd el a fájl saját bájtjait (mp3, wav, flac, ogg, m4a) és az eof-ot, és az átirat sokszorosan gyorsabban jön vissza, mint ahogy a hang lejátszódik, közben transcript.progress keretekkel.
Nincs folytatás. A megszakadt kapcsolat véget vet a munkamenetnek; az újracsatlakozás azt jelenti, hogy új munkamenetet hozol létre, amelyet saját minimummal számláznak, és újra streamelsz az élő forrásodból, a seq pedig újra 0-ról indul. Maga az újracsatlakozás a szokásos minta bármely socket-API-nál, és érdemes egyszer jól megcsinálni:
retryable: true egy error keretben, egy 503 a Retry-After-val, vagy 30 másodperc keret nélkül, ami a capacity_exceeded helyett áll. Az invalid_token és a session_expired azt jelenti, hogy új streamUrl kell a szerveredtől, nem újabb próbálkozás a régivel.Tartsd olcsón hívhatóvá a munkamenetet létrehozó útvonalat, és a lezárást kezeld új kezdésként. A folytatás hiánya annak az ára, hogy semmit nem tárolunk; ha az átiratnak túl kell élnie a socketet, te tartsd meg.
Ha ugyanezt a streamet rögzítve, utólag javítva, exportálva és közönség által, saját nyelvükön szeretnéd olvastatni, akkor inkább élő átírásba tedd. Az élő közvetítési útmutató ugyanezt a PCM-socketet végigvezeti egy átíráson és egy közvetítési linken, a hallgatói útmutató pedig a feliratkozó oldalt mutatja be, ahol minden hallgató választ nyelvet, a később csatlakozó pedig a lastSeq kurzortól pótolja le, ami kimaradt. Hogy ez egy teremben hogyan néz ki, arról az egy link, minden nyelv szól.
Ha egy fájlt szeretnél megtartani, a tárolt útvonal a fájl átírása a VocalStack API-val. A teljes keretreferenciát, minden mezővel, az API-referencia Mikrofon streamelése útmutatója adja.