Dalawang beses dumarating ang realtime transcript: una bilang draft na patuloy na nagbabago, saka bilang huling linya na hindi na nagbabago. I-render ang draft bilang tail na napapalitan at ang huling linya bilang kasaysayan, at ang natitira sa live na caption view ay plumbing lang.
Ang realtime session ang pinakasimpleng ginagawa ng VocalStack API. Gagawa ang iyong server ng isang HTTP call at makakakuha ng signed na streamUrl. Magbubukas ang iyong client ng WebSocket papunta sa URL na iyon, magpapadala ng raw na audio habang kinukuha ito, at babasahin ang transcript pabalik bilang mga JSON text frame, ilang sandali lang sa likod ng nagsasalita. Kapag natapos ang audio, magpapadala ka ng eof; magfa-flush ang server, ibibigay ang mga sukat ng session, at magsasara.
Walang naka-imbak. Nasa wire lang ang transcript, at iyon mismo ang kailangan ng caption view, meeting overlay, o voice feature sa sarili mong app. Kung gusto mong maitago, maibahagi, at ma-export ang mga salita pagkatapos, live transcription at broadcast ang kailangan mo; ituturo ng huling seksyon ang daan.
Halos pareho ang hugis na ito sa karamihan ng streaming speech API: isang credential exchange sa HTTP, isang mahabang socket kung saan pumapasok ang audio at lumalabas ang JSON, at isang paraan para malaman kung alin ang pansamantalang text at alin ang final. Nag-iiba ang mga pangalan mula sa isang API papunta sa susunod, pero bihirang magbago ang mga gumagalaw na bahagi. Narito, ayon sa pagkakasunod:
POST /v1/realtime/sessions gamit ang API key mo, sa server mo. Ang sagot ay { sessionId, streamUrl, expiresAt }.streamUrl.session.loading heartbeat hanggang dumating ang session.ready.transcript.draft (palitan ang tail) at ang transcript.committed (idagdag ang final na linya).{"type":"eof"}; basahin ang session.done.Tanging step 1 ang nangangailangan ng key mo, kaya sa server mo ito nangyayari.
Gumawa ng key sa ilalim ng Developers, API keys na may Write permission. Para sa mga server at script ang key: tinatanggihan ng API ang key na ipinadala mula sa web page o nasa URL, kaya gagawin mo ang session sa likod ng sarili mong route, at ang page ay streamUrl lang ang makikita.
Dala ng URL ang sarili nitong token dahil sa isang limitasyon ng browser na sulit malaman sa pangkalahatan. Hindi nakakapagtakda ng request header ang browser WebSocket constructor, kaya walang Authorization header na paglalagyan ng key; ginagamit ng bawat speech API na nakabase sa browser ang maikling buhay na token sa URL (o isiningit sa pangalan ng subprotocol) para i-authenticate ang socket. Mahalaga ang maikling buhay: napupunta ang URL sa mga log at history, kaya dapat mag-expire ang laman nito. Kumonekta bago ang expiresAt.
Dinadownload ang mga SDK mula sa Developers area, hindi mula sa package registry; ipinapakita ng Install section ng quickstart ang mga command para sa environment na ito. Gamit ang TypeScript SDK, ganito ang itsura ng server route:
// 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 };
}
Kung walang SDK, isang fetch lang. Ang isang ito ay humihiling din ng Spanish at pinapatay ang sentence assembly, para tumugma sa wire sample sa ibaba:
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 };
Lahat ng option ay may default. Ang mga dapat mong malaman sa simula ay ang spokenLanguage (kapag hindi mo ito nilagyan, aalamin ang wika), ang translate (listahan ng target na wika), ang speakerLabels at ang latencyMode; ang iba ay lalabas kapag kailangan na sa ibaba.
Ibigay ang streamUrl sa kahit anong kumukuha ng audio; mula dito, walang key na kasali.
Kumonekta sa streamUrl nang eksakto, walang header. Habang nagsisimula ang session, nagpapadala ang server ng mga session.loading heartbeat. Pagkatapos nito darating ang session.ready. Huwag magpadala ng audio bago iyon. Ang audio na naipadala nang maaga ay hindi itinatabi para sa susunod; itinuturing ito ng server bilang error, magpapadala ng error frame, at magsasara ng socket.
Kapag bukas na ang socket, ang bawat failure ay may hugis ng frame na iyon: {"type":"error","code":"…","message":"…","retryable":true|false}. Saradong listahan ang code (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), para sa mga log mo ang message, at sinasabi ng retryable kung sulit subukan ang bagong session. Hawakan ito mula sa unang frame pa lang:
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;
}
});
Kailangang asikasuhin ng raw client ang dalawa pang bagay; ginagawa na ito ng mga SDK. Una, ang tinanggihang upgrade ay may karaniwang HTTP error envelope ng API: 401 invalid_token, 410 session_expired, o 503 unavailable na may Retry-After header na dapat sundin.
Pangalawa, ang pagkasaturate ay mukhang katahimikan, hindi error. Kapag walang kahit anong frame na dumating sa loob ng 30 segundo, ituring itong capacity_exceeded at kumonekta muli; kadalasan makakapasok ang bagong session. Kung patuloy ang pagdating ng loading frame pero hindi dumarating ang session.ready, ituring itong engine_unavailable, na retryable din. Sa mga SDK, nagre-resolve ang RealtimeSession.connect sa session.ready at pumapalya pagkalipas ng readyDeadlineMs na may tamang code.
Kapag dumating na ang session.ready, inaasahan ng server ang audio sa bilis kung paano ito binibigkas.
Pumapasok ang audio bilang binary frame ng raw PCM: 32-bit float, little-endian, 16 kHz, mono (pcm_f32le, ang default na audio format ng session). Ipadala ito habang kinukuha, sa mga chunk na mga 100 hanggang 250 ms, nang hindi naghihintay ng transcript sa pagitan ng mga chunk.
Sulit gawin nang isang beses ang byte math. 16,000 sample kada segundo na may apat na byte bawat isa ay 64,000 byte kada segundo, kaya 6,400 byte ang 100 ms at 16,000 ang 250 ms. Karaniwang sinasanay ang speech model sa 16 kHz mono, kaya maraming realtime API ang humihingi nito, samantalang ang mikropono at browser ay kumukuha sa 44.1 o 48 kHz at kadalasan sa stereo. May kailangang mag-resample ng audio at pagsamahin ang mga channel sa isa bago lumabas ang mga byte: dito ay ffmpeg iyon, at sa browser ay ang microphone helper ng SDK. Ang pagpapadala ng 48 kHz na audio habang sinasabing 16 kHz ay hindi nagfa-fail; ginagawang parang slow-motion ang pagsasalita, at pangit ang transcript.
Sa Node, ang pinakasimpleng capture device ang 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());
}
Hindi naghihintay ang ws.send; pumipila lang ito. Ang bufferedAmount ang laki ng pilang iyon, at sa malusog na link ay malapit sa zero ito. Hindi napipigilan ang live na audio, kaya ang tamang tugon sa lumalaking pila ay pansinin ito, hindi magtapon ng frame. May backpressure din ang server. Tumatanggap ito ng audio nang ilang segundo nang mas maaga sa tunay na oras (limits.audioBufferS), saka humihinto sa pagbasa hanggang makahabol. Sa pagbalik, nakapila nang walang nawawala ang mga commit habang pinagsasama ang mga draft, kaya mas kaunti at mas bagong draft ang nakikita ng mabagal na reader. Ang client na hindi pa rin makasabay kahit sa mga commit ay makakatanggap ng error frame na may slow_consumer at retryable: false, at idi-disconnect; kung makita mo ito, masyadong maraming trabaho ang ginagawa ng iyong message handler.
Sa browser, humihingi ang streamMicrophone mula sa @vocalstack/sdk/mic ng mikropono, nire-resample sa 16 kHz, at ipinapadala ang mga chunk kasama ang mga clock frame, na nagpapahintulot sa server na markahan ang mga puwang kapag binagalan ng browser ang tab. Ang page ay kumukuha ng session mula sa route mo at hindi nakikita ang key:
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, '');
}
}
}
Ang render(committed, tail) na signature na iyon ang buong UI pattern; ipinapaliwanag ng susunod na seksyon kung bakit.
Dalawang uri ng frame ang bumubuo ng transcript, at ang pagkalito sa kanila ang pinakakaraniwang pagkakamali.
Una, bakit may dalawa. Nagpapasya ang speech model kung ano ang salita sa isang bahagi dahil sa mga salitang sumusunod dito: magkapareho ang tunog ng "I scream" at "ice cream" hanggang sa magpatuloy ang pangungusap. Kaya naglalabas ang realtime recogniser ng maagang hula, saka ito binabago habang may dumarating pang audio, at tumitigil lang kapag sapat na ang narinig. Ganito rin ang paraan ng bawat streaming speech API; may tumatawag sa mga hula na "interim" o "partial" na resulta at minamarkahan ang tapos na mga ito bilang is_final. Nag-iiba ang bokabularyo, pero hindi ang disiplina. Dito, ang hula ay draft at ang tapos na text ay commit.
transcript.draft ang live tail: ang mga salitang narinig mula sa huling commit, na patuloy pang nagbabago. Ang bawat draft ay buong pamalit sa nauna. I-redraw ang tail gamit ang segments nito; huwag itong idagdag. Sinadya na walang seq ang mga draft, kaya huwag subukang itugma sila sa mga commit na kalaunan nilang nagiging.
transcript.committed ang final: isang segment na may seq (0, 1, 2, tuloy-tuloy), start, end, text, speaker, at words na may timing bawat salita. Idagdag ito at alisin ang draft na sakop nito; ang susunod na draft ay magsisimula kung saan nagtapos ang commit na ito.
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
Ang UI na nagdaragdag ng draft ay nagpapakita ng paulit-ulit at kumikislap na teksto. Nagkakaroon ng utal ang caption view mo na wala sa nagsasalita. I-render ang lahat ng committed segment, saka ang pinakabagong draft sa dulo, at burahin ang draft kapag may dumating na commit. Maaaring maglaman ang commit ng mga salitang hindi lumitaw sa kahit anong draft.
Narito ang ilang segundo ng pagsasalita sa wire, mula sa halimbawang fetch sa itaas (translate: ["es"], naka-off ang sentence assembly):
{"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}]}
May dalawa pang frame sa paligid nito. Itinatama ng transcript.amended ang isang committed segment sa lugar mismo (parehong seq, bagong text o speaker) kapag may naitama sa salita nito; ilapat ito sa linyang nailabas mo na. Nagsi-stream ang transcript.words ng word timing para sa tail bago ito ma-commit. Kailangan nito ang events.words, na naka-on bilang default; laging kasama sa commit ang words nito, anuman ang sabi ng event options. Sa huli, huwag pansinin ang mga uri ng frame na hindi mo kilala: nagdadagdag ng bago nang walang abiso.
Narito ang reading loop ng Python SDK, na ang terminal ang caption view. Naka-off din dito ang sentence assembly, kaya dumarating ang mga salin bilang transcript.translated, at ang error frame ay lumalabas bilang VocalStackError mula sa loop:
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())
Ang per-segment na pagsasalin, gaya ng nasa itaas, ang simpleng anyo. Ang anyong mas gusto ng mga reader ay may sariling seksyon.
Humihiling ang translate: ["es", "fr"] ng mga pagsasalin habang tumatakbo ang session. Nakadepende kung paano sila dumarating sa sentence assembly (sentences: { enabled: true }), na naka-on bilang default.
Kapag naka-off ang sentences, sinusundan ng transcript.translated frame ang bawat commit ilang sandali pagkatapos, na tumutukoy sa seq nito at nagdadala ng translations na nakasusi sa wika; ikabit ang mga ito sa segment na iyon. Ito ang ginamit ng mga halimbawa hanggang ngayon. Simple ito at parang commit log: isang segment, isang salin, at ang hangganan ng pangungusap ay nasaan man huminga ang nagsasalita.
Kapag naka-on ang sentences, sinasama-sama ng server ang mga commit sa buong pangungusap at isinasalin ang mga iyon, na mas mainam basahin. Bawat transcript.sentence frame ay buong pamalit sa isang pangungusap, na nakasusi sa sentenceId. Nagbabago ang status nito mula open papunta sa closed (o dropped, na ibig sabihin ay itapon), at provisional ang translations nito habang bukas, at final kapag sarado na. I-upsert ayon sa sentenceId, huwag idagdag. Ang TypeScript SDK ay may kasamang applySentence para eksakto dito:
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}`);
}
Nahuhuli ng isa o dalawang segundo ang mga salin sa transcript. Kung gusto mo rin itong bigkasin, idagdag ang voice: ["es"] (kailangan nito ang sentences). Ang salin sa Espanyol ng bawat sarado na pangungusap ay darating bilang transcript.voice frame, na may dalang clip na base64 Ogg/Opus sa 24 kHz mono. Ang mahabang pangungusap ay dumarating sa mga piraso (part ng parts) na pinapatugtog nang sunod-sunod. Hindi kailanman binibigkas ang provisional na text. Sakop ng live translation guide ang mga voice nang detalyado, at ipinapaliwanag ng kung saan napupunta ang mga segundo ang chain ng pagkaantala.
Hindi naayos ang mga target na wika sa paggawa ng session.
Binabago ng text frame na session.update ang spokenLanguage, translate, speakerLabels, latencyMode, voice o voiceByLang mula sa puntong iyon; ang mga field na hindi mo nilagyan ay hindi nagbabago.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
Inilalantad ng mga SDK ito bilang session.update(…). Pinapalitan ng translate ang buong listahan, at ang [] ay nagpapatay ng pagsasalin. Hindi na muling tinatranscribe o isinasalin ang mga segment na naihatid na, at kung i-on mo ang speaker labels, mula sa puntong iyon lang lalagyan ng label ang mga nagsasalita. Ang nasabi na ay nasabi na; ang settings ay nagdedesisyon lang para sa susunod.
Kapag natapos ang audio, ipadala ang {"type":"eof"} (session.eof() sa mga SDK). Tinatranscribe ng server ang natitira pa nitong hawak, ipinapadala ang huling mga commit, saka ang session.done at nagsasara. Ang pagpapadala ng cancel sa halip ay humihinto agad at itinatapon ang natitira.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
Ang reason ay eof, max_duration (naabot ng session ang pinakamahabang haba ng plan at tumakbo ang parehong flush) o cancelled. Sinusukat ng server ang stats: ang audioS ang audio na ipinadala mo, ang processingS ang oras ng pader na tumakbo ang session, at ang realtimeFactor ang ratio ng dalawa. Nasa bahagyang ibaba ng 1 ang live na session, dahil kasama sa oras ng pader ang huling flush pagkatapos ng eof mo. Kapag malayo itong mas mababa doon, mas matagal tumakbo ang session kaysa haba ng audio; ang unang titingnan ay kung gaano kabilis dumating ang audio. Para sa file, ipinapakita rin ng parehong numero kung ilang beses na mas mabilis sa playback bumalik ang transcript.
Hanggang limits.maxDurationS mula sa GET /v1/realtime/capabilities ang tagal ng session, at ginagastos ng audio nito ang credit mo kada segundo sa live rate ng plan mo, na may parehong 5-minutong minimum ng anumang transcription: bawat session na tumanggap ng audio ay sinisingil nang hindi bababa sa 5 minuto (pricing). Tumatanggap din ang parehong socket ng buong file: gawin ang session na may input: { kind: "file" }, ipadala ang mismong bytes ng file (mp3, wav, flac, ogg, m4a) at eof, at babalik ang transcript nang maraming beses na mas mabilis kaysa playback ng audio, kasama ang mga transcript.progress frame sa daan.
Walang resume. Nagtatapos ang session kapag naputol ang koneksyon; ang pagkonekta muli ay nangangahulugang gumawa ng bagong session, na sisingilin ng sarili nitong minimum, at mag-stream muli mula sa live source mo, na nagsisimula ulit ang seq sa 0. Ang reconnect mismo ay karaniwang pattern para sa anumang socket API, at sulit gawing tama nang isang beses:
retryable: true sa error frame, 503 na may Retry-After, o 30 segundong walang frame, na katumbas ng capacity_exceeded. Ang invalid_token at session_expired ay nangangahulugang bagong streamUrl mula sa server mo, hindi isa pang subok gamit ang luma.Panatilihing mura tawagin ang route na gumagawa ng session, at ituring ang pagsara bilang bagong simula. Ang kawalan ng resume ay bahagi ng presyo ng hindi pag-iimbak ng kahit ano; kung kailangang mabuhay ang transcript nang lampas sa socket, itago mo ito.
Kapag gusto mong maitabi ang parehong stream bilang recording, maitama pagkatapos, ma-export, at mabasa ng audience sa sarili nilang wika, i-publish ito sa live transcription. Dinadaanan ng go live guide ang parehong PCM socket sa pamamagitan ng transcription at broadcast link, at ipinapakita ng listen guide ang panig ng subscriber, kung saan pumipili ng wika ang bawat listener at ang late joiner ay nakakahabol mula sa lastSeq cursor. Ang hitsura nito para sa isang kuwarto ang paksa ng Isang link, bawat wika.
Para sa file na gusto mong itabi, ang naka-store na route ay transcribe ng file gamit ang API. Ang buong reference ng frame, bawat field kasama, ay nasa gabay na Stream a microphone sa API reference.