Back to list
dev_to 2026年4月20日

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
google-meetreal-time-translationelectronprivacy-firstlocal-ai

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, на них ушло больше всего боли.