Back to list
Why my real-time translator for Google Meet works on your laptop, not on my server
Почему мой real-time переводчик для Google Meet работает у вас на ноутбуке, а не на моём сервере
Translated: 2026/4/20 12:00:34
Japanese Translation
$0/月インフラコスト。ユーザーのデバイスからオーディオは外部に流出しません。Google Meetでのリアルタイム双方向音声翻訳。そのすべてを統合したアーキテクチャ上のトリックです。
私は Chrome エkstension を作成し、Google Meet でリアルタイム双方向に音声翻訳を行なっています。日本語で話せば相手が英語を聞き、相手ドイツ語で答えばあなたが日本語を聞き取れます。字幕、TTS(テキスト読み上げ)、すべて期待通りです。
次に、これをどう実装するかという課題がありました。
「会議における AI」の多くは同じパターンで動作します:クライアントはマイクストリーミングをバックエンドに送り、バックエンドは STT(音声認識)、LLM(大規模言語モデル)、TTS(音声合成)を支払います。ユーザーはサブスクリプションを支払う(ただし、費用がカバーされ且つマージンが残ることを願います)。このモデルにおいて私は二つの点に不満を抱いました:
会話の 1 分間は私の AWS ブイレー上の 1 行分のコストで、重度の利用者はアップサイドを提供してくれません。
会話の 1 分間はまた、私のサーバーを通過する相手がマイクであるというプライバシー上の物語も抱えなければなりません。
そこで MeetVoice は別の方法で実装されます。
アーキテクチャは通常では一緒に現れない 2 つの要素です:
Bring-your-own-key (BYOK): ユーザーは Deepgram + Groq +(オプション:OpenAI)自身のキーを接続します。デフォルトの無料 Edge TTS はマイクロソフトが支払う(ドキュメント化されていないエンドポイントですが、すでに数年安定して動作しています)。
「サーバー」はユーザーのラップトップ上に回転します。私は Windows と macOS 用の小さな Electron Tray アプリを提供し、それがローカルの WebSocket サーバーを 127.0.0.1:18900 で起動します。エクステンションはこのサーバーに接続されます。
私はそれを何を得ますか:
インフラコストがゼロ。EC2、Cloud Run、cold starts すべてなし。再発生的な料金はマーケティングサイトの 1 つの Cloudflare Worker に留まります。
オーディオはデバイスを出ません(STT プロバイダーについて、ユーザー自身が選択し支払い実行する鍵を持つプロバイダーを除く)。
スケーリングは無料です。新しいユーザー = 新しいラップトップ = 新しいサーバー。
何を支払いますか:
オンボーディングが困難です。「アプリをダウンロードする」は「エクステンションをインストールしてログインする」よりも多くの摩擦を生みます。
サーバーのフィックスを自動でデプロイできません。electron-updater roundtrip が必要(R2 + electron-updater はこれら両方に対応していますが、余分な動く部品となります)。
ライセンス化はデスクトップ側で生きている(LemonSqueezy + 最小限の Cloudflare Worker でエンタイトルメントを確認)。
インディー SaaS におけるこのトレードオフはノーブラナーです。これで技術的に興味深い部分が始まります。
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
1 つの通話の中で、2 つの pipeline が並行して動作します:
Incoming (peerLang → userLang): tab audio → 翻訳された音声はあなたのスピーカーに再生され、字幕も付加。
Outgoing (userLang → peerLang): あなたのマイク → 翻訳された音声が Meet 内であなたが話しているかのようにはなされ、字幕も相手が付加。
両方の pipeline は同じ WebSocket を共有します。方向性は prefix byte(0x00 incoming, 0x01 outgoing)を介して多重化されます。安価で、複雑なスキームなし、動作します。
定常状態でのエンド・トゥ・エンド レイテンシーは約 1.5–2 秒。大部分は Deepgram からで、is_final と確実にマークするまで待っています。
次に、最も多くの時間をかけた 2 つの出来事。
これが最も興味深い部分です。
Meet がマイクを要求する時、それは navigator.mediaDevices.getUserMedia({ audio: true }) を呼び出し、MediaStream を受け取り、そのストリームは他の参加者に送られます。
私は単に... 他のストリームを与えます。
// content script, world: "MAIN", runAt: "document_start"
const origGetUserMedia = navigator.mediaDevices.getUserMedia
.bind(navigator.mediaDevices);
navigator.mediaDevices.getUserMedia = async (constraints) => {
if (!constraints?.audio) return origGetUserMedia(constraints);
// 本当のマイクを取得しますが、Meet はそれを直接提供しません
const realStream = await origGetUserMedia(constraints);
// Meet が参照する制御されたストリームを作成します
Original Content
$0/месяц на инфраструктуру. Аудио, которое не покидает устройство пользователя. Real-time двусторонний голосовой перевод в Google Meet. Вот архитектурный трюк, который позволил совместить всё это сразу.
Я сделал Chrome-расширение, которое в реальном времени двусторонне переводит голос в Google Meet. Вы говорите по-русски — собеседник слышит английский. Он отвечает по-немецки — вы слышите русский. Субтитры, TTS, всё как полагается.
Потом надо было решить, как это шипить.
Большинство «AI в созвонах» работают по одной схеме: клиент стримит микрофон на бэкенд, бэкенд платит за STT + LLM + TTS, пользователь платит подписку, которая (надеемся) покрывает счёт и оставляет маржу. Меня в этой модели не устраивали две вещи:
Каждая минута разговора — это строчка в моём AWS-биле, и у меня нет апсайда от тяжёлых пользователей.
Каждая минута разговора — это ещё и чужой микрофон, проходящий через мой сервер. Privacy story, которую мне не хотелось поддерживать.
Поэтому MeetVoice шипится иначе.
Архитектура — это две штуки, которые обычно вместе не встречаются:
Bring-your-own-key (BYOK): пользователь подключает свои ключи Deepgram + Groq + (опционально) OpenAI. По дефолту бесплатный Edge TTS — за этот платит Microsoft (через недокументированный endpoint, но он стабильно работает уже несколько лет).
«Сервер» крутится у пользователя на ноутбуке. Я поставляю маленькое Electron tray-приложение под Windows и macOS, которое поднимает локальный WebSocket-сервер на 127.0.0.1:18900. Расширение коннектится к нему.
Что я с этого получаю:
Ноль инфраструктурных затрат. Никаких EC2, Cloud Run, cold starts. Recurring счёт — один Cloudflare Worker для маркетинг-сайта.
Аудио не покидает устройство (с поправкой на STT-провайдера, которого пользователь сам выбрал и оплачивает своим ключом).
Скейлинг бесплатный. Новый пользователь = новый ноутбук = новый сервер.
Чем плачу:
Онбординг сложнее. «Скачать приложение» — больше трения, чем «поставить расширение и залогиниться».
Не могу автоматически выкатить серверный фикс — нужен electron-updater roundtrip (R2 + electron-updater всё это умеют, но это лишняя движущаяся часть).
Лицензирование живёт на десктопной стороне (LemonSqueezy + крошечный Cloudflare Worker для проверки entitlement).
Для indie SaaS этот трейдоф — no-brainer. Теперь технически интересная часть.
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
В одном звонке параллельно работают два таких pipeline'а:
Incoming (peerLang → userLang): tab audio → переведённый голос играется в ваших колонках, плюс субтитры.
Outgoing (userLang → peerLang): ваш микрофон → переведённый голос, который произносится в Meet как будто это вы говорите, плюс субтитры для собеседника.
Оба pipeline'а делят один WebSocket. Направление мультиплексируется через prefix-байт (0x00 incoming, 0x01 outgoing). Дёшево, без схем, работает.
End-to-end latency в установившемся режиме — около 1.5–2 секунд. Большая часть — Deepgram, который ждёт, чтобы уверенно пометить чанк is_final.
Дальше — две вещи, на которые ушло больше всего времени.
Это самая интересная часть.
Когда Meet запрашивает микрофон, он вызывает navigator.mediaDevices.getUserMedia({ audio: true }). Получает MediaStream, и именно этот стрим уходит другим участникам.
Я просто... отдаю ему другой стрим.
// content script, world: "MAIN", runAt: "document_start"
const origGetUserMedia = navigator.mediaDevices.getUserMedia
.bind(navigator.mediaDevices);
navigator.mediaDevices.getUserMedia = async (constraints) => {
if (!constraints?.audio) return origGetUserMedia(constraints);
// Получаем настоящий микрофон, но Meet его напрямую не даём
const realStream = await origGetUserMedia(constraints);
// Строим управляемый стрим, на который Meet будет держать ссылку
const controlStream = new MediaStream();
for (const t of realStream.getAudioTracks()) controlStream.addTrack(t);
for (const t of realStream.getVideoTracks()) controlStream.addTrack(t);
// На следующем user gesture подменим аудио-треки на наш миксованный стрим
document.addEventListener("click", trySetupGraph, true);
return controlStream;
};
Сам микс собирается на Web Audio:
audioCtx = new AudioContext({ sampleRate: 48000 });
destination = audioCtx.createMediaStreamDestination();
micSource = audioCtx.createMediaStreamSource(realStream);
micGainNode = audioCtx.createGain(); // микрофон, с ducking
ttsGainNode = audioCtx.createGain(); // injected TTS, с boost
micSource.connect(micGainNode).connect(destination);
ttsGainNode.connect(destination);
// Подменяем треки на стриме, на который Meet уже держит ссылку
for (const t of controlStream.getAudioTracks()) controlStream.removeTrack(t);
for (const t of destination.stream.getAudioTracks()) controlStream.addTrack(t);
Когда сервер присылает переведённый TTS:
Декодируем чанки в AudioBuffer.
Опускаем micGainNode до 20% — чтобы вы не говорили поверх собственного перевода.
Играем буфер через ttsGainNode → destination.
На source.onended восстанавливаем gain микрофона.
С точки зрения других участников — они слышат, как вы говорите на их языке. Их клиент Meet не подозревает, что в стриме синтезированный голос — это просто байты в том же MediaStream, который Meet и запросил.
Несколько граблей, на которые я наступил:
AudioContext'у нужен user gesture, чтобы стартовать в running. Поэтому getUserMedia сначала возвращает настоящий стрим, а подмена происходит на следующем click/keydown. Без этого Chrome создаёт контекст в state suspended — silent failure: ничего не падает, но аудио не идёт.
Override-скрипт работает в MAIN world, а значит никаких chrome.* API. Вся коммуникация с расширением — через window.postMessage с targetOrigin: "https://meet.google.com" (никогда "*" — defense-in-depth).
Последовательная очередь TTS — обязательна. Два сегмента, пришедшие подряд и декодированные параллельно, перекроются и зазвучат как два пьяных синтезатора. Достаточно одного флага isPlaying + playNext() в source.onended.
Монотонный счётчик activePlaybackId, инкрементящийся на каждый новый playback. Stale onended от предыдущего сегмента проверяет его и выходит. Без этого быстро пришедший новый сегмент получал восстановленный gain микрофона от старого callback'а — и стартовал на полной громкости.
Deepgram отдаёт два типа финализированных транскриптов: is_final (этот чанк зафиксирован) и speech_final (спикер только что взял паузу). Если переводить каждый is_final — получится мусор: фрагменты по три слова, без контекста, ужасное cache-поведение. Если ждать speech_final — переводы чистые, но пользователь ждёт 2+ секунды до первого звука.
Компромисс — TranscriptBuffer, который флашится по тому, что наступит первым:
push(text, speaker, endTime) {
// Сменился спикер — сначала флашим предыдущего
if (speaker !== this.speaker && this.segments.length) this.flush();
this.segments.push(text);
const accumulated = this.segments.join(" ");
if (SENTENCE_BOUNDARY_RE.test(accumulated) && accumulated.length > 20) {
this.flush(); // предложение готово
} else if (wordCount(accumulated) >= 30) {
this.flush(); // длинный монолог
} else if (!this.timer) {
this.timer = setTimeout(() => this.flush(), 4000); // safety на тишину
}
}
На стороне перевода: вместо того, чтобы ждать, пока LLM закончит фразу, ответ Groq стримится и пере-чанкуется по предложениям (regex [.!?] после 20+ символов). Каждое предложение уходит в TTS сразу, не в конце стрима. Это пайплайнит TTS-синтез поверх LLM-генерации — первое слышимое слово приходит заметно быстрее, чем при наивном «перевели → синтезировали».
Субтитры обновляются на промежуточных транскриптах (пользователь видит их живьём), TTS играет только на стабильных предложениях. Получается лучшее из двух.
Deepgram Nova-3 — единственный streaming STT, который у меня нормально диаризовал спикеров в шумных созвонах.
Groq + Llama 3.3 70B — самая быстрая LLM, которую могу позволить в BYOK-продукте. Дешевле GPT-4o-mini за токен и в несколько раз выше throughput. OpenAI оставлен как fallback.
Edge TTS (msedge-tts, MIT) — Microsoft Neural voices, бесплатно, звучат хорошо. OpenAI tts-1 — опциональный upgrade.
WXT — лучший фреймворк для WebExtension, что я использовал. Manifest V3, Vite, TypeScript, content-script worlds, всё работает из коробки.
Electron 41 с ESM tray-приложением — на удивление чисто. utilityProcess крутит WS-сервер в child-процессе, он может крашнуться без последствий для tray.
Astro 6 для маркетинг-сайта — статика, быстро, file-based i18n.
Что отверг:
OpenAI Whisper API — стандартный /v1/audio/transcriptions принимает готовый файл, не стрим. (Новый Realtime API с gpt-4o-transcribe существует, но это уже другой зверь, и появился он слишком поздно для этого дизайна.)
ElevenLabs — красивые голоса, но цена за минуту делает BYOK неподъёмным для ежедневных пользователей.
Традиционный VPS-бэкенд — собственно, против него и весь дизайн.
BYOK + локальный сервер — это рабочий паттерн. Cost-of-revenue схлопывается до $0. Privacy превращается из маркетингового тезиса в свойство архитектуры. Цена — трение в онбординге, и большинство pro-пользователей охотно меняют его на контроль.
Manifest V3 сложнее, чем признаёт документация. В service worker нельзя держать состояние. Для всего stateful (аудио, persistent WebSocket) нужен offscreen document. chrome.storage в нём недоступен — приходится message-pass'ить с retry. Закладывайте время.
Electron не настолько плох, как пишут в Twitter. Tray-only app занимает ~200 МБ на диске и ~80 МБ RAM в idle. electron-builder подписывает под Mac/Windows. GitHub Actions собирает macOS DMG на macos-latest бесплатно.
Если хочется попробовать: скачайте MeetVoice для Windows или macOS на meetvoice.app и поставьте расширение из Chrome Web Store. Понадобится ключ Deepgram (бесплатного тира хватит на тест), остальное — опционально.
С удовольствием отвечу на вопросы в комментариях — особенно про audio graph и MV3 offscreen-doc dance, на них ушло больше всего боли.