$0/месяц на инфраструктуру. Аудио, которое не покидает устройство пользователя. Real-time двусторонний голосовой перевод в Google Meet. Вот архитектурный трюк, который позволил совместить всё это сразу.
Я сделал Chrome-расширение, которое в реальном времени двусторонне переводит голос в Google Meet. Вы говорите по-русски — собеседник слышит английский. Он отвечает по-немецки — вы слышите русский. Субтитры, TTS, всё как полагается.
Потом надо было решить, как это шипить.
Большинство «AI в созвонах» работают по одной схеме: клиент стримит микрофон на бэкенд, бэкенд платит за STT + LLM + TTS, пользователь платит подписку, которая (надеемся) покрывает счёт и оставляет маржу. Меня в этой модели не устраивали две вещи:
- Каждая минута разговора — это строчка в моём AWS-биле, и у меня нет апсайда от тяжёлых пользователей.
- Каждая минута разговора — это ещё и чужой микрофон, проходящий через мой сервер. Privacy story, которую мне не хотелось поддерживать.
Поэтому MeetVoice шипится иначе.
Поворот: BYOK + десктопное приложение
Архитектура — это две штуки, которые обычно вместе не встречаются:
- 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. Теперь технически интересная часть.
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:
- Декодируем чанки в
AudioBuffer. - Опускаем
micGainNodeдо 20% — чтобы вы не говорили поверх собственного перевода. - Играем буфер через
ttsGainNode → destination. - На
source.onendedвосстанавливаем gain микрофона.
С точки зрения других участников — они слышат, как вы говорите на их языке. Их клиент Meet не подозревает, что в стриме синтезированный голос — это просто байты в том же MediaStream, который Meet и запросил.
Несколько граблей, на которые я наступил:
-
AudioContext'у нужен user gesture, чтобы стартовать в
running. ПоэтомуgetUserMediaсначала возвращает настоящий стрим, а подмена происходит на следующем click/keydown. Без этого Chrome создаёт контекст в statesuspended— silent failure: ничего не падает, но аудио не идёт. -
Override-скрипт работает в MAIN world, а значит никаких
chrome.*API. Вся коммуникация с расширением — черезwindow.postMessageсtargetOrigin: "https://meet.google.com"(никогда"*"— defense-in-depth). -
Последовательная очередь TTS — обязательна. Два сегмента, пришедшие подряд и декодированные параллельно, перекроются и зазвучат как два пьяных синтезатора. Достаточно одного флага
isPlaying+playNext()вsource.onended. -
Монотонный счётчик
activePlaybackId, инкрементящийся на каждый новый playback. Staleonendedот предыдущего сегмента проверяет его и выходит. Без этого быстро пришедший новый сегмент получал восстановленный 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, бесплатно, звучат хорошо. OpenAItts-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, на них ушло больше всего боли.
Top comments (0)