Realaus laiko nuorašas ateina dukart: pirmiausia kaip juodraštis, kuris nuolat keičiasi, o paskui kaip galutinė eilutė, kuri nebesikeičia. Juodraštį vaizduokite kaip pakeičiamą galinę dalį, o galutines eilutes – kaip istoriją, ir viskas kita tiesioginių subtitrų rodinyje yra tik santechnika.
Realaus laiko sesija – mažiausias dalykas, ką atlieka VocalStack API. Jūsų serveris atlieka vieną HTTP kvietimą ir gauna pasirašytą streamUrl. Jūsų klientas atidaro WebSocket ryšį su tuo URL, siunčia neapdorotą garsą, kai jis įrašomas, ir skaito nuorašą kaip JSON teksto kadrus, vos kelis akimirkas atsiliekančius nuo kalbėtojo. Kai garsas baigiasi, siunčiate eof, serveris išvalo buferį, pateikia sesijos išmatuotus rodiklius ir uždaro ryšį.
Niekas nesaugoma. Nuorašas egzistuoja tik perdavimo kanale – būtent to reikia subtitrų rodiniui, susitikimo perdangai ar balso funkcijai jūsų programoje. Jei vėliau norite išsaugoti, bendrinti ir eksportuoti žodžius, verta naudoti tiesioginį transkribavimą ir transliaciją; paskutiniame skyriuje nurodoma, kur tai rasti.
Ši schema – ta pati, kurią dauguma srautinio kalbos API turi: prisijungimo duomenų mainai per HTTP, ilgai veikiantis socket ryšys, kuriuo garsas eina į vidų, o JSON – į išorę, ir kažkoks būdas atskirti laikinąjį tekstą nuo galutinio. Pavadinimai nuo vieno API prie kito skiriasi, o judančios dalys beveik niekada. Štai jos tokia tvarka:
POST /v1/realtime/sessions su jūsų API raktu, jūsų serveryje. Atsakymas – { sessionId, streamUrl, expiresAt }.streamUrl.session.loading gyvybės signalai, ir kol ateis session.ready.transcript.draft (pakeiskite galinę dalį) ir transcript.committed (pridėkite galutinę eilutę).{"type":"eof"} ir skaitykite session.done.Raktas reikalingas tik pirmajam žingsniui, todėl jis vyksta jūsų serveryje.
Sukurkite raktą Developers, API keys su Write leidimu. Raktas skirtas serveriams ir scenarijams: API atmeta raktą, atsiųstą iš tinklalapio ar URL, todėl sesiją kuriate už savo maršruto, o puslapis mato tik streamUrl.
URL pats neša savo prieigos žetoną dėl naršyklės apribojimo, kurį verta žinoti apskritai. Naršyklės WebSocket konstruktorius negali nustatyti užklausos antraščių, todėl nėra Authorization antraštės, kurioje galėtų būti raktas; kiekvienas naršyklėms skirtas kalbos API autentifikuoja savo socket ryšį trumpaamžiu žetonu URL (arba paslėptu į subprotokolo pavadinimą). Trumpas galiojimo laikas – čia esmė: URL patenka į žurnalus ir istoriją, todėl jame esantis žetonas turi baigti galioti. Prisijunkite prieš expiresAt.
SDK atsisiunčiami iš skilties Developers, o ne iš paketų registro; greitojo starto skyriuje Install rodomos šios aplinkos komandos. Su TypeScript SDK serverio maršrutas atrodo taip:
// 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 };
}
Be SDK tai – vienas fetch. Šis kreipiasi ir į ispanų kalbą bei išjungia sakinių surinkimą, atitikdamas žemiau pateiktą perdavimo pavyzdį:
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 };
Kiekviena parinktis turi numatytąją reikšmę. Pradžioje verta žinoti spokenLanguage (jei jos nenurodysite, kalba bus aptikta), translate (tikslinių kalbų sąrašas), speakerLabels ir latencyMode; likusios paaiškinamos toliau, kai prireikia.
Perduokite streamUrl tam, kas fiksuoja garsą; nuo čia toliau raktas nebedalyvauja.
Prisijunkite prie streamUrl kaip yra, be antraštės. Kol sesija startuoja, serveris siunčia session.loading gyvybės signalus. Tada ateina session.ready. Prieš jį garso nesiųskite. Garsas, nusiųstas per anksti, neišsaugomas vėlesniam laikui; serveris tai laiko klaida, siunčia error kadrą ir uždaro socket ryšį.
Atidarius socket ryšį, kiekviena klaida įgauna to kadro formą: {"type":"error","code":"…","message":"…","retryable":true|false}. code yra uždarasis sąrašas (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message skirtas jūsų žurnalams, o retryable nurodo, ar verta bandyti naują sesiją. Tvarkykite ją nuo pirmojo kadro:
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;
}
});
Paprastas klientas turi pats tvarkyti dar du dalykus; SDK tai jau daro. Pirma, atmestas atnaujinimas gauna įprastą API HTTP klaidos formatą: 401 invalid_token, 410 session_expired arba 503 unavailable su Retry-After antraštė, kurios reikia paisyti.
Antra, perkrova atrodo kaip tyla, o ne kaip klaida. Jei per 30 sekundžių neateina nė vienas kadras, traktuokite tai kaip capacity_exceeded ir prisijunkite iš naujo; nauja sesija paprastai pralenda. Jei loading kadrai ateina, bet session.ready niekada neateina, traktuokite tai kaip engine_unavailable, kuris taip pat leidžia bandyti dar kartą. SDK RealtimeSession.connect užbaigiamas sulaukus session.ready ir nesėkmingai baigiasi po readyDeadlineMs su tinkamu kodu.
Kai ateina session.ready, serveris tikisi garso tokiu tempu, kokiu jis kalbamas.
Garsas siunčiamas kaip dvejetainiai neapdorotos PCM kadrai: 32 bitų slankusis kablelis, little-endian, 16 kHz, monofoninis (pcm_f32le, numatytasis sesijos audio formatas). Siųskite jį iš karto, kaip jis įrašomas, gabalais apie 100–250 ms, nelaukdami nuorašo tarp gabalų.
Baitų skaičiavimą verta padaryti kartą. 16 000 mėginių per sekundę po 4 baitus yra 64 000 baitų per sekundę, todėl 100 ms yra 6 400 baitų, o 250 ms – 16 000. Kalbos modeliai dažniausiai mokomi su 16 kHz monofoniniu garsu, todėl taip daug realaus laiko API to reikalauja, o mikrofonai ir naršyklės įrašo 44,1 arba 48 kHz, dažnai stereo. Prieš siunčiant baitus, garsą reikia perskaičiuoti į kitą dažnį ir sujungti kanalus į vieną: čia tam naudojamas ffmpeg, o naršyklėje – SDK mikrofono pagalbinė priemonė. Siųsti 48 kHz garsą, bet deklaruoti 16 kHz, klaidos nesukelia; tiesiog kalba transkribuojama kaip sulėtinta, ir rezultatas prastas.
Node aplinkoje paprasčiausias garso įrenginys – ffmpeg:
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 niekada neblokuoja; jis įtraukia į eilę. bufferedAmount yra tos eilės dydis, o sveikame ryšyje jis laikosi arti nulio. Tiesioginio garso negalima sustabdyti, todėl naudinga reakcija į augančią eilę yra ją pastebėti, o ne mesti kadrus. Serveris taip pat taiko atgalinį spaudimą. Jis priima garsą keliomis sekundėmis į priekį nuo realaus laiko (limits.audioBufferS), o tada nustoja skaityti, kol pasivys. Atgal eina taip: patvirtintos eilutės įrašomos į eilę be praradimų, o juodraščiai sujungiami, todėl lėtas skaitytojas mato mažiau, bet šviežesnių juodraščių. Klientas, kuris nespėja net su patvirtintomis eilutėmis, gauna error kadrą su slow_consumer ir retryable: false ir yra atjungiamas; jei tai matote, jūsų pranešimų apdorojimo funkcija dirba per daug.
Naršyklėje streamMicrophone iš @vocalstack/sdk/mic paprašo mikrofono, perskaičiuoja į 16 kHz ir siunčia gabalus kartu su clock kadrais, kurie leidžia serveriui pažymėti tarpus, kai naršyklė stabdo skirtuką. Puslapis sesiją gauna iš jūsų maršruto ir rakto niekada nemato:
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, '');
}
}
}
Šis render(committed, tail) parašas yra visas UI šablonas; kitame skyriuje paaiškinama kodėl.
Nuorašą sudaro du kadrų tipai, o jų supainiojimas – dažniausia klaida.
Pirma, kodėl iš viso yra du. Kalbos modelis žodžio reikšmę iš dalies nustato pagal po jo einančius žodžius: „I scream“ ir „ice cream“ skamba vienodai, kol sakinys nepasibaigia. Todėl realaus laiko atpažintuvas iš pradžių pateikia spėjimą, vėliau jį keičia, kai ateina daugiau garso, ir nusistovi tik tada, kai išgirdo pakankamai. Kiekvienas srautinis kalbos API tai kaip nors parodo. Kai kurie spėjimus vadina „tarpiniais“ arba „daliniais“ rezultatais, o nusistovėjusius pažymi is_final; terminija skiriasi, principas – ne. Čia spėjimas yra juodraštis, o nusistovėjęs tekstas – patvirtinta eilutė.
transcript.draft yra gyvoji galinė dalis: nuo paskutinės patvirtintos eilutės girdimi žodžiai, dar besikeičiantys. Kiekvienas juodraštis visiškai pakeičia ankstesnįjį. Galinę dalį perpieškite su jos segments; niekada jos neprididėkite. Juodraščiai pagal paskirtį neturi seq, todėl nebandykite jų suderinti su patvirtintomis eilutėmis, į kurias jie virsta.
transcript.committed yra galutinis: vienas segment su seq (0, 1, 2, be tarpų), start, end, text, speaker ir words su kiekvieno žodžio laiku. Pridėkite jį ir pašalinkite juodraštį, kurį jis apima; kitas juodraštis prasideda ten, kur baigėsi ši patvirtinta eilutė.
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
Sąsaja, kuri prideda juodraščius, rodo dubliuotą, mirguliuojantį tekstą. Jūsų subtitrų rodinys įgauna kartojimo, kurio kalbėtojas niekada neturėjo. Atvaizduokite visas patvirtintas eilutes, o po jų – naujausią juodraštį, ir kai ateina patvirtinimas, juodraštį išvalykite. Patvirtinime gali būti žodžių, kurių nebuvo nė viename juodraštyje.
Štai keletas sekundžių kalbos perdavimo kanale iš aukščiau pateikto fetch pavyzdžio (translate: ["es"], sakinių surinkimas išjungtas):
{"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}]}
Dar du kadrai yra šalia šių. transcript.amended ištaiso patvirtintą segmentą vietoje (tas pats seq, naujas text arba speaker), kai jame koks žodis ištaisytas; pritaikykite jį eilutei, kurią jau nupiešėte. transcript.words prieš pat patvirtinimą perduoda galinės dalies žodžių laikus. Jam reikia events.words, kuris įjungtas pagal numatytuosius nustatymus; patvirtinta eilutė visada turi savo words, kad ir kokios būtų įvykių parinktys. Galiausiai ignoruokite jums nežinomus kadrų tipus: nauji pridedami be įspėjimo.
Štai Python SDK skaitymo ciklas, o subtitrų rodiniu laikomas terminalas. Ir čia sakinių surinkimas išjungtas, todėl vertimai ateina kaip transcript.translated, o error kadras iškyla kaip VocalStackError, iškeltas iš ciklo:
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())
Vertimas pagal segmentą, kaip aukščiau, yra paprastoji forma. Skaitytojų labiau mėgiama forma turi atskirą skyrių.
translate: ["es", "fr"] prašo vertimų sesijos metu. Kaip jie ateina, priklauso nuo sakinių surinkimo (sentences: { enabled: true }), kuris įjungtas pagal numatymą.
Kai sakiniai išjungti, po kiekvienos patvirtintos eilutės netrukus ateina transcript.translated kadras, nurodantis jos seq ir turintis translations, sugrupuotus pagal kalbą; pririškite juos prie to segmento. Iki šiol pavyzdžiuose buvo būtent taip. Tai paprasta ir primena patvirtinimų žurnalą: vienas segmentas, vienas vertimas, o sakinio riba – ten, kur kalbėtojas įkvėpė.
Kai sakiniai įjungti, serveris patvirtintas eilutes surenka į visus sakinius ir verčia juos, o tai skaitosi daug geriau. Kiekvienas transcript.sentence kadras visiškai pakeičia vieną sakinį ir yra susietas su sentenceId. Jo status pereina iš open į closed (arba dropped, reiškiančio, kad jį reikia atmesti), o jo translations yra laikinieji, kol sakinys atviras, ir galutiniai, kai jis užsidaro. Įrašykite arba atnaujinkite pagal sentenceId, niekada nepridėkite. TypeScript SDK šiam tikslui pateikia 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}`);
}
Vertimai seka nuorašą per sekundę ar dvi. Jei norite, kad jie būtų ir įgarsinti, pridėkite voice: ["es"] (jam reikia sakinių). Kiekvieno uždaro sakinio ispanų kalba tada ateina kaip transcript.voice kadras, kuriame įrašas perduodamas kaip base64 Ogg/Opus, 24 kHz monofoninis. Ilgas sakinys ateina gabalais (part iš parts), kad būtų grojamas vienas po kito. Laikinasis tekstas niekada neįgarsinamas. Tiesioginio vertimo vadove balsai aprašyti išsamiai, o kur dingsta sekundės paaiškina vėlavimo grandinę.
Tikslinės kalbos sukūrimo metu nėra fiksuotos.
Tekstinis session.update kadras nuo to momento keičia spokenLanguage, translate, speakerLabels, latencyMode, voice ar voiceByLang; laukai, kurių nenurodote, lieka nepakeisti.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK tai pateikia kaip session.update(…). translate pakeičia visą sąrašą, o [] išjungia vertimą. Jau pateikti segmentai iš naujo nėra transkribuojami ar verčiami, o jei įjungiate kalbėtojų žymėjimą, kalbėtojai pažymimi tik nuo to momento. Kas jau pasakyta, lieka pasakyta; nustatymai tvarko tik tai, kas bus toliau.
Kai garsas baigiasi, siųskite {"type":"eof"} (SDK – session.eof()). Serveris transkribuoja tai, ką dar turi, siunčia paskutines patvirtintas eilutes, tada session.done ir uždaro ryšį. Siunčiant cancel vietoj to, viskas sustoja iš karto, o likusi dalis atmetama.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason yra eof, max_duration (sesija pasiekė plano maksimalų ilgį ir atliko tą patį išvalymą) arba cancelled. stats statistiką išmatuoja serveris: audioS yra jūsų išsiųstas garsas, processingS – sesijos sienos laikas, o realtimeFactor – jų santykis. Tiesioginė sesija pasiekia kiek mažiau nei 1, nes sienos laikas apima galutinį išvalymą po jūsų eof. Žymiai mažesnis skaičius reiškia, kad sesija truko akivaizdžiai ilgiau, nei tęsėsi garsas; pirmiausia reikia pažiūrėti, kaip greitai garsas atkeliavo. Failui tas pats skaičius parodo, kiek kartų greičiau nei atkuriama garso įrašas grąžinamas nuorašas.
Sesija trunka ne ilgiau nei limits.maxDurationS iš GET /v1/realtime/capabilities, o jos garsas nurašo jūsų kreditą už sekundę pagal jūsų plano tiesioginį tarifą, su tuo pačiu 5 minučių minimumu kaip bet kuriam transkribavimui: kiekviena sesija, gaunanti garsą, apmokestinama bent 5 minutėmis (kainos). Tas pats socket ryšys priima ir visą failą: sukurkite sesiją su input: { kind: "file" }, siųskite paties failo baitus (mp3, wav, flac, ogg, m4a) ir eof, o nuorašas grįžta daug kartų greičiau, nei groja garsas, su transcript.progress kadrais pakeliui.
Atnaujinimo galimybės nėra. Nutrūkęs ryšys baigia sesiją; prisijungti iš naujo reiškia sukurti naują sesiją, kuri apmokestinama savo minimumu, ir vėl transliuoti iš jūsų tiesioginio šaltinio, o seq prasideda nuo 0. Pats prisijungimas iš naujo yra įprastas bet kurio socket API modelis, ir jį verta padaryti teisingai vieną kartą:
retryable: true error kadre, 503 su Retry-After, arba 30 sekundžių be kadro, kas reiškia capacity_exceeded. invalid_token ir session_expired reiškia naują streamUrl iš jūsų serverio, o ne dar vieną bandymą su senuoju.Sesijos kūrimo maršrutą palaikykite pigų kvietimui, o uždarymą laikykite nauju startu. Neturėti atnaujinimo – tai kaina už tai, kad nieko nesaugome; jei nuorašas turi gyventi ilgiau nei socket ryšys, jį išsaugokite.
Kai norite, kad tas pats srautas būtų įrašytas, vėliau ištaisytas, eksportuotas ir skaitomas auditorijos jų kalbomis, jį verta paskelbti tiesioginiame transkribavime. Tiesioginės transliacijos vadove ta pati PCM socket ryšio grandinė nukreipiama per transkribavimą ir transliacijos nuorodą, o klausymo vadove parodyta prenumeratoriaus pusė, kur kiekvienas klausytojas pasirenka kalbą, o vėluojantis prisijungėjęs pasivyja iš lastSeq žymeklio. Kaip tai atrodo kambariui – aptariama straipsnyje viena nuoroda, visos kalbos.
Failui, kurį norite išsaugoti, saugomas kelias yra failo transkribavimas su API. Pilną kadrų žinyną, su visais laukais, pateikia API dokumentacijos vadovas Mikrofono srautinis perdavimas.