Una transcripció en temps real arriba dues vegades: primer com a esborrany que no para de canviar i després com a línia final que no canvia. Dibuixa l'esborrany com una cua substituïble i la línia final com a historial, i la resta d'una vista de subtítols en directe és fontaneria.
Una sessió en temps real és el més petit que fa l'API de VocalStack. El teu servidor fa una crida HTTP i rep un streamUrl signat. El client obre un WebSocket a aquesta URL, envia àudio sense processar a mesura que es captura i llegeix la transcripció com a marcs de text JSON, just després del parlant. Quan acaba l'àudio envies eof, el servidor buida el que li queda, informa de les mesures de la sessió i tanca la connexió.
No s'emmagatzema res. La transcripció existeix només pel cable, que és el que necessita una vista de subtítols, una capa sobre una reunió o una funció de veu a la teva aplicació. Si vols que les paraules es guardin, es comparteixin i s'exportin després, t'interessa una transcripció en directe i una emissió; l'última secció hi apunta.
Aquesta forma és la que comparteixen la majoria d'APIs de veu en streaming: un intercanvi de credencials per HTTP, un socket de llarga durada amb àudio que entra i JSON que surt, i alguna manera de distingir el text provisional del text final. Els noms canvien d'una API a una altra; les peces que es mouen gairebé mai. Aquí, en ordre:
POST /v1/realtime/sessions amb la teva clau d'API, al teu servidor. La resposta és { sessionId, streamUrl, expiresAt }.streamUrl.session.loading fins que arribi session.ready.transcript.draft (substitueix la cua) i transcript.committed (afegeix una línia final).{"type":"eof"}; llegeix session.done.Només el pas 1 necessita la teva clau, així que es fa al teu servidor.
Crea una clau a Developers, API keys amb el permís Write. Una clau és per a servidors i scripts: l'API rebutja una clau enviada des d'una pàgina web o dins d'una URL, així que crees la sessió darrere d'una ruta pròpia, i la pàgina només veu el streamUrl.
La URL porta el seu propi testimoni per una limitació del navegador que convé conèixer en general. El constructor WebSocket del navegador no pot definir capçaleres de petició, de manera que no hi ha cap capçalera Authorization on posar una clau; totes les APIs de veu que es fan servir des del navegador autentiquen el socket amb un testimoni de vida curta a la URL (o amagat en el nom d'un subprotocol). La vida curta és la clau: una URL acaba als registres i a l'historial, així que el que porta ha de caducar. Connecta abans de expiresAt.
Els SDKs es descarreguen de l'àrea Developers, no d'un registre de paquets; la secció Install de la guia d'inici ràpid mostra les ordres pròpies d'aquest entorn. Amb l'SDK de TypeScript, una ruta del servidor té aquest aspecte:
// 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 };
}
Sense SDK és un sol fetch. Aquest també demana espanyol i desactiva l'ensamblatge de frases, igual que la mostra del cable més avall:
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 };
Cada opció té un valor per defecte. Les que convé conèixer des del principi són spokenLanguage (si la deixes fora, l'idioma es detecta), translate (una llista d'idiomes de destí), speakerLabels i latencyMode; la resta apareixen quan calen més avall.
Passa el streamUrl a qui capturi l'àudio; a partir d'aquí no hi intervé cap clau.
Connecta't a streamUrl tal com està, sense capçalera. Mentre la sessió s'inicia, el servidor envia senyals de session.loading. Després arriba session.ready. No enviïs àudio abans. L'àudio enviat d'hora no es guarda per més tard; el servidor ho tracta com un error, envia un marc error i tanca el socket.
Quan el socket està obert, cada error té la forma d'aquest marc: {"type":"error","code":"…","message":"…","retryable":true|false}. El code és una llista tancada (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message és per als teus registres, i retryable indica si val la pena provar amb una sessió nova. Gestiona-ho des del primer marc:
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;
}
});
Un client pur ha de gestionar dues coses més per si mateix; els SDKs ja ho fan. Primer, una actualització rebutjada porta el sobre d'error HTTP habitual de l'API: 401 invalid_token, 410 session_expired o 503 unavailable amb una capçalera Retry-After que cal respectar.
Segon, la saturació sembla silenci, no un error. Si no arriba cap marc en 30 segons, tracta-ho com a capacity_exceeded i torna a connectar; una sessió nova sol passar. Si continuen arribant marcs de càrrega però session.ready no arriba mai, tracta-ho com a engine_unavailable, que també es pot reintentar. Als SDKs, RealtimeSession.connect es resol a session.ready i falla després de readyDeadlineMs amb el codi adequat.
Quan arriba session.ready, el servidor espera àudio al ritme en què es parla.
L'àudio s'envia com a marcs binaris de PCM sense processar: coma flotant de 32 bits, little-endian, 16 kHz, mono (pcm_f32le, el format audio per defecte de la sessió). Envia'l a mesura que es captura, en blocs d'uns 100 a 250 ms, sense esperar la transcripció entre blocs.
Val la pena fer el càlcul de bytes una vegada. 16,000 mostres per segon a 4 bytes cadascuna són 64,000 bytes per segon, així que 100 ms són 6,400 bytes i 250 ms són 16,000. Els models de parla solen entrenar-se amb 16 kHz mono, per això tantes APIs en temps real el demanen, mentre que els micròfons i els navegadors capturen a 44.1 o 48 kHz i sovint en estèreo. Cal que algú remostregi l'àudio i ajunti els canals en un de sol abans que surtin els bytes: aquí és ffmpeg, i al navegador, l'ajudant de micròfon de l'SDK. Enviar àudio a 48 kHz i declarar 16 kHz no falla; transcriu la parla a càmera lenta, i malament.
A Node, ffmpeg és el dispositiu de captura més simple:
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 mai es bloqueja; encua. bufferedAmount és la mida d'aquesta cua, i en una connexió sana es manté a prop de zero. L'àudio en directe no es pot aturar, així que la resposta útil davant d'una cua que creix és adonar-se'n, no descartar marcs. El servidor també aplica contrapressió. Accepta àudio uns quants segons per davant del temps real (limits.audioBufferS) i després deixa de llegir fins que es posa al dia. Al sentit contrari, les confirmacions es posen en cua sense pèrdues mentre que els esborranys es fusionen, de manera que un lector lent veu menys esborranys, però més recents. Un client que no pot seguir el ritme ni amb les confirmacions rep un marc error amb slow_consumer i retryable: false, i es desconnecta; si ho veus, el teu gestor de missatges fa massa feina.
Al navegador, streamMicrophone de @vocalstack/sdk/mic demana el micròfon, remostreja a 16 kHz i envia els blocs juntament amb marcs clock, que permeten al servidor marcar les pauses quan el navegador alenteix la pestanya. La pàgina rep la sessió de la teva ruta i mai veu la clau:
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, '');
}
}
}
Aquesta signatura render(committed, tail) és tot el patró de la interfície; la secció següent n'explica el motiu.
Dos tipus de marc construeixen la transcripció, i barrejar-los és l'error més habitual.
Primer, per què n'hi ha dos. Un model de parla decideix en part què era una paraula a partir de les paraules que segueixen: «I scream» i «ice cream» sonen igual fins que la frase continua. Per això un reconeixedor en temps real emet una suposició d'hora, després la revisa a mesura que arriba més àudio, i només es fixa quan n'ha sentit prou. Totes les APIs de veu en streaming ho exposen d'alguna manera. Algunes anomenen les suposicions resultats «interim» o «partial» i marquen les fixades com a is_final; el vocabulari varia, la disciplina no. Aquí la suposició és un esborrany i el text fixat és una confirmació.
transcript.draft és la cua en directe: les paraules sentides des de l'última confirmació, que encara canvien. Cada esborrany substitueix completament l'anterior. Redibuixa la cua amb els seus segments; mai no l'afegeixis. Els esborranys no porten seq per disseny, així que no intentis lligar-los a les confirmacions en què es converteixen.
transcript.committed és final: un sol segment amb seq (0, 1, 2, sense salts), start, end, text, speaker i words amb els temps de cada paraula. Afegeix-lo i descarta l'esborrany que cobreix; el següent esborrany comença on acaba aquesta confirmació.
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
Una interfície que afegeix esborranys mostra text duplicat i que parpelleja. La teva vista de subtítols adquireix una tartamudesa que el parlant mai no va tenir. Dibuixa totes les confirmacions i després l'últim esborrany darrere d'elles, i esborra l'esborrany quan arriba una confirmació. Una confirmació pot contenir paraules que mai no han aparegut en cap esborrany.
Aquí tens uns quants segons de parla pel cable, de l'exemple de fetch d'adalt (translate: ["es"], ensamblatge de frases desactivat):
{"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}]}
Hi ha dos marcs més al voltant d'aquests. transcript.amended corregeix en el seu lloc un segment confirmat (el mateix seq, un text o speaker nou) quan s'ha corregit una paraula seva; aplica'l a la línia que ja has dibuixat. transcript.words transmet els temps de paraula de la cua just abans que es confirmi. Necessita events.words, que està activat per defecte; una confirmació sempre incorpora els seus words, siguin les que siguin les opcions d'esdeveniments. Finalment, ignora els tipus de marc que no coneixes: n'hi afegeixen de nous sense avisar.
Aquí tens el bucle de lectura de l'SDK de Python, amb el terminal com a vista de subtítols. L'ensamblatge de frases també està desactivat aquí, així que les traduccions arriben com a transcript.translated, i el marc error apareix com un VocalStackError llançat fora del bucle:
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())
La traducció per segment, com la d'adalt, és la forma simple. La forma que prefereixen els lectors té una secció pròpia.
translate: ["es", "fr"] demana traduccions mentre avança la sessió. Com arriben depèn de l'ensamblatge de frases (sentences: { enabled: true }), que està activat per defecte.
Amb les frases desactivades, cada confirmació és seguida poc després per un marc transcript.translated que indica el seu seq i porta translations indexades per idioma; adjunta-les a aquell segment. Això és el que han fet servir els exemples fins ara. És simple i es llegeix com un registre de confirmacions: un segment, una traducció, el límit de la frase on el parlant ha agafat aire.
Amb les frases activades, el servidor ajunta les confirmacions en frases senceres i les tradueix, cosa que es llegeix molt millor. Cada marc transcript.sentence substitueix completament una frase, identificada per sentenceId. El seu status passa de open a closed (o dropped, que vol dir descartar-la), i les seves translations són provisionals mentre està oberta i definitives quan es tanca. Fes upsert per sentenceId, mai no afegeixis. L'SDK de TypeScript inclou applySentence precisament per a això:
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}`);
}
Les traduccions van darrere de la transcripció un o dos segons. Si també les vols parlades, afegeix voice: ["es"] (necessita frases). L'espanyol de cada frase tancada arriba després com a marc transcript.voice, amb el clip en base64 Ogg/Opus a 24 kHz mono. Una frase llarga arriba en trossos (part de parts) per reproduir-los seguits. El text provisional mai no es parla. La guia de traducció en directe explica les veus en detall, i on van a parar els segons explica la cadena de retard.
Els idiomes de destinació no es fixen en el moment de la creació.
Un marc de text session.update canvia spokenLanguage, translate, speakerLabels, latencyMode, voice o voiceByLang a partir d'aquell moment; els camps que deixes fora no canvien.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
Els SDKs l'exposen com a session.update(…). translate substitueix tota la llista, i [] desactiva la traducció. Els segments ja entregats no es tornen a transcriure ni a traduir, i si actives les etiquetes de parlant, els parlants només es marquen a partir d'aquell punt. El que ja s'ha dit continua dit; la configuració només regeix el que ve després.
Quan acaba l'àudio, envia {"type":"eof"} (session.eof() als SDKs). El servidor transcriu el que encara té, envia les últimes confirmacions, després session.done i tanca. Enviar cancel atura de seguida i descarta la resta.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason és eof, max_duration (la sessió ha arribat a la durada màxima del pla i ha fet el mateix buidatge) o cancelled. stats el mesura el servidor: audioS és l'àudio que has enviat, processingS és el temps de rellotge que ha durat la sessió, i realtimeFactor és la seva proporció. Una sessió en directe queda una mica per sota de 1, perquè el temps de rellotge inclou el buidatge final després del teu eof. Un número clarament més baix vol dir que la sessió ha trigat bastant més que la durada de l'àudio; el primer que cal mirar és amb quina rapidesa va arribar l'àudio. En un fitxer, el mateix número indica quantes vegades més ràpid que la reproducció s'ha retornat la transcripció.
Una sessió dura com a màxim limits.maxDurationS segons GET /v1/realtime/capabilities, i el seu àudio consumeix el teu crèdit per segon a la tarifa en directe del teu pla, amb el mateix mínim de 5 minuts que qualsevol transcripció: cada sessió que rep àudio es factura com a mínim 5 minuts (preus). El mateix socket també accepta un fitxer sencer: crea la sessió amb input: { kind: "file" }, envia els bytes del propi fitxer (mp3, wav, flac, ogg, m4a) i eof, i la transcripció torna moltes vegades més ràpid que la reproducció de l'àudio, amb marcs transcript.progress pel camí.
No hi ha manera de reprendre una sessió. Una connexió caiguda l'acaba; reconnectar vol dir crear'n una de nova, que es factura el seu propi mínim, i tornar a transmetre des de la font en directe, amb seq de nou a 0. La reconnexió en si és el patró estàndard de qualsevol API de socket, i val la pena fer-la bé una vegada:
retryable: true en un marc error, un 503 amb el seu Retry-After, o 30 segons sense cap marc, que equival a capacity_exceeded. invalid_token i session_expired signifiquen un streamUrl nou del teu servidor, no un altre intent amb l'antic.Fes que la ruta que crea sessions sigui barata de cridar i tracta un tancament com un inici nou. No poder reprendre és el preu de no emmagatzemar res; si la transcripció ha de sobreviure al socket, guarda-la.
Quan vulguis que el mateix flux s'emmagatzemi com a gravació, es corregeixi després, s'exporti i el llegeixi un públic en els seus propis idiomes, publica'l com a transcripció en directe. La guia per posar-se en directe explica el mateix socket PCM amb una transcripció i un enllaç d'emissió, i la guia d'escolta mostra el costat del subscriptor, on cada oient tria un idioma i qui arriba tard es posa al dia amb un cursor lastSeq. Com es veu això en una sala és el tema de un enllaç, tots els idiomes.
Per a un fitxer que vols conservar, la ruta emmagatzemada és transcriure un fitxer amb l'API. La referència completa dels marcs, amb cada camp, és la guia Transmetre el micròfon de la referència de l'API.