Prepis v realnem času pride dvakrat: najprej kot osnutek, ki se ves čas spreminja, nato kot končna vrstica, ki se ne spremeni. Osnutek prikažite kot zamenljiv rep, končno vrstico pa kot zgodovino, ostalo v pogledu živih podnapisov pa je le tehnična napeljava.
Seja v realnem času je najmanjša stvar, ki jo API VocalStack opravi. Vaš strežnik opravi en klic HTTP in dobi nazaj podpisan streamUrl. Vaš odjemalec odpre WebSocket na ta naslov, pošilja surov zvok, takoj ko je zajet, in prepis bere nazaj kot besedilne okvirje JSON, le trenutek za govorcem. Ko se zvok konča, pošljete eof; strežnik izprazni medpomnilnik, sporoči, kaj je seja izmerila, in zapre povezavo.
Nič se ne shrani. Prepis obstaja le med prenosom, kar je ravno tisto, kar želi pogled podnapisov, prekrivanje srečanja ali glasovna funkcija v vaši aplikaciji. Če želite besede obdržati, deliti in izvoziti kasneje, potrebujete raje prepis v živo in prenos; zadnji razdelek kaže tja.
Oblika je ta, ki jo deli večina pretočnih API-jev za govor: izmenjava poverilnic prek HTTP, dolgotrajna vtičnica, po kateri zvok gre noter, JSON pa ven, in nek način, kako ločiti začasno besedilo od končnega. Imena se od enega API-ja do drugega razlikujejo; premični deli pa redko. Tu so v zaporedju:
POST /v1/realtime/sessions z vašim ključem API, na vašem strežniku. Odgovor je { sessionId, streamUrl, expiresAt }.streamUrl.session.loading, dokler ne pride session.ready.transcript.draft (zamenjajte rep) in transcript.committed (dodajte končno vrstico).{"type":"eof"}; preberite session.done.Samo korak 1 potrebuje vaš ključ, zato se zgodi na vašem strežniku.
Ključ ustvarite pod Developers, API keys z dovoljenjem Write. Ključ je namenjen strežnikom in skriptam: API zavrne ključ, poslan iz spletne strani ali v naslovu URL, zato sejo ustvarite za lastno pot na strežniku, stran pa vidi samo streamUrl.
URL nosi svoj žeton zaradi omejitve brskalnika, ki jo je splošno koristno poznati. Konstruktor brskalnikovega WebSocket ne more nastaviti glav zahtevka, zato ni glave Authorization, v katero bi lahko dali ključ; vsak API za govor, namenjen brskalniku, overi svojo vtičnico s kratkoživim žetonom v URL-ju (ali pa ga vtakne v ime podprotokola). Kratkoživost je bistvena: URL konča v dnevnikih in zgodovini, zato mora, kar nosi, poteči. Povežite se pred expiresAt.
SDK-ji se prenesejo iz območja Developers, ne iz repozitorija paketov; razdelek Install v hitrem začetku prikazuje ukaze za to okolje. Z SDK-jem TypeScript je strežniška pot videti takole:
// 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 };
}
Brez SDK-ja je to ena fetch. Ta zahteva tudi prevod v španščino in izklopi sestavljanje stavkov, kar se ujema z vzorcem sporočil spodaj:
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 };
Vse možnosti imajo privzeto vrednost. Na začetku so pomembne spokenLanguage (če jo izpustite, se jezik zazna), translate (seznam ciljnih jezikov), speakerLabels in latencyMode; ostale se pojavijo, ko bodo potrebne spodaj.
Naslov streamUrl predajte tistemu, ki zajema zvok; od tu naprej ključ ni več potreben.
Povežite se na streamUrl takšen, kot je, brez glave. Medtem ko se seja zaganja, strežnik pošilja okvirje session.loading. Nato pride session.ready. Pred njim ne pošiljajte zvoka. Zvok, poslan prezgodaj, strežnik ne shrani za kasneje; obravnava ga kot napako, pošlje okvir error in zapre vtičnico.
Ko je vtičnica odprta, ima vsaka napaka obliko tega okvirja: {"type":"error","code":"…","message":"…","retryable":true|false}. code je zaprt seznam (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message je namenjen vašim dnevnikom, retryable pa pove, ali se splača poskusiti z novo sejo. Obravnavajte to že od prvega okvira dalje:
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;
}
});
Surovi odjemalec mora obdelati še dve stvari sam; SDK-ji to že opravijo. Prvič, zavrnjena nadgradnja nosi običajno ovojnico napake HTTP API-ja: 401 invalid_token, 410 session_expired ali 503 unavailable z glavo Retry-After, ki jo je treba spoštovati.
Drugič, zasedenost izgleda kot tišina, ne kot napaka. Če v 30 sekundah ne pride noben okvir, to obravnavajte kot capacity_exceeded in se znova povežite; nova seja ponavadi uspe. Če okvirji session.loading še vedno prihajajo, session.ready pa nikoli ne pride, to obravnavajte kot engine_unavailable, kar je prav tako ponovljivo. V SDK-jih se RealtimeSession.connect uspešno zaključi ob session.ready, po readyDeadlineMs pa javi napako s pravo kodo.
Ko pride session.ready, strežnik pričakuje zvok v tempu, s katerim se govori.
Zvok gre noter kot binarni okvirji surovega PCM: 32-bitni float, little-endian, 16 kHz, mono (pcm_f32le, privzeti format audio seje). Pošiljajte ga, takoj ko je zajet, v kosih po približno 100 do 250 ms, ne da bi med kosi čakali na prepis.
Vredno je enkrat izračunati, koliko bajtov je. 16.000 vzorcev na sekundo po 4 bajte je 64.000 bajtov na sekundo, torej je 100 ms 6.400 bajtov, 250 ms pa 16.000. Modeli za govor so običajno učeni na 16 kHz mono, zato toliko API-jev za realni čas to zahteva, medtem ko mikrofoni in brskalniki zajemajo pri 44,1 ali 48 kHz in pogosto v stereu. Nekaj mora zvok ponovno vzorčiti in kanale zliti v enega, preden gredo bajti ven: tukaj je to ffmpeg, v brskalniku pa pomočnik za mikrofon v SDK-ju. Če pošljete zvok pri 48 kHz in deklarirate 16 kHz, ne pride do napake; prepis govora je kot v počasnem posnetku, slab.
V Node je ffmpeg najpreprostejša naprava za zajem:
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 nikoli ne blokira; le postavi v čakalno vrsto. bufferedAmount je velikost te vrste, na zdravi povezavi pa se giblje blizu nič. Živega zvoka ni mogoče ustaviti, zato je koristen odziv na naraščajočo vrsto to, da jo opazite, ne pa da zavržete okvirje. Tudi strežnik uveljavlja protitlak. Sprejme zvok nekaj sekund pred resničnim časom (limits.audioBufferS), nato neha brati, dokler ne dohiti. Pri povratnem toku se potrditve čakajo v vrsti brez izgube, osnutki pa se združujejo, zato počasen bralnik vidi manj, a svežejših osnutkov. Odjemalec, ki ne zmore niti potrditev, prejme okvir error s kodo slow_consumer in retryable: false ter je odklopljen; če to opazite, vaš obdelovalec sporočil preveč dela.
V brskalniku streamMicrophone iz @vocalstack/sdk/mic zahteva mikrofon, ponovno vzorči na 16 kHz in pošilja kose skupaj s sporočili clock, s katerimi strežnik označi vrzeli, kadar brskalnik zavira zavihek. Stran dobi sejo od vaše poti in ključa nikoli ne vidi:
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, '');
}
}
}
Ta podpis render(committed, tail) je celoten vzorec vmesnika; naslednji razdelek pojasni, zakaj.
Prepis gradita dve vrsti okvirjev, zamenjava med njima pa je najpogostejša napaka.
Najprej, zakaj sploh dve. Model za govor odloča o tem, kaj je bila beseda, delno na podlagi besed, ki sledijo: »I scream« in »ice cream« zvenita enako, dokler se stavek ne nadaljuje. Zato prepoznavalnik v realnem času odda zgodnji domnevni rezultat, ga nato popravlja, ko prihaja več zvoka, in se ustali šele, ko je slišal dovolj. Vsak pretočni API za govor to izpostavi na nek način. Nekateri domnevne rezultate imenujejo »interim« ali »partial« in ustaljene označijo z is_final; izrazoslovje se razlikuje, disciplina ne. Tu je domneva osnutek, ustaljeno besedilo pa potrditev.
transcript.draft je živi rep: besede, slišane od zadnje potrditve, ki se še spreminjajo. Vsak osnutek v celoti nadomesti prejšnjega. Rep narišite znova z njegovimi segments; nikoli ga ne dodajajte. Osnutki po zasnovi nimajo seq, zato jih ne poskušajte ujemati s potrditvami, v katere se bodo spremenili.
transcript.committed je končen: en segment s seq (0, 1, 2, brez vrzeli), start, end, text, speaker in words s časi za posamezne besede. Dodajte ga in zavrzite osnutek, ki ga pokriva; naslednji osnutek se začne tam, kjer se je končala ta potrditev.
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, ki osnutke dodaja, prikazuje podvojeno, utripajoče besedilo. Vaš pogled podnapisov dobi jecljanje, ki ga govorec nikoli ni imel. Narišite vse potrjene odlomke, nato zadnji osnutek za njimi, in ko pride potrditev, osnutek izbrišite. Potrditev lahko vsebuje besede, ki se v nobenem osnutku niso pojavile.
Tukaj je nekaj sekund govora na žici iz zgornjega primera fetch (translate: ["es"], sestavljanje stavkov izklopljeno):
{"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}]}
Okoli teh sta še dva okvirja. transcript.amended popravi potrjen odlomek na mestu (isti seq, nov text ali speaker), ko je bila kakšna beseda v njem popravljena; uveljavite ga na vrstici, ki ste jo že narisali. transcript.words pretoči čase besed za rep tik preden se potrdi. Potrebuje events.words, ki je privzeto vklopljen; potrditev vedno vsebuje svoje words ne glede na to, kaj pravijo nastavitve dogodkov. Nazadnje prezrite vrste okvirjev, ki jih ne poznate: nove se dodajajo brez opozorila.
Tu je bralna zanka Python SDK-ja, s terminalom kot pogledom podnapisov. Tudi tu je sestavljanje stavkov izklopljeno, zato prevodi prihajajo kot transcript.translated, okvir error pa se pojavi kot izjema VocalStackError, dvignjena iz zanke:
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())
Prevod po segmentih, kot zgoraj, je preprosta oblika. Oblika, ki jo bralci raje berejo, dobi svoj razdelek.
translate: ["es", "fr"] zahteva prevode sproti. Kako prihajajo, je odvisno od sestavljanja stavkov (sentences: { enabled: true }), ki je privzeto vklopljeno.
Ko so stavki izklopljeni, po vsaki potrditvi kmalu sledi okvir transcript.translated, ki navaja njen seq in nosi translations, ključane po jeziku; priložite jih temu segmentu. Tako so bili primeri doslej. Je preprosta oblika in se bere kot dnevnik potrditev: en segment, en prevod, meja stavka tam, kjer je govorec zajel sapo.
Ko so stavki vklopljeni, strežnik potrditve sestavi v cele stavke in te prevede, kar se bere precej bolje. Vsak okvir transcript.sentence v celoti nadomesti en stavek, ključ pa je sentenceId. Njegov status gre od open do closed (ali dropped, kar pomeni, da ga zavrzite); njegovi translations so začasni, dokler je stavek odprt, in dokončni, ko se zapre. Vstavite ali posodobite po sentenceId, nikoli ne dodajajte. SDK TypeScript vsebuje applySentence prav za to:
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}`);
}
Prevodi sledijo prepisu za sekundo ali dve. Če želite, da se slišijo tudi, dodajte voice: ["es"] (potrebuje stavke). Špansko besedilo za vsak zaprt stavek nato prihaja kot okvir transcript.voice, ki nosi posnetek kot base64 Ogg/Opus pri 24 kHz mono. Dolg stavek prihaja v kosih (part od parts), ki se predvajajo drug za drugim. Začasnega besedila se nikoli ne bere na glas. Vodnik Live translation podrobno pokriva glasove, kam odidejo sekunde pa razloži verigo zamud.
Ciljni jeziki niso določeni ob ustvarjanju.
Besedilni okvir session.update spremeni spokenLanguage, translate, speakerLabels, latencyMode, voice ali voiceByLang od te točke naprej; polja, ki jih izpustite, ostanejo nespremenjena.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK-ji to izpostavijo kot session.update(…). translate nadomesti celoten seznam, [] pa prevajanje izklopi. Že dostavljeni segmenti se ne prepisujejo ali prevajajo znova, in če vklopite oznake govorcev, se govorci označijo samo od te točke naprej. Kar je bilo rečeno, ostane rečeno; nastavitve veljajo samo za tisto, kar sledi.
Ko se zvok konča, pošljite {"type":"eof"} (v SDK-jih session.eof()). Strežnik prepiše, kar še ima, pošlje zadnje potrditve, nato session.done in zapre povezavo. Če namesto tega pošljete cancel, se ustavi takoj in ostanek zavrže.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason je eof, max_duration (seja je dosegla največjo dolžino paketa in je izvedla isto izpraznitev) ali cancelled. stats meri strežnik: audioS je zvok, ki ste ga poslali, processingS je čas na uri, ki ga je seja tekla, realtimeFactor pa je njuno razmerje. Seja v živo se giblje tik pod 1, ker čas na uri vključuje končno izpraznitev po vašem eof. Številka, precej nižja od tega, pomeni, da je seja trajala opazno dlje, kot je trajal zvok; prvo, kar preverite, je, kako hitro je zvok prihajal. Pri datoteki ista številka pove, za kolikokrat hitreje od predvajanja se je prepis vrnil.
Seja traja največ limits.maxDurationS iz GET /v1/realtime/capabilities, zvok pa porablja vaše dobroimetje na sekundo po živi stopnji vašega paketa, z enako najmanjšo obračunsko enoto 5 minut kot pri vsakem prepisu: vsaka seja, ki prejme zvok, se obračuna vsaj za 5 minut (cenik). Ista vtičnica sprejme tudi celotno datoteko: ustvarite sejo z input: { kind: "file" }, pošljite lastne bajte datoteke (mp3, wav, flac, ogg, m4a) in eof, prepis pa se vrne večkrat hitreje od predvajanja zvoka, z okvirji transcript.progress med potekom.
Nadaljevanja ni. Prekinjena povezava konča sejo; ponovna povezava pomeni ustvariti novo sejo, ki se obračuna z lastno najmanjšo enoto, in znova pretakati iz vašega živega vira, s seq ponovno pri 0. Ponovna povezava je standardni vzorec za vsak API z vtičnico in se splača narediti prav enkrat:
retryable: true na okviru error, 503 s svojim Retry-After ali 30 sekund brez okvirja, kar pomeni capacity_exceeded. invalid_token in session_expired pomenita nov streamUrl z vašega strežnika, ne nov poskus s starim.Ohranjajte pot, ki ustvarja sejo, poceni za klic, in z zaprtjem ravnajte kot s svežim začetkom. Brez nadaljevanja plačate ceno, ker nič ne shranjujete; če mora prepis preživeti vtičnico, ga hranite.
Če želite isti tok shraniti kot posnetek, ga pozneje popraviti, izvoziti in prebrati občinstvu v njihovih jezikih, ga objavite v prepisu v živo. Vodnik Go live vodi skozi isto vtičnico PCM prek prepisa in povezave za prenos, vodnik Listen pa prikazuje stran naročnika, kjer si vsak poslušalec izbere jezik, pozni pridruženec pa se dohiti s kazalcem lastSeq. Kako to izgleda za sobo, je tema Ena povezava, vsi jeziki.
Za datoteko, ki jo želite shraniti, je shranjena pot prepis datoteke z API-jem. Popoln referenčni opis okvirjev, z vsakim poljem, je vodnik Stream a microphone v referenci API.