Reāllaika transkripts ierodas divreiz: vispirms kā uzmetums, kas turpina mainīties, un tikai pēc tam kā galīga rinda, kas vairs nemainās. Attēlojiet uzmetumu kā aizstājamu asti, bet galīgo rindu kā vēsturi, un viss pārējais tiešraides subtitru skatā ir tehniska sagatavošana.
Reāllaika sesija ir mazākā lieta, ko VocalStack API veic. Jūsu serveris veic vienu HTTP izsaukumu un saņem atpakaļ parakstītu streamUrl. Jūsu klients atver WebSocket savienojumu ar šo URL, nosūta neapstrādātu audio, kamēr tas tiek ierakstīts, un nolasa transkriptu atpakaļ kā JSON teksta kadrus, kas seko runātājam dažu mirkļu attālumā. Kad audio beidzas, jūs nosūtāt eof; serveris pabeidz atlikušo apstrādi, ziņo, ko sesija izmērīja, un aizver savienojumu.
Nekas netiek glabāts. Transkripts pastāv tikai tīklā, un tieši to vajag subtitru skatam, sapulces pārklājumam vai balss funkcijai jūsu pašu lietotnē. Ja vēlaties vārdus saglabāt, kopīgot un eksportēt vēlāk, jums vajag tiešraides transkripciju un pārraidi; pēdējā sadaļa norāda uz to.
Shēma ir tāda, kādu lielākā daļa straumēšanas runas API izmanto: akreditācijas datu apmaiņa caur HTTP, ilgstošs savienojums, pa kuru audio ieiet un JSON iziet, un kāds veids, kā atšķirt provizorisko tekstu no galīgā. Nosaukumi atšķiras no viena API līdz otram; kustīgās daļas gandrīz nekad. Šeit – pēc kārtas:
POST /v1/realtime/sessions ar jūsu API atslēgu jūsu serverī. Atbilde ir { sessionId, streamUrl, expiresAt }.streamUrl.session.loading signāliem līdz session.ready.transcript.draft (aizstāt asti) un transcript.committed (pievienot galīgu rindu).{"type":"eof"}; nolasiet session.done.Tikai 1. solis prasa jūsu atslēgu, tāpēc tas notiek jūsu serverī.
Izveidojiet atslēgu sadaļā Developers, API keys ar Write atļauju. Atslēga paredzēta serveriem un skriptiem: API noraida atslēgu, kas nosūtīta no tīmekļa lapas vai URL, tāpēc sesiju veidojiet aiz sava maršruta, un lapa redz tikai streamUrl.
URL pats nes savu marķieri šīs pārlūka ierobežojuma dēļ, ko vērts zināt vispārīgi. Pārlūka WebSocket konstruktors nevar iestatīt pieprasījuma galvenes, tāpēc nav Authorization galvenes, kurā ievietot atslēgu; katrs pārlūkā lietojams runas API autentificē savu savienojumu ar īslaicīgu marķieri URL (vai iebāžot to apakšprotokola nosaukumā). Īslaicīgums ir pats mērķis: URL nonāk žurnālos un vēsturē, tāpēc tam ietvertajam jāizbeidzas. Savienojieties pirms expiresAt.
SDK tiek lejupielādēti no Developers apgabala, nevis no pakotņu reģistra; quickstart sadaļa Install parāda šīs vides pašas komandas. Ar TypeScript SDK servera maršruts izskatās šādi:
// 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 tas ir viens fetch. Šis arī pieprasa spāņu valodu un izslēdz teikumu veidošanu, atbilstoši tālākajam vadu piemēram:
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 };
Katrai opcijai ir noklusējums. Tās, kas vērts zināt jau sākumā, ir spokenLanguage (ja to izlaižat, valoda tiek noteikta automātiski), translate (mērķa valodu saraksts), speakerLabels un latencyMode; pārējās parādīsies pēc vajadzības tālāk.
Nododiet streamUrl tam, kas uzņem audio; no šī brīža atslēga vairs nav vajadzīga.
Savienojieties ar streamUrl tādu, kāds tas ir, bez galvenes. Kamēr sesija sākas, serveris sūta session.loading signālus. Tad nāk session.ready. Pirms tā audio nesūtiet. Agri nosūtīts audio netiek saglabāts vēlākai lietošanai; serveris to uzskata par kļūdu, nosūta error kadru un aizver savienojumu.
Kad savienojums ir atvērts, katra kļūda ir šīs kadra formas: {"type":"error","code":"…","message":"…","retryable":true|false}. code ir slēgts saraksts (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message ir domāts jūsu žurnāliem, un retryable norāda, vai jaunas sesijas mēģinājums ir vērts. Apstrādājiet to jau no pirmā kadra:
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;
}
});
Neapstrādāts klients pats jāapstrādā vēl divas lietas; SDK tās jau dara. Pirmkārt, noraidīts savienojuma atjauninājums nes API parasto HTTP kļūdu aploksni: 401 invalid_token, 410 session_expired vai 503 unavailable ar Retry-After galveni, kas jāievēro.
Otrkārt, pārslodze izskatās pēc klusuma, nevis kļūdas. Ja neviens kadrs nenāk 30 sekunžu laikā, traktējiet to kā capacity_exceeded un savienojieties vēlreiz; jaunā sesija parasti iziet cauri. Ja session.loading kadri turpina nākt, bet session.ready nekad nenāk, traktējiet to kā engine_unavailable, kas arī ir atkārtojams. SDK RealtimeSession.connect atgriežas pie session.ready un pēc readyDeadlineMs neizdodas ar pareizo kodu.
Kad session.ready ir pienācis, serveris sagaida audio tādā tempā, kādā tas tiek runāts.
Audio ieiet kā bināri PCM kadri: 32 bitu peldošā komata (float), little-endian, 16 kHz, mono (pcm_f32le, sesijas noklusējuma audio formāts). Sūtiet to, kā tas tiek ierakstīts, fragmentos apmēram 100 līdz 250 ms, negaidot transkriptu starp fragmentiem.
Baitu aritmētiku vērts izdarīt vienreiz. 16 000 paraugi sekundē ar 4 baitiem katrs ir 64 000 baitu sekundē, tātad 100 ms ir 6 400 baiti un 250 ms ir 16 000. Runas modeļi parasti tiek apmācīti ar 16 kHz mono, tāpēc tik daudzi reāllaika API to prasa, kamēr mikrofoni un pārlūki ieraksta 44,1 vai 48 kHz un bieži stereo. Kaut kam jāpārveido paraugu frekvence un jāapvieno kanāli vienā, pirms baiti aiziet: šeit tas ir ffmpeg, bet pārlūkā – SDK mikrofona palīgs. Nosūtīt 48 kHz audio un deklarēt 16 kHz nenoved pie kļūdas; tas transkribē palēninātu runu, un to dara slikti.
Node vidē ffmpeg ir vienkāršākā ierakstīšanas ierīce:
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 nekad nebloķē; tas ieliek rindā. bufferedAmount ir šīs rindas izmērs, un veselīgā savienojumā tas svārstās ap nulli. Tiešraides audio nevar apturēt, tāpēc noderīga reakcija uz augošu rindu ir to pamanīt, nevis izmest kadrus. Serveris arī pielieto pretspiedienu. Tas pieņem audio dažas sekundes priekšā reālajam laikam (limits.audioBufferS), tad pārstāj lasīt, līdz panāk. Atpakaļceļā apstiprinājumi tiek ieliktu rindā bez zaudējumiem, bet uzmetumi tiek apvienoti, tāpēc lēnais lasītājs redz mazāk, bet svaigākus uzmetumus. Klients, kas nespēj tikt līdzi pat apstiprinājumiem, saņem error kadru ar slow_consumer un retryable: false un tiek atvienots; ja to redzat, jūsu ziņojumu apstrādātājs dara pārāk daudz.
Pārlūkā streamMicrophone no @vocalstack/sdk/mic pieprasa mikrofonu, pārveido uz 16 kHz un sūta fragmentus kopā ar clock kadriem, kas ļauj serverim atzīmēt spraugas, kad pārlūks palēnina cilni. Lapa sesiju saņem no jūsu maršruta un nekad neredz atslēgu:
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) paraksts ir viss UI modelis; nākamā sadaļa paskaidro, kāpēc.
Divi kadru tipi veido transkriptu, un to sajaukšana ir visbiežākā kļūda.
Vispirms, kāpēc vispār divi. Runas modelis nosaka, ko vārds nozīmēja, daļēji pēc vārdiem, kas seko: „I scream“ un „ice cream“ skan vienādi, kamēr teikums nav beidzies. Tāpēc reāllaika atpazinējs izsniedz minējumu agri un pēc tam to labo, kad dzird vairāk audio, un nofiksējas tikai tad, kad ir dzirdējis pietiekami. Katrs straumēšanas runas API to kaut kā atklāj. Daži minējumus sauc par „starprezultātiem“ vai „daļējiem“ un galīgos atzīmē ar is_final; nosaukumi atšķiras, disciplīna ne. Šeit minējums ir uzmetums, bet nofiksētais teksts ir apstiprinājums.
transcript.draft ir dzīvā aste: vārdi, kas dzirdēti kopš pēdējā apstiprinājuma, joprojām mainās. Katrs uzmetums pilnībā aizstāj iepriekšējo. Pārzīmējiet asti ar tās segments; nekad to nepievienojiet. Uzmetumiem dizaina dēļ nav seq, tāpēc nemēģiniet tos saskaņot ar apstiprinājumiem, kuros tie kļūst.
transcript.committed ir galīgs: viens segment ar seq (0, 1, 2, bez caurumiem), start, end, text, speaker un words ar katra vārda laikiem. Pievienojiet to un nometiet uzmetumu, ko tas aptver; nākamais uzmetums sākas tur, kur beidzās šis apstiprinājums.
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
Lietotāja saskarne, kas pievieno uzmetumus, rāda dublētu, mirgojošu tekstu, un jūsu subtitru skatā parādās stostīšanās, ko runātājs nekad nebija radījis. Atveidojiet visus apstiprinātos segmentus, pēc tiem pēdējo uzmetumu, un notīriet uzmetumu, kad ierodas apstiprinājums. Apstiprinājums var saturēt vārdus, kas nekad nav parādījušies nevienā uzmetumā.
Šeit ir dažas sekundes runas vadā no iepriekšējā fetch piemēra (translate: ["es"], teikumu veidošana izslēgta):
{"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}]}
Divi vairāk kadru tipi atrodas ap šiem. transcript.amended labo apstiprinātu segmentu uz vietas (tas pats seq, jauns text vai speaker), kad kāds tā vārds ir labots; piemērojiet to rindai, ko jau esat uzzīmējis. transcript.words straumē vārdu laikus asti tieši pirms tās apstiprināšanas. Tam nepieciešams events.words, kas ir ieslēgts pēc noklusējuma; apstiprinājums vienmēr ietver savus words, neatkarīgi no notikumu opcijām. Visbeidzot, ignorējiet nezināmus kadru tipus: jaunie tiek pievienoti bez brīdinājuma.
Šeit ir Python SDK lasīšanas cilpa ar termināli kā subtitru skatu. Teikumu veidošana arī šeit ir izslēgta, tāpēc tulkojumi ierodas kā transcript.translated, un error kadrs parādās kā VocalStackError, kas tiek izmests no cilpas:
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())
Segmentu tulkošana, kā iepriekš, ir vienkāršā forma. Forma, ko lasītāji dod priekšroku, saņem atsevišķu sadaļu.
translate: ["es", "fr"] pieprasa tulkojumus, kamēr sesija norit. Kā tie ierodas, ir atkarīgs no teikumu veidošanas (sentences: { enabled: true }), kas pēc noklusējuma ir ieslēgta.
Ar izslēgtiem teikumiem katram apstiprinājumam pēc brīža seko transcript.translated kadrs, kas nosauc tā seq un nes translations, atslēgtus pēc valodas; piestipriniet tos šim segmentam. Tieši šos piemērus iepriekš esam izmantojuši. Tas ir vienkārši un atgādina apstiprinājumu žurnālu: viens segments, viens tulkojums, teikuma robeža tur, kur runātājs ievilka elpu.
Ar ieslēgtiem teikumiem serveris apvieno apstiprinājumus veselos teikumos un tulko tos, un tas lasās daudz labāk. Katrs transcript.sentence kadrs pilnībā aizstāj vienu teikumu, ko atslēgo sentenceId. Tā status pāriet no open uz closed (vai dropped, kas nozīmē to izmest), un tā translations ir provizoriski, kamēr teikums ir atvērts, un galīgi, kad tas aizveras. Atjauniniet pēc sentenceId, nekad nepievienojot. TypeScript SDK ir applySentence tieši šim nolūkam:
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}`);
}
Tulkojumi seko transkriptam par sekundi vai divām. Ja vēlaties, lai tie tiktu arī izrunāti, pievienojiet voice: ["es"] (tam nepieciešami teikumi). Katra slēgta teikuma spāņu valodas tulkojums tad ierodas kā transcript.voice kadrs, kas nes klipu kā base64 Ogg/Opus 24 kHz mono. Garš teikums ierodas daļās (part no parts), lai tās atskaņotu cita aiz citas. Provizoriskais teksts nekad netiek izrunāts. Tiešraides tulkošanas ceļvedis sīki aplūko balsis, un kur paiet sekundes izskaidro kavēšanās ķēdi.
Mērķa valodas netiek fiksētas pie izveides.
Teksta kadrs session.update maina spokenLanguage, translate, speakerLabels, latencyMode, voice vai voiceByLang no šā brīža; lauki, ko izlaižat, paliek nemainīti.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK to atklāj kā session.update(…). translate aizstāj visu sarakstu, un [] izslēdz tulkošanu. Jau piegādātie segmenti netiek atkārtoti transkribēti vai atkārtoti tulkoti, un, ja ieslēdzat runātāju marķējumu, runātāji tiek marķēti tikai no šā brīža. Teiktais paliek teiktais; iestatījumi nosaka tikai to, kas nāks tālāk.
Kad audio beidzas, nosūtiet {"type":"eof"} (session.eof() SDK). Serveris transkribē to, ko vēl tur, nosūta pēdējos apstiprinājumus, tad session.done un aizver savienojumu. Ja sūtat cancel, tas apstājas uzreiz un atmet atlikušo.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason ir eof, max_duration (sesija sasniedza plāna maksimālo garumu un izpildīja to pašu noslēgumu) vai cancelled. stats mēra serveris: audioS ir audio, ko nosūtījāt, processingS ir sienas pulksteņa laiks, cik ilgi sesija darbojās, un realtimeFactor ir to attiecība. Tiešraides sesija sasniedz nedaudz zem 1, jo sienas pulksteņa laiks ietver galīgo noslēgumu pēc jūsu eof. Skaitlis ievērojami zemāk nozīmē, ka sesija ilga manāmi ilgāk nekā audio; pirmkārt jāpārbauda, cik ātri audio ienāca. Failam tas pats skaitlis parāda, cik reižu ātrāk nekā atskaņošana transkripts atgriezās.
Sesija ilgst ne vairāk kā limits.maxDurationS no GET /v1/realtime/capabilities, un tās audio tērē jūsu kredītu par sekundi pēc plāna tiešraides likmes, ar to pašu 5 minūšu minimumu kā jebkurai transkripcijai: katra sesija, kas saņem audio, tiek norēķināta vismaz uz 5 minūtēm (cenas). Tas pats savienojums pieņem arī veselu failu: izveidojiet sesiju ar input: { kind: "file" }, nosūtiet paša faila baitus (mp3, wav, flac, ogg, m4a) un eof, un transkripts atgriežas daudzkārt ātrāk nekā audio atskaņojas, ar transcript.progress kadriem pa ceļam.
Atsākšanas nav. Pārtraukts savienojums izbeidz sesiju; atkārtoti savienoties nozīmē izveidot jaunu sesiju, kas tiek norēķināta ar savu minimumu, un atkal straumēt no jūsu tiešraides avota, ar seq atkal no 0. Pats atkārtota savienojuma veidošana ir standarta modelis jebkuram WebSocket API, un to vērts reiz izdarīt pareizi:
retryable: true error kadrā, 503 ar tā Retry-After, vai 30 sekundes bez kadra, kas nozīmē capacity_exceeded. invalid_token un session_expired nozīmē jaunu streamUrl no jūsu servera, nevis vēl vienu mēģinājumu ar veco.Turiet sesiju veidojošo maršrutu lētu izsaukšanai un traktējiet aizvēršanu kā jaunu sākumu. Nepiešķirtā atsākšana ir cena par neglabāšanu; ja transkriptam jādzīvo ilgāk par savienojumu, glabājiet to pats.
Kad vēlaties, lai tā pati straume tiktu saglabāta kā ieraksts, vēlāk labota, eksportēta un nolasīta auditorijai viņu valodās, publicējiet to tiešraides transkripcijā. Go live ceļvedis parāda to pašu PCM savienojumu caur transkripciju un pārraides saiti, un listen ceļvedis parāda abonenta pusi, kur katrs klausītājs izvēlas valodu un vēlu pievienojies atjauno no lastSeq rādītāja. Kā tas izskatās telpai, ir viena saite, visas valodas tēma.
Failam, kuru vēlaties saglabāt, glabātais maršruts ir transkribēt failu ar API. Pilna kadru atsauce ar katru lauku ir Stream a microphone ceļvedis API atsaucē.