Eu desenvolvo sozinho uma extensão de Chrome que mostra legendas traduzidas em tempo real sobre uma chamada do Meet, do Zoom ou do Teams aberta no navegador. Nenhum bot entra na reunião: o áudio vem da própria aba. Abaixo estão as três partes que deram mais trabalho. Os números são de 17 de setembro de 2026.
Áudio da aba no Manifest V3
No MV3 o plano de fundo da extensão é um service worker, e lá não existe Web Audio nem getUserMedia. Por isso o service worker só obtém um id de stream e cria um documento offscreen (reasons: ['USER_MEDIA']), uma página invisível onde essas APIs existem:
const streamId = await chrome.tabCapture.getMediaStreamId({ targetTabId: tabId });
// dentro do documento offscreen:
const stream = await navigator.mediaDevices.getUserMedia({
audio: { mandatory: { chromeMediaSource: 'tab', chromeMediaSourceId: streamId } },
});
Primeira armadilha: assim que a aba é capturada, o som dela para de sair nas caixas e a pessoa deixa de ouvir a reunião. A correção é devolver o stream para a saída:
const ctx = new AudioContext();
ctx.createMediaStreamSource(stream).connect(ctx.destination);
Depois um AudioWorklet junta os canais em mono e envia PCM Int16 em blocos de 4096 amostras, cerca de 85 ms a 48 kHz.
Segunda armadilha: "Cannot capture a tab with an active stream". Aparece quando a captura anterior daquela aba não foi liberada, e não some nem recarregando a página nem com chrome.runtime.reload(). O stream vive no documento offscreen, então é preciso fechá-lo. E nem isso basta: o Chrome libera de forma assíncrona, então consulte o estado em vez de adivinhar com um timeout:
// a cada 50 ms, no máximo 500 ms, depois de fechar o documento offscreen
const tabs = await chrome.tabCapture.getCapturedTabs().catch(() => []);
const busy = tabs.some((t) => t.tabId === tabId && t.status === 'active');
Terceira, do lado do produto: getMediaStreamId exige que a extensão tenha sido invocada naquela aba (activeTab), e essa permissão dura só até a próxima navegação. Depois de recarregar a página, a mensagem honesta é "clique no ícone", não "erro". E só dá para capturar o áudio da aba: os aplicativos de desktop do Zoom e do Teams, e o celular, ficam de fora.
Dois serviços contra um único fluxo
A primeira versão reconhecia a fala com um stream da Deepgram e traduzia com a DeepL. Para a tradução não aparecer de uma vez só no fim da frase, os resultados parciais também iam para o tradutor. A DeepL cobra pelo tamanho do texto de origem, e uma frase ainda aberta cresce e é reenviada inteira a cada vez. O quanto isso custa não dá para ler no código, depende de como o reconhecimento corta a frase, então medi no fio: uma faixa de teste entrava num socket real da Deepgram e os parciais passavam pelo motor da extensão com a chamada de tradução substituída. Medição de 29 de agosto: foram 2,53 vezes mais caracteres para a tradução do que traduzindo apenas as frases finais, e uma hora de chamada custava cerca de US$ 2,55.
A saída foi um provedor que reconhece e traduz no mesmo fluxo. Passei para a Soniox, modelo stt-rt-v5. O servidor emite uma chave temporária: o TTL limita por quanto tempo a chave pode abrir fluxos, não a duração de um fluxo já aberto, então uma chamada de qualquer tamanho cabe num TTL de poucos minutos. A configuração da sessão vai como primeira mensagem, antes do primeiro byte de áudio:
{
api_key, model: 'stt-rt-v5',
audio_format: 'pcm_s16le', sample_rate: 48000, num_channels: 1,
language_hints: ['en'], enable_language_identification: true,
enable_endpoint_detection: true,
translation: { type: 'one_way', target_language: 'pt' },
}
O que a documentação não dizia:
- O fim da frase é anunciado pelo servidor com um token , mas a tradução fica alguns tokens atrás da fala. Fechar a linha logo no cola o rabo da tradução na frase seguinte, então mantenho uma janela de 700 ms.
- Os códigos de idioma vêm sem região: pt-BR e pt-PT viram pt, zh-Hans e zh-Hant viram zh.
- Os erros 400, 401, 402 e 403 não se resolvem reconectando; as tentativas só rendem quase vinte segundos de tela vazia. Trato como fatal na hora e caio num caminho reserva mais lento, com blocos de 3 segundos na chave de outro provedor.
Quanto custa uma hora, pela fatura
Do /v1/usage/summary, de 30 de agosto a 17 de setembro: stt-rt-v5, US$ 31,23 por 202,6 horas de áudio, ou seja US$ 0,154 por hora. Contra os US$ 2,55 com dois serviços, é 17 vezes menos.
O preço é pago em atraso, não em dinheiro. No banco de testes, em agosto a frase final chegava com a Deepgram em pouco mais de 2 s; em 8 de setembro, com a Soniox, levou 4,3 s e 7,6 s, porque agora é o servidor que decide onde a frase termina.
Testar sem chamadas reais
Tudo que interessa acontece no Chrome e na plataforma real, então testes unitários pegam pouco. Numa máquina Linux roda um banco de testes: três perfis do Chrome entram na mesma chamada e "falam" com as próprias vozes (um roteiro de entrevista de 118 falas, sintetizado antes), e um quarto perfil, com a extensão, registra as legendas. Um script compara com o roteiro: quantas falas foram reconhecidas, sob qual nome, quantos milissegundos até o resultado final.
Foi assim que apareceu a diferença de atraso entre provedores, e também um bug de cobrança: o tempo era contado da abertura do socket, e não da primeira legenda, então uma aba sem som gastava minutos. Agora o contador espera o primeiro resultado final: 43,7 s de socket aberto numa aba muda, zero segundos cobrados.
O que continua sem solução
O atraso da linha final ainda é maior do que eu gostaria para uma conversa. E ainda não sei distinguir "a aba está muda" de "o provedor parou de responder": o contador de PCM cresce no silêncio também.
Fico feliz com críticas à parte da captura no MV3, principalmente se alguém achou algo mais confiável do que consultar o getCapturedTabs em intervalos
Escrevi este texto com ajuda de uma IA para tradução e edição. O código, as medições e os números vêm do meu próprio projeto.
Top comments (0)