Uma transcrição em tempo real chega duas vezes: primeiro como um rascunho que não para de mudar, depois como uma linha final que não muda mais. Desenhe o rascunho como uma cauda substituível e a linha final como histórico, e o resto de uma visualização de legendas ao vivo é encanamento.
Uma sessão em tempo real é a menor coisa que a API do VocalStack faz. Seu servidor faz uma chamada HTTP e recebe de volta um streamUrl assinado. Seu cliente abre um WebSocket para essa URL, envia o áudio bruto conforme ele é capturado e lê a transcrição de volta como frames de texto JSON, com um instante de atraso em relação a quem fala. Quando o áudio termina, você envia eof; o servidor finaliza o processamento, informa o que a sessão mediu e fecha a conexão.
Nada é armazenado. A transcrição existe apenas no fluxo da conexão, que é o que uma visualização de legendas, uma sobreposição em reunião ou um recurso de voz no seu próprio aplicativo precisam. Se você quer guardar as palavras, compartilhá-las e exportá-las depois, precisa de uma transcrição ao vivo e de uma transmissão; a última seção aponta para lá.
A forma é a que a maioria das APIs de fala em streaming compartilha: uma troca de credenciais por HTTP, um socket de longa duração com áudio entrando e JSON saindo, e alguma maneira de distinguir texto provisório de texto final. Os nomes mudam de uma API para outra; as peças raramente mudam. Aqui, em ordem:
POST /v1/realtime/sessions com sua chave de API, no seu servidor. A resposta é { sessionId, streamUrl, expiresAt }.streamUrl.session.loading até session.ready.transcript.draft (substitua a cauda) e transcript.committed (acrescente uma linha final).{"type":"eof"}; leia session.done.Só o passo 1 precisa da sua chave, então ele acontece no seu servidor.
Crie uma chave em Developers, API keys com a permissão Write. A chave serve para servidores e scripts: a API recusa uma chave enviada por uma página web ou em uma URL, então você cria a sessão atrás de uma rota sua, e a página só enxerga o streamUrl.
A URL carrega o próprio token por causa de uma limitação do navegador que vale conhecer de modo geral. O construtor WebSocket do navegador não consegue definir cabeçalhos de requisição, então não há cabeçalho Authorization onde colocar uma chave; toda API de fala voltada ao navegador autentica o socket com um token de curta duração na URL (ou embutido no nome de um subprotocolo). Curta duração é o ponto: uma URL acaba em logs e no histórico, então o que ela carrega precisa expirar. Conecte-se antes de expiresAt.
Os SDKs são baixados da área de Developers, não de um registro de pacotes; a seção Instalação do guia de início rápido mostra os comandos desse ambiente. Com o SDK em TypeScript, uma rota do servidor fica assim:
// 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 };
}
Sem um SDK, basta um fetch. Este também pede espanhol e desliga a montagem de frases, combinando com o exemplo de protocolo mais adiante:
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 };
Toda opção tem um padrão. As que valem a pena conhecer logo no início são spokenLanguage (se você não a informar, o idioma é detectado), translate (uma lista de idiomas de destino), speakerLabels e latencyMode; as demais aparecem conforme forem necessárias abaixo.
Entregue o streamUrl a quem captura o áudio; daqui em diante, nenhuma chave é envolvida.
Conecte-se a streamUrl como está, sem cabeçalho. Enquanto a sessão inicia, o servidor envia batimentos session.loading. Depois vem session.ready. Não envie áudio antes disso. O áudio enviado cedo não é guardado para depois; o servidor o trata como erro, envia um frame error e fecha o socket.
Depois que o socket está aberto, toda falha tem a forma desse frame: {"type":"error","code":"…","message":"…","retryable":true|false}. O code pertence a uma lista fechada (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message é para os seus logs, e retryable indica se vale tentar uma sessão nova. Trate isso desde o primeiro frame:
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;
}
});
Um cliente bruto precisa tratar duas coisas a mais por conta própria; os SDKs já fazem isso. Primeiro, uma atualização recusada traz o envelope de erro HTTP habitual da API: 401 invalid_token, 410 session_expired ou 503 unavailable com um cabeçalho Retry-After a respeitar.
Segundo, saturação parece silêncio, não erro. Se nenhum frame chegar em 30 segundos, trate como capacity_exceeded e conecte de novo; uma sessão nova normalmente passa. Se os frames de carregamento continuarem chegando, mas session.ready nunca chegar, trate como engine_unavailable, que também permite nova tentativa. Nos SDKs, RealtimeSession.connect resolve em session.ready e falha após readyDeadlineMs com o código correto.
Quando session.ready chega, o servidor espera áudio no ritmo em que é falado.
O áudio entra como frames binários de PCM bruto: float de 32 bits, little-endian, 16 kHz, mono (pcm_f32le, o formato audio padrão da sessão). Envie conforme é capturado, em blocos de cerca de 100 a 250 ms, sem esperar a transcrição entre um bloco e outro.
Vale fazer a conta uma vez. 16.000 amostras por segundo com 4 bytes cada são 64.000 bytes por segundo, então 100 ms são 6.400 bytes e 250 ms são 16.000. Modelos de fala costumam ser treinados com áudio mono de 16 kHz, e por isso tantas APIs em tempo real pedem esse formato, enquanto microfones e navegadores capturam em 44,1 ou 48 kHz, muitas vezes em estéreo. Algo precisa reamostrar o áudio e unir os canais em um só antes que os bytes sejam enviados: aqui isso é o ffmpeg, e no navegador é o auxiliar de microfone do SDK. Enviar áudio de 48 kHz declarando 16 kHz não gera erro; transcreve fala em câmera lenta, e mal.
No Node, o ffmpeg é o dispositivo de captura mais simples:
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 bloqueia; ele enfileira. bufferedAmount é o tamanho dessa fila, e em uma conexão saudável ela fica perto de zero. O áudio ao vivo não pode ser pausado, então a resposta útil a uma fila crescente é percebê-la, não descartar frames. O servidor também aplica contrapressão. Ele aceita áudio alguns segundos adiantado em relação ao tempo real (limits.audioBufferS) e, então, para de ler até alcançar. No caminho de volta, as confirmações são enfileiradas sem perda, enquanto os rascunhos são unificados, de modo que um leitor lento vê menos rascunhos, porém mais recentes. Um cliente que não acompanha nem as confirmações recebe um frame error com slow_consumer e retryable: false, e é desconectado; se você vir isso, seu manipulador de mensagens está fazendo trabalho demais.
No navegador, streamMicrophone de @vocalstack/sdk/mic pede acesso ao microfone, reamostra para 16 kHz e envia os blocos junto com frames clock, que permitem ao servidor marcar lacunas quando o navegador desacelera a aba. A página obtém a sessão da sua rota e nunca vê a chave:
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, '');
}
}
}
Essa assinatura render(committed, tail) é todo o padrão de interface; a próxima seção explica por quê.
Dois tipos de frame constroem a transcrição, e confundi-los é o erro mais comum.
Primeiro, por que há dois. Um modelo de fala decide o que uma palavra foi em parte pelas palavras que vêm depois dela: "I scream" e "ice cream" soam iguais até a frase continuar. Por isso um reconhecedor em tempo real emite um palpite cedo e o revisa à medida que chega mais áudio, fixando-o só quando ouviu o suficiente. Toda API de fala em streaming expõe isso de alguma forma. Algumas chamam os palpites de resultados "interim" ou "partial" e marcam os fixados com is_final; o vocabulário varia, a disciplina não. Aqui o palpite é um rascunho e o texto fixado é uma confirmação.
transcript.draft é a cauda ao vivo: as palavras ouvidas desde a última confirmação, ainda mudando. Cada rascunho substitui por completo o anterior. Redesenhe a cauda com os seus segments; nunca a acrescente. Os rascunhos não têm seq por design, então não tente associá-los às confirmações em que se tornarão.
transcript.committed é final: um segment com seq (0, 1, 2, sem lacunas), start, end, text, speaker e words com o tempo de cada palavra. Acrescente-o e descarte o rascunho que ele cobre; o próximo rascunho começa onde esta confirmação terminou.
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
Uma interface que acrescenta rascunhos mostra texto duplicado e tremeluzente. Sua visualização de legendas ganha um engasgo que o orador nunca teve. Mostre todos os segmentos confirmados e, depois deles, o rascunho mais recente; limpe o rascunho quando chegar uma confirmação. Uma confirmação pode conter palavras que nunca apareceram em nenhum rascunho.
Aqui estão alguns segundos de fala no fio, do exemplo de fetch acima (translate: ["es"], montagem de frases desligada):
{"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}]}
Mais dois frames ficam ao redor destes. transcript.amended corrige um segmento confirmado no lugar (mesmo seq, novo text ou speaker) quando alguma palavra dele foi corrigida; aplique-o à linha que você já desenhou. transcript.words transmite os tempos das palavras da cauda ao vivo logo antes de ela ser confirmada. Ele precisa de events.words, que vem ativado por padrão; uma confirmação sempre inclui suas words, seja qual for a configuração de eventos. Por fim, ignore tipos de frame que você não conhece: novos tipos são adicionados sem aviso.
Aqui está o laço de leitura do SDK em Python, com o terminal como visualização de legendas. A montagem de frases também está desligada, então as traduções chegam como transcript.translated, e o frame error aparece como um VocalStackError lançado de dentro do laço:
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 tradução por segmento, como acima, é a forma simples. A forma que os leitores preferem ganha uma seção própria.
translate: ["es", "fr"] pede traduções conforme a sessão avança. Como elas chegam depende da montagem de frases (sentences: { enabled: true }), que vem ativada por padrão.
Com as frases desligadas, cada confirmação é seguida, um pouco depois, por um frame transcript.translated que indica o seu seq e traz translations indexadas por idioma; anexe-as a esse segmento. É o que os exemplos até aqui usaram. É simples e se parece com um registro de confirmações: um segmento, uma tradução, a fronteira da frase onde o orador tomou fôlego.
Com as frases ligadas, o servidor reúne as confirmações em frases inteiras e as traduz, o que lê muito melhor. Cada frame transcript.sentence substitui por completo uma frase, identificada por sentenceId. Seu status passa de open para closed (ou dropped, que significa descartar), e suas translations são provisórias enquanto a frase está aberta e finais depois que ela fecha. Faça upsert por sentenceId, nunca acrescente. O SDK em TypeScript traz applySentence justamente para isso:
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 traduções seguem a transcrição com um ou dois segundos de atraso. Se quiser que sejam faladas também, acrescente voice: ["es"] (exige frases). O espanhol de cada frase fechada chega então como um frame transcript.voice, com o clipe em Ogg/Opus codificado em base64, 24 kHz mono. Uma frase longa chega em pedaços (part de parts) para tocar em sequência. Texto provisório nunca é falado. O guia de tradução ao vivo detalha as vozes, e para onde vão os segundos explica a cadeia de atraso.
Os idiomas de destino não são fixados na criação.
Um frame de texto session.update muda spokenLanguage, translate, speakerLabels, latencyMode, voice ou voiceByLang a partir daquele ponto; os campos que você omitir continuam como estavam.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
Os SDKs expõem isso como session.update(…). translate substitui a lista inteira, e [] desliga a tradução. Segmentos já entregues não são transcritos nem traduzidos de novo, e se você ligar os rótulos de orador, os oradores só são rotulados a partir daquele ponto. O que já foi dito continua dito; as configurações só governam o que vem depois.
Quando o áudio termina, envie {"type":"eof"} (session.eof() nos SDKs). O servidor transcreve o que ainda tem, envia as últimas confirmações, depois session.done e fecha. Enviar cancel em vez disso para imediatamente e descarta o restante.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason é eof, max_duration (a sessão atingiu o tamanho máximo do plano e executou o mesmo encerramento) ou cancelled. stats é medido pelo servidor: audioS é o áudio que você enviou, processingS é o tempo de relógio que a sessão levou, e realtimeFactor é a razão entre os dois. Uma sessão ao vivo fica logo abaixo de 1, porque o tempo de relógio inclui o encerramento final depois do seu eof. Um número bem abaixo disso significa que a sessão levou bem mais tempo do que durou o áudio; o primeiro lugar para olhar é a velocidade com que o áudio chegou. Para um arquivo, o mesmo número indica quantas vezes mais rápido que a reprodução a transcrição voltou.
Uma sessão dura no máximo limits.maxDurationS segundo GET /v1/realtime/capabilities, e seu áudio consome o crédito por segundo na taxa ao vivo do seu plano, com o mesmo mínimo de 5 minutos de qualquer transcrição: cada sessão que recebe áudio é cobrada por pelo menos 5 minutos (preços). O mesmo socket também aceita um arquivo inteiro: crie a sessão com input: { kind: "file" }, envie os bytes do próprio arquivo (mp3, wav, flac, ogg, m4a) e eof, e a transcrição volta muitas vezes mais rápido que a reprodução do áudio, com frames transcript.progress ao longo do caminho.
Não há retomada. Uma conexão caída encerra a sessão; reconectar significa criar uma nova, cobrada com seu próprio mínimo, e transmitir de novo a partir da sua fonte ao vivo, com seq voltando a 0. A reconexão em si é o padrão de qualquer API baseada em socket, e vale acertá-la uma vez:
retryable: true em um frame error, um 503 com seu Retry-After, ou 30 segundos sem frame, que equivalem a capacity_exceeded. invalid_token e session_expired significam uma nova streamUrl do seu servidor, não outra tentativa com a antiga.Mantenha a rota que cria sessões barata de chamar e trate um encerramento como um início novo. Não ter retomada é o preço de não armazenar nada; se a transcrição precisa sobreviver ao socket, guarde-a.
Quando você quer que o mesmo fluxo seja guardado como gravação, corrigido depois, exportado e lido por uma audiência em seus próprios idiomas, publique-o em uma transcrição ao vivo. O guia de transmissão ao vivo percorre o mesmo socket PCM por uma transcrição e um link de transmissão, e o guia de escuta mostra o lado do assinante, em que cada ouvinte escolhe um idioma e quem chega atrasado alcança a transmissão a partir de um cursor lastSeq. Como isso fica para uma sala é o assunto de um link, todos os idiomas.
Para um arquivo que você quer guardar, a rota armazenada é transcrever um arquivo com a API. A referência completa dos frames, com todos os campos, está no guia Transmitir um microfone da referência da API.