’n Intydse transkripsie kom twee keer aan: eers as ’n konsep wat aanhoudend verander, en dan as ’n finale lyn wat nie weer verander nie. Gee die konsep as ’n vervangbare stert en die finale lyn as geskiedenis, en die res van ’n regstreekse onderskrifaansig is slegs verbindingswerk.
’n Intydse sessie is die kleinste ding wat die VocalStack-API doen. Jou bediener maak een HTTP-oproep en kry ’n getekende streamUrl terug. Jou kliënt open ’n WebSocket na daardie URL, stuur rou oudio soos dit opgevang word, en lees die transkripsie terug as JSON-teksrame, net ’n oomblik agter die spreker. Wanneer die oudio eindig, stuur jy eof; die bediener spoel uit, rapporteer wat die sessie gemeet het, en maak toe.
Niks word gestoor nie. Die transkripsie bestaan net op die draad, wat is wat ’n onderskrifaansig, ’n vergaderingsoorlegging of ’n stemkenmerk in jou eie app wil hê. As jy wil hê die woorde moet agterna behou, gedeel en uitgevoer word, wil jy eerder ’n regstreekse transkripsie en ’n uitsending hê; die laaste afdeling wys daarheen.
Die vorm is dié wat die meeste stroomsprek-API’s deel: ’n geloofsbriefuitruil oor HTTP, ’n langlewende verbinding met oudio wat ingaan en JSON wat uitkom, en ’n manier om voorlopige teks van finale teks te onderskei. Die name verskil van die een API na die ander; die bewegende dele verskil selde. Hier, in volgorde:
POST /v1/realtime/sessions met jou API-sleutel, op jou bediener. Die antwoord is { sessionId, streamUrl, expiresAt }.streamUrl.session.loading-hartklopse tot session.ready.transcript.draft (vervang die stert) en transcript.committed (voeg ’n finale lyn by).{"type":"eof"}; lees session.done.Slegs stap 1 benodig jou sleutel, so dit gebeur op jou bediener.
Skep ’n sleutel onder Developers, API keys met die Write-toestemming. ’n Sleutel is vir bedieners en skripte: die API weier een wat van ’n webblad af of in ’n URL gestuur word, so jy skep die sessie agter ’n roete van jou eie, en die bladsy sien ooit net die streamUrl.
Die URL dra sy eie token weens ’n blaaierbeperking wat die moeite werd is om in die algemeen te ken. Die blaaier se WebSocket-konstruktor kan nie koptekste stel nie, so daar is geen Authorization-kopteks om ’n sleutel in te sit nie; elke blaaiergerigte spraak-API verifieer sy verbinding met ’n kortlewende token in die URL (of ingesmokkel in ’n subprotokolnaam). Kortlewend is die punt: ’n URL beland in logs en geskiedenis, so wat dit dra moet verval. Koppel voor expiresAt.
Die SDK’s word van die Developers-area afgelaai, nie van ’n pakketregister nie; die Install-afdeling van die quickstart wys hierdie omgewing se eie opdragte. Met die TypeScript-SDK lyk ’n bedieneroete so:
// 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 };
}
Sonder ’n SDK is dit een fetch. Hierdie een vra ook vir Spaans en skakel sinsbou af, ooreenstemmend met die draadvoorbeeld hieronder:
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 };
Elke opsie het ’n verstek. Die wat die moeite werd is om vroeg te ken, is spokenLanguage (laat dit weg en die taal word outomaties bepaal), translate (’n lys teikentale), speakerLabels en latencyMode; die res kom soos benodig hieronder.
Gee die streamUrl aan wat ook al die oudio opvang; van hier af is geen sleutel betrokke nie.
Koppel aan streamUrl soos dit is, sonder kopteks. Terwyl die sessie begin, stuur die bediener session.loading-hartklopse. Dan kom session.ready. Stuur geen oudio voor dit nie. Oudio wat vroeg gestuur word, word nie vir later bewaar nie; die bediener behandel dit as ’n fout, stuur ’n error-raam en maak die verbinding toe.
Sodra die verbinding oop is, neem elke fout die vorm van daardie raam aan: {"type":"error","code":"…","message":"…","retryable":true|false}. Die code is ’n geslote lys (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message is vir jou logs, en retryable sê vir jou of ’n nuwe sessie die moeite werd is om te probeer. Hanteer dit vanaf die eerste raam af:
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;
}
});
’n Rou kliënt moet nog twee dinge self hanteer; die SDK’s doen dit reeds. Eerstens dra ’n geweierde opgradering die API se gewone HTTP-foutomhulsel: 401 invalid_token, 410 session_expired, of 503 unavailable met ’n Retry-After-kopteks om te eerbiedig.
Tweedens lyk versadiging soos stilte, nie soos ’n fout nie. As geen enkele raam binne 30 sekondes aankom nie, behandel dit as capacity_exceeded en koppel weer; ’n nuwe sessie kom gewoonlik deur. As laairame aanhou aankom maar session.ready nooit, behandel dit as engine_unavailable, wat ook herprobeerbaar is. In die SDK’s los RealtimeSession.connect op by session.ready en misluk na readyDeadlineMs met die regte kode.
Sodra session.ready aankom, verwag die bediener oudio teen die tempo waarteen dit gespreek word.
Oudio gaan in as binêre rame van rou PCM: 32-bis-drywendekoma, little-endian, 16 kHz, mono (pcm_f32le, die sessie se verstek audio-formaat). Stuur dit soos dit opgevang word, in stukkies van omtrent 100 tot 250 ms, sonder om op die transkripsie tussen stukkies te wag.
Die greepberekening is die moeite werd om een keer te doen. 16,000 monsters per sekonde teen 4 grepe elk is 64,000 grepe per sekonde, so 100 ms is 6,400 grepe en 250 ms is 16,000. Spraakmodelle word gewoonlik op 16 kHz mono afgerig, en daarom vra soveel intydse API’s daarvoor, terwyl mikrofone en blaaiers teen 44.1 of 48 kHz opneem en dikwels in stereo. Iets moet die oudio herbemonster en die kanale tot een saamvou voordat die grepe uitgaan: hier is dit ffmpeg, en in die blaaier is dit die SDK se mikrofoonhulp. Stuur 48 kHz-oudio en verklaar 16 kHz, en dit misluk nie; dit transkribeer stadige spraak, sleg.
In Node is ffmpeg die eenvoudigste opnametoestel:
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 blokkeer nooit nie; dit ry in die tou. bufferedAmount is die grootte van daardie tou, en op ’n gesonde skakel hang dit naby nul. Lewendige oudio kan nie onderbreek word nie, so die nuttige reaksie op ’n groeiende tou is om dit op te let, nie om rame te laat val nie. Die bediener pas ook terugdruk toe. Dit aanvaar oudio ’n paar sekondes vooruit van werklike tyd (limits.audioBufferS), en lees dan nie weer voordat dit ingehaal het nie. Op die terugweg word vasleggings sonder verlies in ’n tou geplaas terwyl konsepte saamgevoeg word, so ’n stadige leser sien minder, nuwer konsepte. ’n Kliënt wat selfs met die vasleggings nie kan byhou nie, ontvang ’n error-raam met slow_consumer en retryable: false, en word ontkoppel; as jy dit sien, doen jou boodskaphanteerder te veel werk.
In ’n blaaier vra streamMicrophone van @vocalstack/sdk/mic om die mikrofoon, herbemonster na 16 kHz, en stuur die stukkies saam met clock-rame, wat die bediener help om gapings te merk wanneer die blaaier die blad vertraag. Die bladsy kry die sessie van jou roete en sien nooit die sleutel nie:
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, '');
}
}
}
Daardie render(committed, tail)-handtekening is die hele UI-patroon; die volgende afdeling verduidelik hoekom.
Twee raamtipes bou die transkripsie, en om dit te verwar is die algemeenste fout.
Eerstens, waarom daar twee is. ’n Spraakmodel besluit wat ’n woord was deels uit die woorde wat dit volg: "I scream" en "ice cream" klink dieselfde totdat die sin verder gaan. ’n Intydse herkenner stuur dus vroeg ’n raaiwerk uit, hersien dit namate meer oudio aankom, en eers vas wanneer dit genoeg gehoor het. Elke stroomsprek-API stel dit op een of ander manier bloot. Sommige noem die raaiwerke "tussentydse" of "gedeeltelike" resultate en merk die vasgestelde resultate met is_final; die woordeskat wissel, die dissipline nie. Hier is die raaiwerk ’n konsep en die vasgestelde teks ’n vasleg.
transcript.draft is die lewendige stert: die woorde wat sedert die laaste vasleg gehoor is, wat nog verander. Elke konsep vervang die vorige een in sy geheel. Teken die stert opnuut met sy segments; voeg dit nooit by nie. Konsepte dra by ontwerp geen seq nie, so moenie probeer om hulle te pas by die vasleggings waarin hulle word nie.
transcript.committed is finaal: een segment met seq (0, 1, 2, sonder gapings), start, end, text, speaker en words met woordtydsberekening per woord. Voeg dit by en laat die konsep wat dit dek val; die volgende konsep begin waar hierdie vasleg geëindig het.
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
’n UI wat konsepte byvoeg, wys gedupliseerde, flikkerende teks. Jou onderskrifaansig ontwikkel ’n stotter wat die spreker nooit gehad het nie. Teken alle vasgelegde segmente, dan die jongste konsep daarna, en maak die konsep skoon wanneer ’n vasleg aankom. ’n Vasleg kan woorde bevat wat nooit in enige konsep verskyn het nie.
Hier is ’n paar sekondes spraak op die draad, uit die fetch-voorbeeld hierbo (translate: ["es"], sinsbou afgeskakel):
{"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}]}
Twee meer rame lê rondom hierdie. transcript.amended korrigeer ’n vasgelegde segment op sy plek (dieselfde seq, nuwe text of speaker) wanneer ’n woord daarin reggestel is; pas dit toe op die lyn wat jy reeds geteken het. transcript.words stroom woordtydsberekening vir die stert net voor dit vasgelê word. Dit benodig events.words, wat standaard aan is; ’n vasleg dra altyd sy words in, wat ook al die gebeurtenisopsies sê. Ignoreer ten slotte rametipes wat jy nie ken nie: nuwe word sonder kennisgewing bygevoeg.
Hier is die Python-SDK se leeslus, met die terminaal as die onderskrifaansig. Sinsbou is hier ook afgeskakel, so vertalings kom as transcript.translated aan, en die error-raam verskyn as ’n VocalStackError wat uit die lus gelig word:
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())
Vertaling per segment, soos hierbo, is die eenvoudige vorm. Die vorm wat lesers verkies kry sy eie afdeling.
translate: ["es", "fr"] vra vir vertalings soos die sessie vorder. Hoe dit aankom, hang af van sinsbou (sentences: { enabled: true }), wat standaard aan is.
Met sinne afgeskakel volg elke vasleg ’n oomblik later ’n transcript.translated-raam wat sy seq noem en translations dra wat volgens taal gesleutel is; heg dit aan daardie segment. Dit is wat die voorbeelde tot dusver gebruik het. Dit is eenvoudig en lyk soos ’n vasleglogboek: een segment, een vertaling, die sinsgrens waar die spreker asem gehaal het.
Met sinne aan stel die bediener vasleggings saam tot hele sinne en vertaal dié, wat baie beter lees. Elke transcript.sentence-raam is ’n volledige vervanging van een sin, gesleutel volgens sentenceId. Sy status gaan van open na closed (of dropped, wat beteken gooi dit weg), en sy translations is voorlopig terwyl dit oop is en finaal sodra dit sluit. Werk op volgens sentenceId, voeg nooit by nie. Die TypeScript-SDK lewer applySentence presies vir dit:
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}`);
}
Vertalings volg die transkripsie een of twee sekondes agter. As jy wil hê dit moet ook uitgespreek word, voeg voice: ["es"] by (dit benodig sinne). Die Spaans vir elke gesluite sin kom dan as ’n transcript.voice-raam aan, met die klip as base64 Ogg/Opus teen 24 kHz mono. ’n Lang sin kom in stukkies (part van parts) om agtermekaar terug te speel. Voorlopige teks word nooit uitgespreek nie. Die regstreekse vertalingsgids dek stemme in detail, en waar die sekondes weggaan verduidelik die vertragingsketting.
Teikentale word nie by skepping vasgelê nie.
’n session.update-teksraam verander spokenLanguage, translate, speakerLabels, latencyMode, voice of voiceByLang vanaf daardie punt af; velde wat jy weglaat, bly onveranderd.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
Die SDK’s stel dit bloot as session.update(…). translate vervang die hele lys, en [] skakel vertaling af. Segmente wat reeds afgelewer is, word nie weer getranskribeer of vertaal nie, en as jy sprekeretikette aanskakel, word sprekers slegs vanaf daardie punt benoem. Wat gesê is, bly gesê; die instellings bepaal net wat volgende kom.
Wanneer die oudio eindig, stuur {"type":"eof"} (session.eof() in die SDK’s). Die bediener transkribeer wat dit nog hou, stuur die laaste vasleggings, dan session.done en maak toe. Om cancel te stuur, stop dadelik en gooi die res weg.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason is eof, max_duration (die sessie het die plan se maksimum lengte bereik en dieselfde afspoeling uitgevoer) of cancelled. stats word deur die bediener gemeet: audioS is die oudio wat jy gestuur het, processingS is die wandklok-tyd wat die sessie geduur het, en realtimeFactor is hul verhouding. ’n Lewendige sessie land net onder 1, omdat die wandklok-tyd die finale afspoeling na jou eof insluit. ’n Getal ver daaronder beteken die sessie het merkbaar langer geneem as die oudio geduur het; die eerste plek om te kyk is hoe vinnig die oudio aangekom het. Vir ’n lêer sê dieselfde getal hoeveel keer vinniger as afspeel die transkripsie teruggekom het.
’n Sessie duur hoogstens limits.maxDurationS van GET /v1/realtime/capabilities, en sy oudio spandeer jou krediet per sekonde teen jou plan se lewendige tarief, met dieselfde minimum van 5 minute as enige transkripsie: elke sessie wat oudio ontvang, word ten minste 5 minute gefaktureer (pryse). Dieselfde verbinding neem ook ’n hele lêer aan: skep die sessie met input: { kind: "file" }, stuur die lêer se eie grepe (mp3, wav, flac, ogg, m4a) en eof, en die transkripsie kom baie keer vinniger as die oudio speel, met transcript.progress-rame langs die pad.
Daar is geen hervatting nie. ’n Verbinding wat wegval, beëindig die sessie; weer koppel beteken ’n nuwe sessie skep, wat sy eie minimum gefaktureer kry, en weer stroom vanaf jou lewendige bron, met seq terug na 0. Die herkoppeling self is die standaardpatroon vir enige socket-API, en die moeite werd om een keer reg te kry:
retryable: true op ’n error-raam, ’n 503 met sy Retry-After, of 30 sekondes sonder ’n raam, wat vir capacity_exceeded staan. invalid_token en session_expired beteken ’n nuwe streamUrl van jou bediener af, nie nog ’n poging met die ou een nie.Hou die roete wat die sessie skep goedkoop om aan te roep, en behandel ’n sluiting as ’n vars begin. Om geen hervatting te hê nie, is die prys van niks berg nie; as die transkripsie die verbinding moet oorleef, berg dit.
Wanneer jy wil hê dieselfde stroom moet as ’n opname gestoor word, agterna reggestel, uitgevoer en deur ’n gehoor in hul eie tale gelees word, publiseer dit eerder na ’n regstreekse transkripsie. Die gaan-regstreeks-gids loop dieselfde PCM-verbinding deur ’n transkripsie en ’n uitsendingsskakel, en die luister-gids wys die inteken-kant, waar elke luisteraar ’n taal kies en ’n laat aansluiter op ’n lastSeq-merker inhaal. Wat dit vir ’n kamer beteken, is die onderwerp van een skakel, elke taal.
Vir ’n lêer wat jy wil bewaar, is die gestoorde roete transkribeer ’n lêer met die API. Die volledige raamverwysing, elke veld ingesluit, is die Stroom ’n mikrofoon-gids in die API-verwysing.