Unha transcrición en tempo real chega dúas veces: primeiro como un borrador que non para de cambiar, despois como unha liña final que xa non cambia. Debuxa o borrador como unha cola substituíbel e a liña final como historial, e o resto dunha vista de subtítulos en directo é fontanería.
Unha sesión en tempo real é o máis pequeno que fai a API de VocalStack. O teu servidor fai unha chamada HTTP e recibe un streamUrl asinado. O cliente abre un WebSocket a esa URL, envía audio en bruto a medida que se captura e lee a transcrición de volta como marcos de texto JSON, uns instantes detrás do falante. Cando o audio remata, envías eof; o servidor vacía o buffer, informa do que mediu a sesión e pecha.
Non se garda nada. A transcrición existe só na conexión, que é o que quere unha vista de subtítulos, unha superposición para reunións ou unha función de voz na túa propia aplicación. Se queres conservar, compartir e exportar as palabras despois, o que che convén é unha transcrición en directo e unha emisión; a última sección apunta cara a iso.
A forma é a que comparten a maioría das APIs de voz en streaming: un intercambio de credenciais por HTTP, un socket de longa duración con audio que entra e JSON que sae, e algún xeito de distinguir o texto provisional do final. Os nomes cambian dunha API a outra; as pezas case nunca. Aquí, por orde:
POST /v1/realtime/sessions coa túa clave de API, no teu servidor. A resposta é { sessionId, streamUrl, expiresAt }.streamUrl.session.loading ata session.ready.transcript.draft (substitúe a cola) e transcript.committed (engade unha liña final).{"type":"eof"}; lee session.done.Só o paso 1 necesita a túa clave, así que ocorre no teu servidor.
Crea unha clave en Developers, API keys co permiso Write. As claves son para servidores e scripts: a API rexeita unha clave enviada desde unha páxina web ou nunha URL, así que creas a sesión detrás dunha ruta propia, e a páxina só ve o streamUrl.
A URL leva o seu propio token por unha limitación do navegador que convén coñecer en xeral. O constructor WebSocket do navegador non pode definir cabeceiras de solicitude, así que non hai cabeceira Authorization onde pór unha clave; todas as APIs de voz pensadas para o navegador autentican o socket cun token de curta duración na URL (ou metido nun nome de subprotocolo). Que sexa de curta duración é o punto: unha URL acaba en rexistros e no historial, así que o que leva ten que caducar. Conecta antes de expiresAt.
Os SDK descárganse desde a área Developers, non desde un rexistro de paquetes; a sección Install da guía de inicio rápido mostra os comandos propios deste entorno. Co SDK de TypeScript, unha ruta do servidor ten 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 };
}
Sen SDK é un único fetch. Este pide tamén español e desactiva a montaxe de frases, igual que a mostra de conexión máis abaixo:
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ón ten un valor por defecto. As que conven coñecer ao principio son spokenLanguage (se a omites, o idioma detéctase), translate (unha lista de idiomas de destino), speakerLabels e latencyMode; as demais aparecen cando fagan falta máis abaixo.
Entrega o streamUrl a quen capture o audio; desde aquí en adiante non interven claves.
Conecta a streamUrl tal cal, sen cabeceira. Mentres a sesión arranca, o servidor envía latidos session.loading. Despois chega session.ready. Non envíes audio antes del. O audio enviado cedo non se garda para máis tarde: o servidor trátao como un erro, envía un marco error e pecha o socket.
Unha vez aberto o socket, todo fallo ten a forma dese marco: {"type":"error","code":"…","message":"…","retryable":true|false}. O code é unha lista pechada (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message é para os teus rexistros, e retryable indica se paga a pena probar unha sesión nova. Trátao desde o primeiro marco:
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 en bruto ten que xestionar dúas cousas máis por si mesmo; os SDK xa o fan. Primeiro, unha actualización rexeitada leva o sobre de erro HTTP habitual da API: 401 invalid_token, 410 session_expired ou 503 unavailable cunha cabeceira Retry-After que hai que respectar.
Segundo, a saturación parece silencio, non erro. Se en 30 segundos non chega ningún marco, trátao como capacity_exceeded e conecta de novo; unha sesión nova adoita pasar. Se seguen chegando latidos de carga pero session.ready nunca chega, trátao como engine_unavailable, que tamén é reintentábel. Nos SDK, RealtimeSession.connect resólvese en session.ready e falla despois de readyDeadlineMs co código correcto.
Cando chega session.ready, o servidor espera o audio ao ritmo en que se fala.
O audio entra como marcos binarios de PCM en bruto: 32 bits en punto flotante, little-endian, 16 kHz, mono (pcm_f32le, o formato audio por defecto da sesión). Envíao a medida que se captura, en anacos de uns 100 a 250 ms, sen agardar pola transcrición entre anacos.
Paga a pena facer a conta unha vez. 16.000 mostras por segundo a 4 bytes cada unha son 64.000 bytes por segundo, así que 100 ms son 6.400 bytes e 250 ms son 16.000. Os modelos de voz adóitanse adestrar con audio mono a 16 kHz, e por iso tantas APIs de tempo real o piden, mentres que os micrófonos e os navegadores capturan a 44,1 ou 48 kHz e a miúdo en estéreo. Algo ten que remostrear o audio e xuntar os canles nun só antes de enviar os bytes: aquí é ffmpeg, e no navegador é o asistente de micrófono do SDK. Enviar audio a 48 kHz declarándoo como 16 kHz non falla; transcribe a fala a cámara lenta, e mal.
En Node, ffmpeg é o dispositivo de captura máis sinxelo:
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; pon en cola. bufferedAmount é o tamaño desa cola, e nunha conexión sa anda preto de cero. O audio en directo non se pode pausar, así que a resposta útil ante unha cola que medra é notala, non descartar marcos. O servidor tamén aplica contrapresión. Acepta audio uns segundos por diante do tempo real (limits.audioBufferS) e despois deixa de ler ata poñerse ao día. Na volta, as confirmacións póñense en cola sen perda mentres os borradores se fusionan, así que un lector lento ve menos borradores, pero máis frescos. Un cliente que non chega nin ás confirmacións recibe un marco error con slow_consumer e retryable: false, e desconéctase; se o ves, o teu manexador de mensaxes está facendo demasiado traballo.
No navegador, streamMicrophone de @vocalstack/sdk/mic pide o micrófono, remostrea a 16 kHz e envía os anacos xunto con marcos clock, que permiten ao servidor marcar as lagoas cando o navegador freas a lapela. A páxina recibe a sesión da túa ruta e nunca ve a 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 sinatura render(committed, tail) é todo o patrón da interface; a sección seguinte explica por que.
Dous tipos de marco constrúen a transcrición, e confundilos é o erro máis común.
Primeiro, por que hai dous. Un modelo de voz decide que palabra foi en parte polas palabras que seguen: «I scream» e «ice cream» soan igual ata que a frase continúa. Así que un recoñecedor en tempo real emite unha suposición cedo, despois a revisa a medida que chega máis audio, e só se fixa cando escoitou o suficiente. Todas as APIs de voz en streaming expoñen isto dalgún xeito. Algunhas chaman «intermedios» ou «parciais» aos resultados e marcan os fixados con is_final; o vocabulario varía, a disciplina non. Aquí a suposición é un borrador e o texto fixado é unha confirmación.
transcript.draft é a cola en directo: as palabras escoitadas desde a última confirmación, que aínda cambian. Cada borrador substitúe por completo o anterior. Redebuxa a cola cos seus segments; nunca a engadas. Os borradores non levan seq por deseño, así que non intentes emparellalos coas confirmacións en que se converten.
transcript.committed é final: un segment con seq (0, 1, 2, sen saltos), start, end, text, speaker e words con os tempos de cada palabra. Engádea e elimina o borrador que cubre; o seguinte borrador comeza onde remataba esta confirmación.
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
Unha interface que engade borradores amosa texto duplicado e tremeluzente. A túa vista de subtítulos desenvolve un tartamudeo que o falante nunca tivo. Debuxa todos os segmentos confirmados e despois o último borrador a continuación, e borra o borrador cando chegue unha confirmación. Unha confirmación pode conter palabras que non apareceron en ningún borrador.
Aquí tes uns segundos de fala no cable, do exemplo de fetch de arriba (translate: ["es"], montaxe de frases desactivada):
{"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}]}
Hai dous marcos máis ao redor destes. transcript.amended corrixe un segmento confirmado no seu lugar (mesmo seq, novo text ou speaker) cando se corrixe unha palabra del; aplícao á liña que xa debuxaches. transcript.words envía os tempos das palabras da cola xusto antes de confirmarse. Necesita events.words, que está activado por defecto; unha confirmación sempre incorpora os seus words, sexan cales sexan as opcións de eventos. Por último, ignora os tipos de marco que non coñeces: os novos engádense sen aviso.
Aquí está o bucle de lectura do SDK de Python, coa terminal como vista de subtítulos. A montaxe de frases tamén está desactivada aquí, así que as traduccións chegan como transcript.translated, e o marco error aparece como un VocalStackError lanzado desde o 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())
A tradución por segmento, como na de arriba, é a forma simple. A forma que prefiren os lectores leva unha sección propia.
translate: ["es", "fr"] pide traduccións mentres avanza a sesión. Como chegan depende da montaxe de frases (sentences: { enabled: true }), que está activada por defecto.
Con frases desactivadas, cada confirmación vai seguida dun pouco despois por un marco transcript.translated que indica o seu seq e leva translations indexadas por idioma; engádeas a ese segmento. É o que usaron os exemplos ata agora. É simple e lese como un rexistro de confirmacións: un segmento, unha tradución, e o límite da frase onde o falante colleu alento.
Con frases activadas, o servidor monta as confirmacións en frases completas e tradúceas, o que se le moito mellor. Cada marco transcript.sentence substitúe por completo unha frase, identificada por sentenceId. O seu status pasa de open a closed (ou dropped, que significa descartala), e as súas translations son provisionais mentres está aberta e finais cando se pecha. Actualiza por sentenceId, nunca engadas. O SDK de TypeScript inclúe applySentence precisamente para isto:
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}`);
}
As traduccións seguen á transcrición un ou dous segundos despois. Se as queres tamén faladas, engade voice: ["es"] (necesita frases). O español de cada frase pechada chega entón como un marco transcript.voice, co clip en base64 Ogg/Opus a 24 kHz mono. Unha frase longa chega en anacos (part de parts) para reproducilos seguidos. O texto provisional nunca se fala. A guía de tradución en directo cubre as voces en detalle, e onde se van os segundos explica a cadea de atrasos.
Os idiomas de destino non se fixan na creación.
Un marco de texto session.update cambia spokenLanguage, translate, speakerLabels, latencyMode, voice ou voiceByLang desde ese punto en adiante; os campos que omites non cambian.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
Os SDK expoñen isto como session.update(…). translate substitúe a lista completa, e [] desactiva a tradución. Os segmentos xa entregados non se volven transcribir nin traducir, e se activas as etiquetas de falante, os falantes etiquétanse só desde ese punto. O que se dixo queda dito; as configuracións só rexen o que vén despois.
Cando o audio remata, envía {"type":"eof"} (session.eof() nos SDK). O servidor transcribe o que aínda ten, envía as últimas confirmacións, despois session.done e pecha. Enviar cancel no seu lugar para de inmediato e descarta o resto.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason é eof, max_duration (a sesión chegou á lonxitude máxima do plan e fixo o mesmo vaciado) ou cancelled. stats mídeo o servidor: audioS é o audio que enviaches, processingS é o tempo de reloxo que durou a sesión, e realtimeFactor é a súa razón. Unha sesión en directo queda logo por debaixo de 1, porque o tempo de reloxo inclúe o vaciado final despois do teu eof. Un número moi por debaixo diso significa que a sesión tardou bastante máis que o que durou o audio; o primeiro que hai que mirar é a velocidade a que chegou o audio. Para un ficheiro, o mesmo número indica cantas veces máis rápido que a reprodución volveu a transcrición.
Unha sesión dura como máximo limits.maxDurationS de GET /v1/realtime/capabilities, e o seu audio gasta o teu crédito por segundo á tarifa en directo do teu plan, co mesmo mínimo de 5 minutos que calquera transcrición: cada sesión que recibe audio factúrase polo menos 5 minutos (prezos). O mesmo socket tamén admite un ficheiro completo: crea a sesión con input: { kind: "file" }, envía os bytes propios do ficheiro (mp3, wav, flac, ogg, m4a) e eof, e a transcrición volve moitas veces máis rápido do que se reproduce o audio, con marcos transcript.progress polo camiño.
Non hai reanudación. Unha conexión caída remata a sesión; reconectar significa crear unha sesión nova, facturada co seu propio mínimo, e volver emitir desde a túa fonte en directo, con seq de novo en 0. A reconexión en si é o patrón estándar de calquera API por socket, e paga a pena facelo ben unha vez:
retryable: true nun marco error, un 503 co seu Retry-After, ou 30 segundos sen ningún marco, que equivalen a capacity_exceeded. invalid_token e session_expired significan un streamUrl novo desde o teu servidor, non outro intento co antigo.Mantén a ruta que crea sesións barata de chamar e trata un peche como un inicio novo. Non ter reanudación é o prezo de non gardar nada; se a transcrición tiña que sobrevivir ao socket, gárdaa ti.
Cando queres que o mesmo fluxo se garde como gravación, se corrixa despois, se exporte e o lean unha audiencia nos seus propios idiomas, publícao como transcrición en directo. A guía para emitir en directo lles amosa como o mesmo socket PCM pasa por unha transcrición e unha ligazón de emisión, e a guía de escoita mostra o lado do subscritor, onde cada oínte escolle un idioma e quen se une tarde se pon ao día desde un cursor lastSeq. Como se ve isto para unha sala é o tema de unha ligazón, todos os idiomas.
Para un ficheiro que queres conservar, a ruta almacenada é transcribir un ficheiro coa API. A referencia completa dos marcos, con todos os campos, é a guía para transmitir un micrófono da referencia da API.