Una transcripción en tiempo real llega dos veces: primero como un borrador que sigue cambiando y luego como una línea final que ya no cambia. Dibuja el borrador como una cola reemplazable y la línea final como historial, y el resto de una vista de subtítulos en directo es mera fontanería.
Una sesión en tiempo real es lo más pequeño que hace la API de VocalStack. Tu servidor hace una llamada HTTP y recibe un streamUrl firmado. Tu cliente abre un WebSocket a esa URL, envía el audio sin procesar a medida que se captura y lee la transcripción de vuelta como fotogramas de texto JSON, con apenas un instante de retraso respecto al hablante. Cuando el audio termina, envías eof; el servidor vacía lo que le queda, informa de lo que midió la sesión y cierra.
No se guarda nada. La transcripción existe solo en el cable, que es lo que quiere una vista de subtítulos, una superposición para reuniones o una función de voz en tu propia aplicación. Si quieres conservar las palabras, compartirlas y exportarlas después, lo que necesitas es una transcripción en directo y una emisión; la última sección te lleva hasta ahí.
La forma es la que comparten la mayoría de las APIs de voz en streaming: un intercambio de credenciales por HTTP, un socket de larga duración con audio que entra y JSON que sale, y alguna manera de distinguir el texto provisional del texto final. Los nombres cambian de una API a otra; las piezas móviles casi nunca. Aquí, en orden:
POST /v1/realtime/sessions con tu clave de API, en tu servidor. La respuesta es { sessionId, streamUrl, expiresAt }.streamUrl.session.loading hasta recibir session.ready.transcript.draft (reemplaza la cola) y transcript.committed (añade una línea final).{"type":"eof"}; lee session.done.Solo el paso 1 necesita tu clave, así que se hace en tu servidor.
Crea una clave en Developers, API keys con el permiso Write. Una clave es para servidores y scripts: la API rechaza una que llegue desde una página web o en una URL, así que creas la sesión detrás de una ruta propia, y la página solo ve el streamUrl.
La URL lleva su propio token por una limitación del navegador que conviene conocer en general. El constructor WebSocket del navegador no puede definir cabeceras de petición, así que no hay cabecera Authorization donde poner una clave; toda API de voz para navegador autentica su socket con un token de corta duración en la URL (o colado en el nombre de un subprotocolo). Que sea de corta duración es lo importante: una URL acaba en registros e historiales, así que lo que lleva tiene que caducar. Conéctate antes de expiresAt.
Los SDK se descargan desde el área de Developers, no desde un registro de paquetes; la sección Install de la guía de inicio rápido muestra los comandos propios de este entorno. Con el SDK de TypeScript, una ruta del servidor tiene este aspecto:
// 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 };
}
Sin SDK, basta con un fetch. Este pide además traducción al español y desactiva el ensamblado de frases, igual que el ejemplo de cable más abajo:
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 };
Todas las opciones tienen un valor por defecto. Las que conviene conocer desde el principio son spokenLanguage (si la omites, el idioma se detecta), translate (una lista de idiomas de destino), speakerLabels y latencyMode; el resto aparecen cuando se necesitan más abajo.
Pasa el streamUrl a lo que capture el audio; a partir de aquí no interviene ninguna clave.
Conéctate a streamUrl tal cual, sin cabecera. Mientras la sesión arranca, el servidor envía latidos session.loading. Después llega session.ready. No envíes audio antes. El audio enviado pronto no se guarda para más tarde: el servidor lo trata como un error, envía un fotograma error y cierra el socket.
Una vez abierto el socket, todo fallo adopta la forma de ese fotograma: {"type":"error","code":"…","message":"…","retryable":true|false}. El code es una lista cerrada (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message es para tus registros, y retryable indica si merece la pena intentar una sesión nueva. Gestiónalo desde el primer fotograma:
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 cliente HTTP directo tiene que gestionar dos cosas más; los SDK ya lo hacen. Primero, una negociación rechazada trae el sobre de error HTTP habitual de la API: 401 invalid_token, 410 session_expired o 503 unavailable con una cabecera Retry-After que hay que respetar.
Segundo, la saturación parece silencio, no un error. Si no llega ningún fotograma en 30 segundos, trátalo como capacity_exceeded y vuelve a conectar; una sesión nueva suele pasar. Si siguen llegando latidos de carga pero session.ready nunca llega, trátalo como engine_unavailable, que también es reintentable. En los SDK, RealtimeSession.connect se resuelve en session.ready y falla tras readyDeadlineMs con el código correcto.
Una vez llega session.ready, el servidor espera el audio al ritmo en que se habla.
El audio entra como tramas binarias de PCM sin procesar: coma flotante de 32 bits, little-endian, 16 kHz, mono (pcm_f32le, el formato audio por defecto de la sesión). Envíalo a medida que se captura, en trozos de unos 100 a 250 ms, sin esperar la transcripción entre trozo y trozo.
Conviene hacer la cuenta de bytes una vez. 16,000 muestras por segundo a 4 bytes cada una son 64,000 bytes por segundo, así que 100 ms son 6,400 bytes y 250 ms son 16,000. Los modelos de voz suelen entrenarse con audio mono de 16 kHz, por eso tantas APIs de tiempo real lo piden, mientras que los micrófonos y los navegadores capturan a 44.1 o 48 kHz y a menudo en estéreo. Algo tiene que remuestrear el audio y unir los canales en uno antes de que salgan los bytes: aquí eso lo hace ffmpeg, y en el navegador lo hace el asistente de micrófono del SDK. Enviar audio de 48 kHz declarando 16 kHz no falla; transcribe el habla a cámara lenta, y mal.
En Node, ffmpeg es el dispositivo de captura más sencillo:
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 nunca bloquea; encola. bufferedAmount es el tamaño de esa cola, y en un enlace sano ronda cero. El audio en directo no se puede pausar, así que la respuesta útil ante una cola creciente es detectarla, no descartar tramas. El servidor también aplica contrapresión. Acepta audio unos segundos por delante del tiempo real (limits.audioBufferS) y luego deja de leer hasta ponerse al día. En sentido inverso, los confirmados se encolan sin pérdida mientras los borradores se fusionan, así que un lector lento ve menos borradores, pero más recientes. Un cliente que ni siquiera con los confirmados puede seguir recibe un fotograma error con slow_consumer y retryable: false, y se le desconecta; si lo ves, tu manejador de mensajes hace demasiado trabajo.
En el navegador, streamMicrophone de @vocalstack/sdk/mic pide el micrófono, remuestrea a 16 kHz y envía los trozos junto con tramas clock, que permiten al servidor marcar los huecos cuando el navegador limita la pestaña. La página recibe la sesión desde tu ruta y nunca ve la clave:
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, '');
}
}
}
Esa firma render(committed, tail) es todo el patrón de interfaz; la siguiente sección explica por qué.
Dos tipos de trama construyen la transcripción, y confundirlos es el error más común.
Primero, por qué hay dos. Un modelo de voz decide qué palabra fue en parte por las palabras que vienen después: «hierba» y «hierva» suenan igual hasta que la frase sigue. Por eso un reconocedor en tiempo real emite una suposición temprana, la revisa conforme llega más audio y solo se asienta cuando ha oído lo suficiente. Toda API de voz en streaming expone esto de alguna forma. Algunas llaman a las suposiciones resultados «interim» o «partial» y marcan los asentados con is_final; el vocabulario varía, la disciplina no. Aquí la suposición es un borrador y el texto asentado es un confirmado.
transcript.draft es la cola en vivo: las palabras oídas desde el último confirmado, que siguen cambiando. Cada borrador reemplaza por completo al anterior. Redibuja la cola con sus segments; nunca la añadas. Los borradores no llevan seq a propósito, así que no intentes emparejarlos con los confirmados en que se convierten.
transcript.committed es final: un segment con seq (0, 1, 2, sin huecos), start, end, text, speaker y words con los tiempos de cada palabra. Añádelo y descarta el borrador que cubre; el siguiente borrador empieza donde terminó este confirmado.
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 interfaz que añade borradores muestra texto duplicado y parpadeante. Tu vista de subtítulos desarrolla un tartamudeo que el hablante nunca tuvo. Dibuja todos los segmentos confirmados y, después de ellos, el último borrador; borra el borrador cuando llegue un confirmado. Un confirmado puede contener palabras que nunca aparecieron en ningún borrador.
Aquí hay unos segundos de habla en el cable, del ejemplo fetch de arriba (translate: ["es"], ensamblado de frases desactivado):
{"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}]}
Hay dos tramas más alrededor de estas. transcript.amended corrige en su sitio un segmento confirmado (mismo seq, nuevo text o speaker) cuando se ha corregido una palabra; aplícala a la línea que ya dibujaste. transcript.words envía los tiempos de palabra de la cola justo antes de confirmarse. Necesita events.words, que está activado por defecto; un confirmado siempre incluye sus words, digan lo que digan las opciones de eventos. Por último, ignora los tipos de trama que no conozcas: se añaden nuevos sin aviso.
Aquí está el bucle de lectura del SDK de Python, con la terminal como vista de subtítulos. El ensamblado de frases también está desactivado, así que las traducciones llegan como transcript.translated, y la trama error se eleva como VocalStackError desde el 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ón por segmento, como arriba, es la forma sencilla. La forma que prefieren los lectores tiene sección propia.
translate: ["es", "fr"] pide traducciones según avanza la sesión. Cómo llegan depende del ensamblado de frases (sentences: { enabled: true }), que viene activado por defecto.
Con las frases desactivadas, cada confirmado va seguido poco después de una trama transcript.translated que nombra su seq y lleva translations indexadas por idioma; adjúntalas a ese segmento. Es lo que han usado los ejemplos hasta ahora. Es sencillo y se lee como un registro de confirmados: un segmento, una traducción, y el límite de frase donde el hablante tomó aire.
Con las frases activadas, el servidor reúne los confirmados en frases completas y las traduce, lo que se lee bastante mejor. Cada trama transcript.sentence reemplaza por completo una frase, identificada por sentenceId. Su status pasa de open a closed (o a dropped, que significa descartarla), y sus translations son provisionales mientras está abierta y definitivas al cerrarse. Actualiza por sentenceId, nunca añadas. El SDK de TypeScript incluye applySentence justo para esto:
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}`);
}
Las traducciones van un segundo o dos por detrás de la transcripción. Si también quieres oírlas, añade voice: ["es"] (necesita frases). El español de cada frase cerrada llega entonces como una trama transcript.voice, con el clip en Ogg/Opus codificado en base64 a 24 kHz mono. Una frase larga llega en trozos (part de parts) para reproducirlos uno detrás de otro. El texto provisional nunca se lee en voz alta. La guía de traducción en directo explica las voces con detalle, y dónde se va el tiempo explica la cadena de retrasos.
Los idiomas de destino no se fijan al crear la sesión.
Una trama de texto session.update cambia spokenLanguage, translate, speakerLabels, latencyMode, voice o voiceByLang desde ese momento en adelante; los campos que omites no cambian.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
Los SDK lo exponen como session.update(…). translate reemplaza la lista completa, y [] desactiva la traducción. Los segmentos ya entregados no se vuelven a transcribir ni a traducir, y si activas las etiquetas de hablante, los hablantes se etiquetan solo desde ese punto. Lo dicho queda dicho; la configuración solo rige lo que viene después.
Cuando el audio termina, envía {"type":"eof"} (session.eof() en los SDK). El servidor transcribe lo que aún retiene, envía los últimos confirmados, luego session.done y cierra. Enviar cancel en su lugar detiene al instante y descarta lo restante.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason es eof, max_duration (la sesión llegó a la duración máxima del plan y ejecutó el mismo vaciado) o cancelled. stats lo mide el servidor: audioS es el audio que enviaste, processingS es el tiempo de reloj que duró la sesión, y realtimeFactor es su cociente. Una sesión en directo queda justo por debajo de 1, porque el tiempo de reloj incluye el vaciado final tras tu eof. Un número bastante inferior indica que la sesión tardó bastante más que la duración del audio; lo primero que conviene revisar es la rapidez con que llegó el audio. En un archivo, ese mismo número indica cuántas veces más rápido que la reproducción volvió la transcripción.
Una sesión dura como máximo limits.maxDurationS según GET /v1/realtime/capabilities, y su audio consume tu crédito por segundo a la tarifa en directo de tu plan, con el mismo mínimo de 5 minutos que cualquier transcripción: cada sesión que recibe audio se factura al menos 5 minutos (precios). El mismo socket también admite un archivo completo: crea la sesión con input: { kind: "file" }, envía los propios bytes del archivo (mp3, wav, flac, ogg, m4a) y eof, y la transcripción vuelve muchas veces más rápido de lo que suena el audio, con tramas transcript.progress por el camino.
No hay reanudación. Una conexión caída termina la sesión; reconectar significa crear una nueva, facturada con su propio mínimo, y volver a transmitir desde tu fuente en directo, con seq otra vez en 0. La reconexión en sí es el patrón estándar de cualquier API por socket, y conviene acertar una vez:
retryable: true en una trama error, un 503 con su Retry-After, o 30 segundos sin ninguna trama, que equivalen a capacity_exceeded. invalid_token y session_expired significan un streamUrl nuevo de tu servidor, no otro intento con el antiguo.Mantén la ruta que crea sesiones barata de llamar y trata un cierre como un inicio nuevo. No tener reanudación es el precio de no guardar nada; si la transcripción debe sobrevivir al socket, guárdala tú.
Cuando quieras que el mismo stream se guarde como grabación, se corrija después, se exporte y lo lea una audiencia en sus propios idiomas, publícalo como transcripción en directo. La guía para ir en directo recorre el mismo socket PCM a través de una transcripción y un enlace de emisión, y la guía de escucha muestra el lado del suscriptor, donde cada oyente elige un idioma y quien llega tarde se pone al día desde un cursor lastSeq. Lo que eso supone para una sala es el tema de un enlace, todos los idiomas.
Para un archivo que quieras conservar, la ruta almacenada es transcribir un archivo con la API. La referencia completa de tramas, con cada campo, está en la guía Transmitir desde un micrófono de la referencia de la API.