<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Сергей Скрыльков</title>
    <description>The latest articles on DEV Community by Сергей Скрыльков (@skrylkovs).</description>
    <link>https://dev.to/skrylkovs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2888376%2F8209e5cf-2173-485a-a73d-f1d11f28e2a1.png</url>
      <title>DEV Community: Сергей Скрыльков</title>
      <link>https://dev.to/skrylkovs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/skrylkovs"/>
    <language>en</language>
    <item>
      <title>Legendas traduzidas ao vivo numa extensão do Chrome: áudio da aba no MV3 e um fluxo em vez de dois serviços</title>
      <dc:creator>Сергей Скрыльков</dc:creator>
      <pubDate>Tue, 22 Sep 2026 22:47:57 +0000</pubDate>
      <link>https://dev.to/skrylkovs/legendas-traduzidas-ao-vivo-numa-extensao-do-chrome-audio-da-aba-no-mv3-e-um-fluxo-em-vez-de-dois-28ae</link>
      <guid>https://dev.to/skrylkovs/legendas-traduzidas-ao-vivo-numa-extensao-do-chrome-audio-da-aba-no-mv3-e-um-fluxo-em-vez-de-dois-28ae</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Áudio da aba no Manifest V3
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;streamId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabCapture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMediaStreamId&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;targetTabId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tabId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// dentro do documento offscreen:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mandatory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;chromeMediaSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tab&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;chromeMediaSourceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;streamId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AudioContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMediaStreamSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Depois um AudioWorklet junta os canais em mono e envia PCM Int16 em blocos de 4096 amostras, cerca de 85 ms a 48 kHz.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// a cada 50 ms, no máximo 500 ms, depois de fechar o documento offscreen&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tabs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabCapture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCapturedTabs&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;busy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;tabId&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dois serviços contra um único fluxo
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stt-rt-v5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;audio_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pcm_s16le&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sample_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;48000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;num_channels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;language_hints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;enable_language_identification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;enable_endpoint_detection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;translation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;one_way&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target_language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pt&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;O que a documentação não dizia:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Os códigos de idioma vêm sem região: pt-BR e pt-PT viram pt, zh-Hans e zh-Hant viram zh.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Quanto custa uma hora, pela fatura
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testar sem chamadas reais
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  O que continua sem solução
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>braziliandevs</category>
      <category>javascript</category>
      <category>chrome</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Translated captions in a Chrome extension: tab audio in MV3, and one stream instead of two services</title>
      <dc:creator>Сергей Скрыльков</dc:creator>
      <pubDate>Mon, 21 Sep 2026 22:49:17 +0000</pubDate>
      <link>https://dev.to/skrylkovs/translated-captions-in-a-chrome-extension-tab-audio-in-mv3-and-one-stream-instead-of-two-services-1ha3</link>
      <guid>https://dev.to/skrylkovs/translated-captions-in-a-chrome-extension-tab-audio-in-mv3-and-one-stream-instead-of-two-services-1ha3</guid>
      <description>&lt;p&gt;I build a Chrome extension on my own that shows translated captions over a Meet, Zoom or Teams call running in a browser tab. No bot joins the call; the audio comes from the tab itself. Below are the three things that took the most time. Figures are as of September 17, 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tab audio in Manifest V3
&lt;/h2&gt;

&lt;p&gt;In MV3 the extension background is a service worker, and it has neither Web Audio nor getUserMedia. So the service worker gets only a stream id and creates an offscreen document (reasons: ['USER_MEDIA']), an invisible page where those APIs exist:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;streamId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabCapture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getMediaStreamId&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;targetTabId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tabId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="c1"&gt;// inside the offscreen document:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mandatory&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;chromeMediaSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tab&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;chromeMediaSourceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;streamId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First trap: once the tab is captured, its sound stops reaching the speakers, and the user can no longer hear the call. The fix is to route the stream back to the output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AudioContext&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMediaStreamSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An AudioWorklet then mixes the channels down to mono and emits Int16 PCM in blocks of 4096 samples, about 85 ms at 48 kHz.&lt;/p&gt;

