<?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: Oleksii “Alex” Herasymchuk</title>
    <description>The latest articles on DEV Community by Oleksii “Alex” Herasymchuk (@alexey_grsm).</description>
    <link>https://dev.to/alexey_grsm</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3888643%2F9ab127f1-a0ce-4f6c-82d3-21af602a728a.jpg</url>
      <title>DEV Community: Oleksii “Alex” Herasymchuk</title>
      <link>https://dev.to/alexey_grsm</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/alexey_grsm"/>
    <language>en</language>
    <item>
      <title>Why my real-time Google Meet translator runs on your laptop, not my server</title>
      <dc:creator>Oleksii “Alex” Herasymchuk</dc:creator>
      <pubDate>Mon, 20 Apr 2026 11:40:10 +0000</pubDate>
      <link>https://dev.to/alexey_grsm/why-my-real-time-google-meet-translator-runs-on-your-laptop-not-my-server-243d</link>
      <guid>https://dev.to/alexey_grsm/why-my-real-time-google-meet-translator-runs-on-your-laptop-not-my-server-243d</guid>
      <description>&lt;p&gt;&lt;strong&gt;$0/month in infra costs. Audio that never leaves the user's device. Real-time two-way voice translation in Google Meet.&lt;/strong&gt; Here's the architecture trick that made all three possible at the same time.&lt;/p&gt;

&lt;p&gt;I built a Chrome extension that does real-time, two-way voice translation in Google Meet. You speak Russian, your colleague hears English. They reply in German, you hear Russian. Subtitles, TTS, the whole thing.&lt;/p&gt;

&lt;p&gt;Then I had to figure out how to ship it.&lt;/p&gt;

&lt;p&gt;Most "AI in your meeting" tools follow the same playbook: the client streams mic audio to a backend, the backend pays for STT + LLM + TTS, and the user pays a subscription that hopefully covers the bill plus some. That model has two problems I didn't want:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every minute of conversation is a row in my AWS bill, and I have no upside on heavy users.&lt;/li&gt;
&lt;li&gt;Every minute of conversation is also someone else's microphone going through my server. That's a privacy story I didn't want to maintain.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So MeetVoice ships a different way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pivot: BYOK + a desktop app
&lt;/h2&gt;

&lt;p&gt;The architecture is two things you don't usually combine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bring-your-own-key (BYOK):&lt;/strong&gt; users plug in their own Deepgram + Groq + (optional) OpenAI keys. Free-tier Edge TTS as default — Microsoft pays for that one (unofficial endpoint, but it's been stable for years).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "server" runs on the user's laptop.&lt;/strong&gt; I ship a small Electron tray app for Windows and macOS that boots a local WebSocket server on &lt;code&gt;127.0.0.1:18900&lt;/code&gt;. The Chrome extension connects to it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What I get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero infra cost.&lt;/strong&gt; No EC2, no Cloud Run, no serverless cold starts. My recurring infra bill is one Cloudflare Worker for the marketing site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio never leaves the device&lt;/strong&gt; (modulo the user's chosen STT provider, which is on their key — and they picked it).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scaling is free.&lt;/strong&gt; New user = new laptop = new server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I trade away:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Onboarding is harder. "Download an app" is more friction than "install extension and sign in."&lt;/li&gt;
&lt;li&gt;I can't auto-update server-side bug fixes without an electron-updater roundtrip (R2 + electron-updater handles this fine, but it's another moving part).&lt;/li&gt;
&lt;li&gt;Licensing has to live on the desktop side (LemonSqueezy + a tiny Cloudflare Worker for entitlement checks).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For an indie SaaS, that tradeoff is a no-brainer. Now let me show you the technically interesting part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mic / Tab audio
   │
   ▼
Deepgram Nova-3 (streaming WebSocket, diarization)
   │
   ▼
TranscriptBuffer (sentence boundary + speaker change + 4s safety timeout)
   │
   ▼
Groq Llama 3.3 70B (streaming, sentence-chunked translation)
   │
   ▼
Edge TTS (free, Microsoft Neural voices)
   │
   ▼
