Legendas traduzidas ao vivo numa extensão do Chrome: áudio da aba no MV3 e um fluxo em vez de dois serviços

Legendas traduzidas ao vivo numa extensão do Chrome: áudio da aba no MV3 e um fluxo em vez de dois serviços

Chrome 扩展实时翻译字幕:MV3 中的标签页音频与单流替代双服务方案

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. 我独自开发了一款 Chrome 扩展程序,可以在浏览器中打开的 Meet、Zoom 或 Teams 会议上实时显示翻译后的字幕。没有任何机器人进入会议:音频直接来自标签页本身。以下是开发过程中最棘手的三个部分。数据截至 2026 年 9 月 17 日。

Áudio da aba no Manifest V3

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: 在 MV3 中,扩展程序的后台是一个 Service Worker,那里既没有 Web Audio 也没有 getUserMedia。因此,Service Worker 只能获取一个流 ID,并创建一个离屏文档(offscreen document,原因:[‘USER_MEDIA’]),这是一个存在这些 API 的不可见页面:

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. 随后,一个 AudioWorklet 将声道合并为单声道,并以 4096 个采样点的块发送 PCM Int16 数据,在 48 kHz 下约为 85 毫秒。

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: 第二个陷阱:“Cannot capture a tab with an active stream”(无法捕获具有活动流的标签页)。当该标签页之前的捕获未释放时会出现此错误,且无论是刷新页面还是使用 chrome.runtime.reload() 都无法解决。流存在于离屏文档中,因此必须将其关闭。但这还不够:Chrome 是异步释放资源的,所以要查询状态,而不是用超时(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. 第三个陷阱,从产品角度看:getMediaStreamId 要求扩展程序必须在该标签页中被调用(activeTab),且该权限仅持续到下一次导航。刷新页面后,诚实的提示应该是“点击图标”,而不是“错误”。此外,只能捕获标签页音频:Zoom 和 Teams 的桌面应用程序以及手机端均无法捕获。


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. 第一个版本使用 Deepgram 流进行语音识别,并使用 DeepL 进行翻译。为了不让翻译在句末才一次性显示,部分结果也会发送给翻译器。DeepL 按源文本长度收费,而一个未结束的句子会不断增长,并每次被完整重新发送。这到底花费多少无法从代码中直接读出,取决于识别器如何切分句子,所以我进行了实测:一段测试音频进入真实的 Deepgram socket,部分结果通过扩展引擎,同时替换掉翻译调用。

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. 8 月 29 日的测量结果显示:翻译的字符数是仅翻译完整句子的 2.53 倍,一小时通话成本约为 2.55 美元。解决方案是寻找一个在同一流中完成识别和翻译的提供商。我转向了 Soniox,使用 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: 服务器会颁发一个临时密钥:TTL 限制了密钥打开流的时长,而不是已打开流的持续时间,因此任何时长的通话都可以包含在几分钟的 TTL 内。会话配置作为第一条消息发送,在第一个音频字节之前:

{
  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 <eos> 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. 文档中没提到的是:服务器会用 <eos> token 宣布句末,但翻译会比语音滞后几个 token。如果在 <eos> 处立即关闭行,翻译的尾巴会粘到下一句中,所以我保留了 700 毫秒的窗口。语言代码不带地区:pt-BR 和 pt-PT 变为 pt,zh-Hans 和 zh-Hant 变为 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. 400、401、402 和 403 错误无法通过重连解决;尝试重连只会导致近 20 秒的空白屏幕。我将其视为致命错误,并立即切换到较慢的备用路径,使用另一个提供商的密钥进行 3 秒块处理。

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. 根据 8 月 30 日至 9 月 17 日的 /v1/usage/summary:stt-rt-v5 费用为 31.23 美元,处理了 202.6 小时音频,即每小时 0.154 美元。相比双服务方案的 2.55 美元,成本降低了 17 倍。价格是事后结算的。在测试基准中,8 月份 Deepgram 的最终句子在 2 秒多一点到达;9 月 8 日,使用 Soniox,耗时 4.3 秒到 7.6 秒,因为现在是由服务器决定句子在哪里结束。

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. 所有关键环节都发生在 Chrome 和真实平台中,因此单元测试作用有限。我在一台 Linux 机器上运行测试基准:三个 Chrome 配置文件进入同一个通话,并用各自的声音“说话”(预先合成的 118 句采访脚本),第四个配置文件运行扩展程序并记录字幕。一个脚本将其与脚本进行对比:识别了多少句话、在什么名字下、距离最终结果有多少毫秒。通过这种方式,我发现了不同提供商之间的延迟差异,以及一个计费 Bug:时间是从 socket 打开开始计算的,而不是从第一条字幕开始,所以静音标签页会消耗掉几分钟。现在计数器会等待第一个最终结果:在静音标签页中 socket 打开 43.7 秒,计费为零秒。

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. 最终行的延迟对于对话来说仍然比我预期的要大。而且我仍然无法区分“标签页静音”和“提供商停止响应”:PCM 计数器在静音时也会增长。欢迎对 MV3 捕获部分提出批评,特别是如果有人发现了比轮询 getCapturedTabs 更可靠的方法。

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. 本文在 AI 的帮助下进行翻译和编辑。代码、测量数据和数字均来自我自己的项目。