&lt;p&gt;Second trap: "Cannot capture a tab with an active stream". It shows up when the previous capture of that tab was not released, and neither a page reload nor chrome.runtime.reload() clears it. The stream lives in the offscreen document, so that document has to be closed. Even that is not enough: Chrome releases the stream asynchronously, so poll the state instead of guessing with a timeout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// poll every 50 ms, for at most 500 ms, after closing the offscreen document&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tabs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;chrome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabCapture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCapturedTabs&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;busy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tabId&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;tabId&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Third, a product one: getMediaStreamId needs the extension to have been invoked on that tab (activeTab), and the grant only lasts until navigation. After a page reload the honest message is "click the icon", not "error". And only tab audio can be captured: desktop Zoom and Teams apps, like phones, are out of reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two services versus one stream
&lt;/h2&gt;

&lt;p&gt;The first version recognised speech with a Deepgram stream and translated with DeepL. To keep the translation from appearing in one jump at the end of a sentence, interim results were translated too. DeepL bills by source length, and an unfinished line keeps growing and gets sent again in full. How expensive that is cannot be read from the code, it depends on how recognition splits the phrase, so I measured it on the wire: a test track went into a real Deepgram socket, and interim results ran through the extension engine with the translation call stubbed. Measured on August 29: 2.53 times more characters went to translation than when translating final phrases only, and an hour of call cost about $2.55.&lt;/p&gt;

&lt;p&gt;The way out was a provider that recognises and translates in one stream. I moved to Soniox, model stt-rt-v5. The server mints a temporary key: the TTL limits how long the key can open streams, not how long an open stream lives, so a call of any length fits a TTL of a few minutes. The session config goes as the first message, before the first byte of audio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;api_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stt-rt-v5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;audio_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pcm_s16le&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sample_rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;48000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;num_channels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;language_hints&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;enable_language_identification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;enable_endpoint_detection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;translation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;one_way&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;target_language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What the docs did not say:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The server announces the end of a line with an  token, but translation lags a few tokens behind speech. Closing the line right at  glues the translation tail to the next phrase, so I hold a 700 ms window.&lt;/li&gt;
&lt;li&gt;Language codes come without regions: pt-BR and pt-PT both become pt, zh-Hans and zh-Hant both become zh.&lt;/li&gt;
&lt;li&gt;Errors 400, 401, 402 and 403 are not fixed by reconnecting; retries only give almost twenty seconds of empty screen. I treat them as fatal at once and fall back to a slower pipeline of 3-second chunks on another provider's key.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What an hour costs, by the invoice
&lt;/h2&gt;

&lt;p&gt;From /v1/usage/summary, August 30 to September 17: stt-rt-v5, $31.23 for 202.6 hours of audio, which is $0.154 per hour. Against $2.55 with two services, that is 17 times less.&lt;/p&gt;

&lt;p&gt;The price is paid in latency, not money. On the test stand in August a final phrase arrived after a bit over 2 s with Deepgram; on September 8 with Soniox it took 4.3 and 7.6 s, because the server now decides where the phrase ends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing without live calls
&lt;/h2&gt;

&lt;p&gt;Everything interesting happens in Chrome and on the real platform, so unit tests catch little. A Linux box runs a stand: three Chrome profiles join one call and "speak" with their own voices (an interview script of 118 lines, synthesised in advance), and a fourth profile with the extension writes captions. A script compares them with the script: how many lines were recognised, under which speaker name, how many milliseconds until the final.&lt;/p&gt;

&lt;p&gt;That is how the latency difference between providers showed up, and a billing bug: time was counted from the socket opening, not from the first phrase, so a silent tab burned minutes. Now the counter waits for the first final: 43.7 s of open socket on a silent tab, zero seconds billed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still unsolved
&lt;/h2&gt;

&lt;p&gt;Final latency is higher than I would like for a conversation. And I cannot yet tell "the tab is silent" from "the provider is quiet": the PCM counter grows during silence too.&lt;/p&gt;

&lt;p&gt;I would be glad to hear critique of the MV3 capture part, especially if someone found something more reliable than polling getCapturedTabs&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this with help from an AI assistant for translation and editing. The code, measurements and figures come from my own project.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>chrome</category>
    </item>
  </channel>
</rss>