Audio injection back into Meet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two of these run in parallel per call:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Incoming pipeline&lt;/strong&gt; (&lt;code&gt;peerLang → userLang&lt;/code&gt;): tab audio → translated voice played through your speakers, plus subtitles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outgoing pipeline&lt;/strong&gt; (&lt;code&gt;userLang → peerLang&lt;/code&gt;): your mic → translated voice spoken into the meeting &lt;em&gt;as if you said it&lt;/em&gt;, plus subtitles for the other side.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both pipelines share one WebSocket. I multiplex direction with a prefix byte (&lt;code&gt;0x00&lt;/code&gt; incoming, &lt;code&gt;0x01&lt;/code&gt; outgoing). Cheap, schemaless, works.&lt;/p&gt;

&lt;p&gt;End-to-end latency is around &lt;strong&gt;1.5–2 seconds&lt;/strong&gt; in the steady state. Most of it is Deepgram waiting to confidently mark a chunk &lt;code&gt;is_final&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now the two parts that took the longest to get right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hack #1: hijacking getUserMedia to inject TTS into Meet
&lt;/h2&gt;

&lt;p&gt;This is the cool one.&lt;/p&gt;

&lt;p&gt;When Meet wants your microphone, it calls &lt;code&gt;navigator.mediaDevices.getUserMedia({ audio: true })&lt;/code&gt;. It gets back a &lt;code&gt;MediaStream&lt;/code&gt;, and that's what flows to the other participants.&lt;/p&gt;

&lt;p&gt;So I just... return a different stream.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// content script, world: "MAIN", runAt: "document_start"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;origGetUserMedia&lt;/span&gt; &lt;span class="o"&gt;=&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="nx"&gt;getUserMedia&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&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="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="nx"&gt;getUserMedia&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;constraints&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;constraints&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;origGetUserMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;constraints&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Get the real mic, but don't hand it to Meet directly&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;realStream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;origGetUserMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;constraints&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Build a controllable stream Meet will hold a reference to&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controlStream&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;MediaStream&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &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;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;realStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAudioTracks&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;controlStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTrack&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="k"&gt;for &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;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;realStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getVideoTracks&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;controlStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTrack&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="c1"&gt;// After the next user gesture, swap the audio tracks for our mixed stream&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trySetupGraph&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;controlStream&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;The mixed stream is built with Web Audio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;audioCtx&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="na"&gt;sampleRate&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;destination&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMediaStreamDestination&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;micSource&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioCtx&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;realStream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;micGainNode&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createGain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// mic, with ducking&lt;/span&gt;
&lt;span class="nx"&gt;ttsGainNode&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createGain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// injected TTS, with boost&lt;/span&gt;

&lt;span class="nx"&gt;micSource&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;micGainNode&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;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;ttsGainNode&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;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Swap tracks on the stream Meet is already holding a reference to&lt;/span&gt;
&lt;span class="k"&gt;for &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;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;controlStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAudioTracks&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;controlStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeTrack&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="k"&gt;for &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;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;destination&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;getAudioTracks&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;controlStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTrack&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the server sends translated TTS audio back:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Decode the chunks into an &lt;code&gt;AudioBuffer&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Duck &lt;code&gt;micGainNode&lt;/code&gt; to 20%, so you don't talk over yourself.&lt;/li&gt;
&lt;li&gt;Play the buffer through &lt;code&gt;ttsGainNode → destination&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On &lt;code&gt;source.onended&lt;/code&gt;, restore the mic gain.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;From the other participants' point of view, they hear &lt;em&gt;you&lt;/em&gt; speaking their language. Their Meet client doesn't know there's a synthesised voice in the pipe — it's just bytes on the same MediaStream Meet asked for.&lt;/p&gt;

&lt;p&gt;A few things that bit me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AudioContext needs a user gesture&lt;/strong&gt; to start in the &lt;code&gt;running&lt;/code&gt; state. So &lt;code&gt;getUserMedia&lt;/code&gt; returns the &lt;em&gt;real&lt;/em&gt; stream first, and the swap happens on the next click/keydown. Skip this and Chrome creates the context in &lt;code&gt;suspended&lt;/code&gt; state — silent failure mode where nothing throws but no audio flows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The override script runs in the MAIN world&lt;/strong&gt;, which means &lt;strong&gt;no &lt;code&gt;chrome.*&lt;/code&gt; APIs.&lt;/strong&gt; All extension communication goes through &lt;code&gt;window.postMessage&lt;/code&gt; with &lt;code&gt;targetOrigin: "https://meet.google.com"&lt;/code&gt; (never &lt;code&gt;"*"&lt;/code&gt; — defense-in-depth).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sequential TTS queue is mandatory.&lt;/strong&gt; Two segments arriving back-to-back and decoded in parallel will overlap and sound like two drunk synths arguing. A single &lt;code&gt;isPlaying&lt;/code&gt; flag plus &lt;code&gt;playNext()&lt;/code&gt; in &lt;code&gt;source.onended&lt;/code&gt; is enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A monotonic &lt;code&gt;activePlaybackId&lt;/code&gt; counter&lt;/strong&gt;, bumped on every new playback. Stale &lt;code&gt;onended&lt;/code&gt; callbacks from a previous segment check it and bail out. Without this, a fast-arriving newer segment got its mic gain restored by an older callback and the next one started full-volume.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Hack #2: streaming translations without choppy TTS
&lt;/h2&gt;

