DEV Community

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

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

$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
Enter fullscreen mode Exit fullscreen mode

В одном звонке параллельно работают два таких 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;
};
Enter fullscreen mode Exit fullscreen mode

Сам микс собирается на 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);
Enter fullscreen mode Exit fullscreen mode

Когда сервер присылает переведённый 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 на тишину
  }
}
Enter fullscreen mode Exit fullscreen mode

На стороне перевода: вместо того, чтобы ждать, пока 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, на них ушло больше всего боли.

Top comments (0)