Почему мой real-time переводчик для Google Meet работает у вас на ноутбуке, а не на моём сервере

$0/месяц на инфраструктуру. Аудио, которое не покидает устройство пользователя. Real-time двусторонний голосовой перевод в Google Meet. Вот архитектурный трюк, который позволил совместить всё это сразу.

Я сделал Chrome-расширение, которое в реальном в…


This content originally appeared on DEV Community and was authored by Oleksii “Alex” Herasymchuk

$0/месяц на инфраструктуру. Аудио, которое не покидает устройство пользователя. Real-time двусторонний голосовой перевод в Google Meet. Вот архитектурный трюк, который позволил совместить всё это сразу.

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

Потом надо было решить, как это шипить.

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

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

Поэтому MeetVoice шипится иначе.

Поворот: BYOK + десктопное приложение

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

  1. Bring-your-own-key (BYOK): пользователь подключает свои ключи Deepgram + Groq + (опционально) OpenAI. По дефолту бесплатный Edge TTS — за этот платит Microsoft (через недокументированный endpoint, но он стабильно работает уже несколько лет).
  2. «Сервер» крутится у пользователя на ноутбуке. Я поставляю маленькое 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. Теперь технически интересная часть.

Pipeline

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.

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

Хак №1: перехват getUserMedia для инжекции TTS в Meet

Это самая интересная часть.

Когда 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:

  1. Декодируем чанки в AudioBuffer.
  2. Опускаем micGainNode до 20% — чтобы вы не говорили поверх собственного перевода.
  3. Играем буфер через ttsGainNode → destination.
  4. На 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'а — и стартовал на полной громкости.

Хак №2: streaming-перевод без рваного TTS

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-бэкенд — собственно, против него и весь дизайн.

Три вещи, которые я бы сказал себе в прошлом

  1. BYOK + локальный сервер — это рабочий паттерн. Cost-of-revenue схлопывается до $0. Privacy превращается из маркетингового тезиса в свойство архитектуры. Цена — трение в онбординге, и большинство pro-пользователей охотно меняют его на контроль.
  2. Manifest V3 сложнее, чем признаёт документация. В service worker нельзя держать состояние. Для всего stateful (аудио, persistent WebSocket) нужен offscreen document. chrome.storage в нём недоступен — приходится message-pass'ить с retry. Закладывайте время.
  3. 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, на них ушло больше всего боли.


This content originally appeared on DEV Community and was authored by Oleksii “Alex” Herasymchuk


Print Share Comment Cite Upload Translate Updates
APA

Oleksii “Alex” Herasymchuk | Sciencx (2026-04-20T11:36:26+00:00) Почему мой real-time переводчик для Google Meet работает у вас на ноутбуке, а не на моём сервере. Retrieved from https://www.scien.cx/2026/04/20/%d0%bf%d0%be%d1%87%d0%b5%d0%bc%d1%83-%d0%bc%d0%be%d0%b9-real-time-%d0%bf%d0%b5%d1%80%d0%b5%d0%b2%d0%be%d0%b4%d1%87%d0%b8%d0%ba-%d0%b4%d0%bb%d1%8f-google-meet-%d1%80%d0%b0%d0%b1%d0%be%d1%82%d0%b0%d0%b5/

MLA
" » Почему мой real-time переводчик для Google Meet работает у вас на ноутбуке, а не на моём сервере." Oleksii “Alex” Herasymchuk | Sciencx - Monday April 20, 2026, https://www.scien.cx/2026/04/20/%d0%bf%d0%be%d1%87%d0%b5%d0%bc%d1%83-%d0%bc%d0%be%d0%b9-real-time-%d0%bf%d0%b5%d1%80%d0%b5%d0%b2%d0%be%d0%b4%d1%87%d0%b8%d0%ba-%d0%b4%d0%bb%d1%8f-google-meet-%d1%80%d0%b0%d0%b1%d0%be%d1%82%d0%b0%d0%b5/
HARVARD
Oleksii “Alex” Herasymchuk | Sciencx Monday April 20, 2026 » Почему мой real-time переводчик для Google Meet работает у вас на ноутбуке, а не на моём сервере., viewed ,<https://www.scien.cx/2026/04/20/%d0%bf%d0%be%d1%87%d0%b5%d0%bc%d1%83-%d0%bc%d0%be%d0%b9-real-time-%d0%bf%d0%b5%d1%80%d0%b5%d0%b2%d0%be%d0%b4%d1%87%d0%b8%d0%ba-%d0%b4%d0%bb%d1%8f-google-meet-%d1%80%d0%b0%d0%b1%d0%be%d1%82%d0%b0%d0%b5/>
VANCOUVER
Oleksii “Alex” Herasymchuk | Sciencx - » Почему мой real-time переводчик для Google Meet работает у вас на ноутбуке, а не на моём сервере. [Internet]. [Accessed ]. Available from: https://www.scien.cx/2026/04/20/%d0%bf%d0%be%d1%87%d0%b5%d0%bc%d1%83-%d0%bc%d0%be%d0%b9-real-time-%d0%bf%d0%b5%d1%80%d0%b5%d0%b2%d0%be%d0%b4%d1%87%d0%b8%d0%ba-%d0%b4%d0%bb%d1%8f-google-meet-%d1%80%d0%b0%d0%b1%d0%be%d1%82%d0%b0%d0%b5/
CHICAGO
" » Почему мой real-time переводчик для Google Meet работает у вас на ноутбуке, а не на моём сервере." Oleksii “Alex” Herasymchuk | Sciencx - Accessed . https://www.scien.cx/2026/04/20/%d0%bf%d0%be%d1%87%d0%b5%d0%bc%d1%83-%d0%bc%d0%be%d0%b9-real-time-%d0%bf%d0%b5%d1%80%d0%b5%d0%b2%d0%be%d0%b4%d1%87%d0%b8%d0%ba-%d0%b4%d0%bb%d1%8f-google-meet-%d1%80%d0%b0%d0%b1%d0%be%d1%82%d0%b0%d0%b5/
IEEE
" » Почему мой real-time переводчик для Google Meet работает у вас на ноутбуке, а не на моём сервере." Oleksii “Alex” Herasymchuk | Sciencx [Online]. Available: https://www.scien.cx/2026/04/20/%d0%bf%d0%be%d1%87%d0%b5%d0%bc%d1%83-%d0%bc%d0%be%d0%b9-real-time-%d0%bf%d0%b5%d1%80%d0%b5%d0%b2%d0%be%d0%b4%d1%87%d0%b8%d0%ba-%d0%b4%d0%bb%d1%8f-google-meet-%d1%80%d0%b0%d0%b1%d0%be%d1%82%d0%b0%d0%b5/. [Accessed: ]
rf:citation
» Почему мой real-time переводчик для Google Meet работает у вас на ноутбуке, а не на моём сервере | Oleksii “Alex” Herasymchuk | Sciencx | https://www.scien.cx/2026/04/20/%d0%bf%d0%be%d1%87%d0%b5%d0%bc%d1%83-%d0%bc%d0%be%d0%b9-real-time-%d0%bf%d0%b5%d1%80%d0%b5%d0%b2%d0%be%d0%b4%d1%87%d0%b8%d0%ba-%d0%b4%d0%bb%d1%8f-google-meet-%d1%80%d0%b0%d0%b1%d0%be%d1%82%d0%b0%d0%b5/ |

Please log in to upload a file.




There are no updates yet.
Click the Upload button above to add an update.

You must be logged in to translate posts. Please log in or register.