&lt;p&gt;Deepgram emits two kinds of finalized transcripts: &lt;code&gt;is_final&lt;/code&gt; (this chunk is locked in) and &lt;code&gt;speech_final&lt;/code&gt; (the speaker just took a breath). If you translate every &lt;code&gt;is_final&lt;/code&gt; chunk you get garbage — three-word fragments, no context, awful cache behavior. If you wait for &lt;code&gt;speech_final&lt;/code&gt; you get clean translations but the user waits 2+ seconds before hearing anything.&lt;/p&gt;

&lt;p&gt;The compromise is a &lt;code&gt;TranscriptBuffer&lt;/code&gt; that flushes on whichever happens first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;speaker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Speaker switched — flush the previous speaker first&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;speaker&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;speaker&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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;accumulated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SENTENCE_BOUNDARY_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accumulated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;accumulated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                          &lt;span class="c1"&gt;// sentence done&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;wordCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accumulated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                          &lt;span class="c1"&gt;// long monologue&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// silence safety&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;On the translation side: instead of waiting for the LLM to finish the whole sentence, the Groq response is streamed and re-chunked by sentence (regex &lt;code&gt;[.!?]&lt;/code&gt; after 20+ chars). Each sentence is sent to TTS &lt;em&gt;as soon as it lands&lt;/em&gt;, not at end-of-stream. This pipelines TTS synthesis on top of LLM generation — first audible word arrives noticeably faster than the naive "translate, then synthesize" loop.&lt;/p&gt;

&lt;p&gt;Subtitles update on the interim transcripts (so the user sees them live), but TTS only plays on stable sentences. Best of both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stack rundown
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deepgram Nova-3&lt;/strong&gt; — only streaming STT I tried that handles speaker diarization well in noisy meetings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Groq + Llama 3.3 70B&lt;/strong&gt; — fastest LLM I can afford for a BYOK product. Cheaper per token than GPT-4o-mini and a few times higher throughput. OpenAI is the fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge TTS&lt;/strong&gt; (&lt;code&gt;msedge-tts&lt;/code&gt;, MIT-licensed) — Microsoft's Neural voices, free, sound great. OpenAI &lt;code&gt;tts-1&lt;/code&gt; is an optional upgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WXT&lt;/strong&gt; — best WebExtension framework I've used. Manifest V3, Vite, TypeScript, content-script worlds, all just work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Electron 41&lt;/strong&gt; with an ESM tray app — surprisingly clean. &lt;code&gt;utilityProcess&lt;/code&gt; runs the WS server in a child process so it can crash without taking the tray with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Astro 6&lt;/strong&gt; for the marketing site — static, fast, file-based i18n.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I rejected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI Whisper API&lt;/strong&gt; — the standard &lt;code&gt;/v1/audio/transcriptions&lt;/code&gt; endpoint takes a finished file, not a stream. (The newer Realtime API with &lt;code&gt;gpt-4o-transcribe&lt;/code&gt; exists, but it's a different beast and came too late for this design.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ElevenLabs&lt;/strong&gt; — beautiful voices, but the per-minute price would make BYOK unaffordable for daily users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A traditional VPS backend&lt;/strong&gt; — the entire point of this design.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three things I'd tell past me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;BYOK + local server is a real pattern.&lt;/strong&gt; Cost-of-revenue collapses to $0. Privacy goes from a marketing line to an architecture property. The price you pay is onboarding friction — and most pro users will gladly trade that for control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manifest V3 is harder than the docs admit.&lt;/strong&gt; You can't keep state in the service worker. You need an offscreen document for anything stateful (audio, persistent WebSocket). &lt;code&gt;chrome.storage&lt;/code&gt; is &lt;em&gt;not&lt;/em&gt; available in the offscreen doc, so you message-pass with retry. Plan for it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Electron is not as bad as Twitter says.&lt;/strong&gt; A tray-only app is ~200 MB on disk and ~80 MB RAM idle. electron-builder handles signing on Mac/Windows. GitHub Actions builds the macOS DMG on &lt;code&gt;macos-latest&lt;/code&gt; for free.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to try the thing: &lt;strong&gt;download MeetVoice for Windows or macOS at &lt;a href="https://meetvoice.app" rel="noopener noreferrer"&gt;meetvoice.app&lt;/a&gt;&lt;/strong&gt; and &lt;strong&gt;install the &lt;a href="https://chromewebstore.google.com/detail/meetvoice/ahmdecnledffbnblohemfonphfapcifg" rel="noopener noreferrer"&gt;Chrome extension&lt;/a&gt;&lt;/strong&gt;. You'll need a Deepgram key (free tier is enough to test); the rest is optional.&lt;/p&gt;

&lt;p&gt;Happy to answer questions in the comments — especially about the audio graph or the MV3 offscreen-doc dance. Those took the most pain to figure out.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Russian version: &lt;a href="https://dev.to/alexey_grsm/pochiemu-moi-real-time-pierievodchik-dlia-google-meet-rabotaiet-u-vas-na-noutbukie-a-nie-na-moiom-siervierie-4423"&gt;На русском&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Почему мой real-time переводчик для Google Meet работает у вас на ноутбуке, а не на моём сервере</title>
      <dc:creator>Oleksii “Alex” Herasymchuk</dc:creator>
      <pubDate>Mon, 20 Apr 2026 11:36:26 +0000</pubDate>
      <link>https://dev.to/alexey_grsm/pochiemu-moi-real-time-pierievodchik-dlia-google-meet-rabotaiet-u-vas-na-noutbukie-a-nie-na-moiom-siervierie-4423</link>
      <guid>https://dev.to/alexey_grsm/pochiemu-moi-real-time-pierievodchik-dlia-google-meet-rabotaiet-u-vas-na-noutbukie-a-nie-na-moiom-siervierie-4423</guid>
      <description>&lt;p&gt;&lt;strong&gt;$0/месяц на инфраструктуру. Аудио, которое не покидает устройство пользователя. Real-time двусторонний голосовой перевод в Google Meet.&lt;/strong&gt; Вот архитектурный трюк, который позволил совместить всё это сразу.&lt;/p&gt;

&lt;p&gt;Я сделал Chrome-расширение, которое в реальном времени двусторонне переводит голос в Google Meet. Вы говорите по-русски — собеседник слышит английский. Он отвечает по-немецки — вы слышите русский. Субтитры, TTS, всё как полагается.&lt;/p&gt;

&lt;p&gt;Потом надо было решить, как это шипить.&lt;/p&gt;

&lt;p&gt;Большинство «AI в созвонах» работают по одной схеме: клиент стримит микрофон на бэкенд, бэкенд платит за STT + LLM + TTS, пользователь платит подписку, которая (надеемся) покрывает счёт и оставляет маржу. Меня в этой модели не устраивали две вещи:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Каждая минута разговора — это строчка в моём AWS-биле, и у меня нет апсайда от тяжёлых пользователей.&lt;/li&gt;
&lt;li&gt;Каждая минута разговора — это ещё и чужой микрофон, проходящий через мой сервер. Privacy story, которую мне не хотелось поддерживать.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Поэтому MeetVoice шипится иначе.&lt;/p&gt;

&lt;h2&gt;
  
  
  Поворот: BYOK + десктопное приложение
&lt;/h2&gt;

&lt;p&gt;Архитектура — это две штуки, которые обычно вместе не встречаются:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bring-your-own-key (BYOK):&lt;/strong&gt; пользователь подключает свои ключи Deepgram + Groq + (опционально) OpenAI. По дефолту бесплатный Edge TTS — за этот платит Microsoft (через недокументированный endpoint, но он стабильно работает уже несколько лет).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;«Сервер» крутится у пользователя на ноутбуке.&lt;/strong&gt; Я поставляю маленькое Electron tray-приложение под Windows и macOS, которое поднимает локальный WebSocket-сервер на &lt;code&gt;127.0.0.1:18900&lt;/code&gt;. Расширение коннектится к нему.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Что я с этого получаю:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ноль инфраструктурных затрат.&lt;/strong&gt; Никаких EC2, Cloud Run, cold starts. Recurring счёт — один Cloudflare Worker для маркетинг-сайта.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Аудио не покидает устройство&lt;/strong&gt; (с поправкой на STT-провайдера, которого пользователь сам выбрал и оплачивает своим ключом).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Скейлинг бесплатный.&lt;/strong&gt; Новый пользователь = новый ноутбук = новый сервер.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Чем плачу:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Онбординг сложнее. «Скачать приложение» — больше трения, чем «поставить расширение и залогиниться».&lt;/li&gt;
&lt;li&gt;Не могу автоматически выкатить серверный фикс — нужен electron-updater roundtrip (R2 + electron-updater всё это умеют, но это лишняя движущаяся часть).&lt;/li&gt;
&lt;li&gt;Лицензирование живёт на десктопной стороне (LemonSqueezy + крошечный Cloudflare Worker для проверки entitlement).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Для indie SaaS этот трейдоф — no-brainer. Теперь технически интересная часть.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mic / Tab audio
   │
   ▼
Deepgram Nova-3 (streaming WebSocket, диаризация)
   │
   ▼
TranscriptBuffer (граница предложения + смена спикера + safety timeout 4с)
   │
   ▼
Groq Llama 3.3 70B (streaming, sentence-chunked перевод)
   │
   ▼
Edge TTS (бесплатно, Microsoft Neural voices)
   │
   ▼
Инжекция аудио обратно в Meet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;В одном звонке параллельно работают два таких pipeline'а:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Incoming&lt;/strong&gt; (&lt;code&gt;peerLang → userLang&lt;/code&gt;): tab audio → переведённый голос играется в ваших колонках, плюс субтитры.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Outgoing&lt;/strong&gt; (&lt;code&gt;userLang → peerLang&lt;/code&gt;): ваш микрофон → переведённый голос, который произносится в Meet &lt;em&gt;как будто это вы говорите&lt;/em&gt;, плюс субтитры для собеседника.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Оба pipeline'а делят один WebSocket. Направление мультиплексируется через prefix-байт (&lt;code&gt;0x00&lt;/code&gt; incoming, &lt;code&gt;0x01&lt;/code&gt; outgoing). Дёшево, без схем, работает.&lt;/p&gt;

&lt;p&gt;End-to-end latency в установившемся режиме — около &lt;strong&gt;1.5–2 секунд&lt;/strong&gt;. Большая часть — Deepgram, который ждёт, чтобы уверенно пометить чанк &lt;code&gt;is_final&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Дальше — две вещи, на которые ушло больше всего времени.&lt;/p&gt;

&lt;h2&gt;
  
  
  Хак №1: перехват getUserMedia для инжекции TTS в Meet
&lt;/h2&gt;

&lt;p&gt;Это самая интересная часть.&lt;/p&gt;

&lt;p&gt;Когда Meet запрашивает микрофон, он вызывает &lt;code&gt;navigator.mediaDevices.getUserMedia({ audio: true })&lt;/code&gt;. Получает &lt;code&gt;MediaStream&lt;/code&gt;, и именно этот стрим уходит другим участникам.&lt;/p&gt;

&lt;p&gt;Я просто... отдаю ему другой стрим.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// content script, world: "MAIN", runAt: "document_start"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;origGetUserMedia&lt;/span&gt; &lt;span class="o"&gt;=&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="nx"&gt;getUserMedia&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&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="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="nx"&gt;getUserMedia&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;constraints&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;constraints&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;origGetUserMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;constraints&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Получаем настоящий микрофон, но Meet его напрямую не даём&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;realStream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;origGetUserMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;constraints&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Строим управляемый стрим, на который Meet будет держать ссылку&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controlStream&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;MediaStream&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &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;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;realStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAudioTracks&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;controlStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTrack&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="k"&gt;for &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;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;realStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getVideoTracks&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;controlStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTrack&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="c1"&gt;// На следующем user gesture подменим аудио-треки на наш миксованный стрим&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;trySetupGraph&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;controlStream&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;Сам микс собирается на Web Audio:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;audioCtx&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="na"&gt;sampleRate&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;destination&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createMediaStreamDestination&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;micSource&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioCtx&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;realStream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;micGainNode&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createGain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// микрофон, с ducking&lt;/span&gt;
&lt;span class="nx"&gt;ttsGainNode&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;audioCtx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createGain&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// injected TTS, с boost&lt;/span&gt;

&lt;span class="nx"&gt;micSource&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;micGainNode&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;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;ttsGainNode&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;destination&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Подменяем треки на стриме, на который Meet уже держит ссылку&lt;/span&gt;
&lt;span class="k"&gt;for &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;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;controlStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAudioTracks&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;controlStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeTrack&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="k"&gt;for &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;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;destination&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;getAudioTracks&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;controlStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTrack&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Когда сервер присылает переведённый TTS:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Декодируем чанки в &lt;code&gt;AudioBuffer&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Опускаем &lt;code&gt;micGainNode&lt;/code&gt; до 20% — чтобы вы не говорили поверх собственного перевода.&lt;/li&gt;
&lt;li&gt;Играем буфер через &lt;code&gt;ttsGainNode → destination&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;На &lt;code&gt;source.onended&lt;/code&gt; восстанавливаем gain микрофона.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;С точки зрения других участников — они слышат, как &lt;em&gt;вы&lt;/em&gt; говорите на их языке. Их клиент Meet не подозревает, что в стриме синтезированный голос — это просто байты в том же &lt;code&gt;MediaStream&lt;/code&gt;, который Meet и запросил.&lt;/p&gt;

&lt;p&gt;Несколько граблей, на которые я наступил:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AudioContext'у нужен user gesture&lt;/strong&gt;, чтобы стартовать в &lt;code&gt;running&lt;/code&gt;. Поэтому &lt;code&gt;getUserMedia&lt;/code&gt; сначала возвращает &lt;em&gt;настоящий&lt;/em&gt; стрим, а подмена происходит на следующем click/keydown. Без этого Chrome создаёт контекст в state &lt;code&gt;suspended&lt;/code&gt; — silent failure: ничего не падает, но аудио не идёт.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Override-скрипт работает в MAIN world&lt;/strong&gt;, а значит &lt;strong&gt;никаких &lt;code&gt;chrome.*&lt;/code&gt; API.&lt;/strong&gt; Вся коммуникация с расширением — через &lt;code&gt;window.postMessage&lt;/code&gt; с &lt;code&gt;targetOrigin: "https://meet.google.com"&lt;/code&gt; (никогда &lt;code&gt;"*"&lt;/code&gt; — defense-in-depth).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Последовательная очередь TTS — обязательна.&lt;/strong&gt; Два сегмента, пришедшие подряд и декодированные параллельно, перекроются и зазвучат как два пьяных синтезатора. Достаточно одного флага &lt;code&gt;isPlaying&lt;/code&gt; + &lt;code&gt;playNext()&lt;/code&gt; в &lt;code&gt;source.onended&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Монотонный счётчик &lt;code&gt;activePlaybackId&lt;/code&gt;&lt;/strong&gt;, инкрементящийся на каждый новый playback. Stale &lt;code&gt;onended&lt;/code&gt; от предыдущего сегмента проверяет его и выходит. Без этого быстро пришедший новый сегмент получал восстановленный gain микрофона от старого callback'а — и стартовал на полной громкости.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Хак №2: streaming-перевод без рваного TTS
&lt;/h2&gt;

&lt;p&gt;Deepgram отдаёт два типа финализированных транскриптов: &lt;code&gt;is_final&lt;/code&gt; (этот чанк зафиксирован) и &lt;code&gt;speech_final&lt;/code&gt; (спикер только что взял паузу). Если переводить каждый &lt;code&gt;is_final&lt;/code&gt; — получится мусор: фрагменты по три слова, без контекста, ужасное cache-поведение. Если ждать &lt;code&gt;speech_final&lt;/code&gt; — переводы чистые, но пользователь ждёт 2+ секунды до первого звука.&lt;/p&gt;

&lt;p&gt;Компромисс — &lt;code&gt;TranscriptBuffer&lt;/code&gt;, который флашится по тому, что наступит первым:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;speaker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endTime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Сменился спикер — сначала флашим предыдущего&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;speaker&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;speaker&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&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;accumulated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;segments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SENTENCE_BOUNDARY_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accumulated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;accumulated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                        &lt;span class="c1"&gt;// предложение готово&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;wordCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accumulated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;                                        &lt;span class="c1"&gt;// длинный монолог&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// safety на тишину&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;На стороне перевода: вместо того, чтобы ждать, пока LLM закончит фразу, ответ Groq стримится и пере-чанкуется по предложениям (regex &lt;code&gt;[.!?]&lt;/code&gt; после 20+ символов). Каждое предложение уходит в TTS &lt;em&gt;сразу&lt;/em&gt;, не в конце стрима. Это пайплайнит TTS-синтез поверх LLM-генерации — первое слышимое слово приходит заметно быстрее, чем при наивном «перевели → синтезировали».&lt;/p&gt;

&lt;p&gt;Субтитры обновляются на промежуточных транскриптах (пользователь видит их живьём), TTS играет только на стабильных предложениях. Получается лучшее из двух.&lt;/p&gt;

&lt;h2&gt;
  
  
  Стек
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deepgram Nova-3&lt;/strong&gt; — единственный streaming STT, который у меня нормально диаризовал спикеров в шумных созвонах.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Groq + Llama 3.3 70B&lt;/strong&gt; — самая быстрая LLM, которую могу позволить в BYOK-продукте. Дешевле GPT-4o-mini за токен и в несколько раз выше throughput. OpenAI оставлен как fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge TTS&lt;/strong&gt; (&lt;code&gt;msedge-tts&lt;/code&gt;, MIT) — Microsoft Neural voices, бесплатно, звучат хорошо. OpenAI &lt;code&gt;tts-1&lt;/code&gt; — опциональный upgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WXT&lt;/strong&gt; — лучший фреймворк для WebExtension, что я использовал. Manifest V3, Vite, TypeScript, content-script worlds, всё работает из коробки.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Electron 41&lt;/strong&gt; с ESM tray-приложением — на удивление чисто. &lt;code&gt;utilityProcess&lt;/code&gt; крутит WS-сервер в child-процессе, он может крашнуться без последствий для tray.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Astro 6&lt;/strong&gt; для маркетинг-сайта — статика, быстро, file-based i18n.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Что отверг:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI Whisper API&lt;/strong&gt; — стандартный &lt;code&gt;/v1/audio/transcriptions&lt;/code&gt; принимает готовый файл, не стрим. (Новый Realtime API с &lt;code&gt;gpt-4o-transcribe&lt;/code&gt; существует, но это уже другой зверь, и появился он слишком поздно для этого дизайна.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ElevenLabs&lt;/strong&gt; — красивые голоса, но цена за минуту делает BYOK неподъёмным для ежедневных пользователей.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Традиционный VPS-бэкенд&lt;/strong&gt; — собственно, против него и весь дизайн.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Три вещи, которые я бы сказал себе в прошлом
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;BYOK + локальный сервер — это рабочий паттерн.&lt;/strong&gt; Cost-of-revenue схлопывается до $0. Privacy превращается из маркетингового тезиса в свойство архитектуры. Цена — трение в онбординге, и большинство pro-пользователей охотно меняют его на контроль.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manifest V3 сложнее, чем признаёт документация.&lt;/strong&gt; В service worker нельзя держать состояние. Для всего stateful (аудио, persistent WebSocket) нужен offscreen document. &lt;code&gt;chrome.storage&lt;/code&gt; в нём &lt;em&gt;недоступен&lt;/em&gt; — приходится message-pass'ить с retry. Закладывайте время.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Electron не настолько плох, как пишут в Twitter.&lt;/strong&gt; Tray-only app занимает ~200 МБ на диске и ~80 МБ RAM в idle. electron-builder подписывает под Mac/Windows. GitHub Actions собирает macOS DMG на &lt;code&gt;macos-latest&lt;/code&gt; бесплатно.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Если хочется попробовать: &lt;strong&gt;скачайте MeetVoice для Windows или macOS на &lt;a href="https://meetvoice.app" rel="noopener noreferrer"&gt;meetvoice.app&lt;/a&gt;&lt;/strong&gt; и &lt;strong&gt;поставьте &lt;a href="https://chromewebstore.google.com/detail/meetvoice/ahmdecnledffbnblohemfonphfapcifg" rel="noopener noreferrer"&gt;расширение из Chrome Web Store&lt;/a&gt;&lt;/strong&gt;. Понадобится ключ Deepgram (бесплатного тира хватит на тест), остальное — опционально.&lt;/p&gt;

&lt;p&gt;С удовольствием отвечу на вопросы в комментариях — особенно про audio graph и MV3 offscreen-doc dance, на них ушло больше всего боли.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
