Я один пишу расширение Chrome, которое показывает субтитры с переводом поверх созвона в Meet, Zoom или Teams, открытых во вкладке браузера. В звонок при этом никто не заходит, звук берётся из вкладки. Ниже три вещи, на которых ушло больше всего времени. Цифры на 17 сентября 2026.

Звук вкладки в Manifest V3

В MV3 фон расширения это service worker, а в нём нет ни Web Audio, ни getUserMedia. Поэтому service worker получает не поток, а только идентификатор, и создаёт offscreen-документ (reasons: ['USER_MEDIA']), невидимую страницу, где эти API есть:

const streamId = await chrome.tabCapture.getMediaStreamId({ targetTabId: tabId });
// в offscreen-документе:
const stream = await navigator.mediaDevices.getUserMedia({
  audio: { mandatory: { chromeMediaSource: 'tab', chromeMediaSourceId: streamId } },
});

Первая ловушка: как только вкладка захвачена, её звук перестаёт идти в колонки, и человек перестаёт слышать собеседника. Лечится возвратом потока на выход:

const ctx = new AudioContext();
ctx.createMediaStreamSource(stream).connect(ctx.destination);

Дальше AudioWorklet сводит каналы в моно и отдаёт Int16 PCM блоками по 4096 сэмплов, около 85 мс при 48 кГц.

Вторая ловушка, ошибка «Cannot capture a tab with an active stream». Она появляется, когда предыдущий захват этой вкладки не отпущен, и не проходит ни от перезагрузки страницы, ни от chrome.runtime.reload(). Поток живёт в offscreen-документе, значит документ надо закрыть. Но и этого мало: Chrome снимает активный поток асинхронно, поэтому состояние надо опрашивать, а не угадывать таймаутом:

// опрос каждые 50 мс, не дольше 500 мс, после закрытия offscreen-документа
const tabs = await chrome.tabCapture.getCapturedTabs().catch(() => []);
const busy = tabs.some((t) => t.tabId === tabId && t.status === 'active');

Третья, организационная: getMediaStreamId требует, чтобы расширение было «вызвано» на этой вкладке (activeTab), и разрешение держится только до навигации. После перезагрузки страницы честный ответ пользователю это «нажмите значок», а не «ошибка». И звук берётся только из вкладки: десктопные Zoom и Teams, как и телефон, так не захватить.

Два сервиса против одного потока

Первая версия распознавала речь потоком Deepgram, а переводила через DeepL. Чтобы перевод не появлялся рывком в конце фразы, в переводчик уходили и промежуточные результаты. DeepL берёт деньги за длину исходного текста, а незакрытая реплика растёт и уходит в перевод заново целиком. Из кода не видно, насколько это дорого, всё зависит от того, как распознавание дробит фразу. Поэтому мерили на проводе: тестовая дорожка шла в настоящий сокет Deepgram, а промежуточные результаты прокручивались через движок расширения с подменённым вызовом перевода. Замер 29 августа: символов в перевод уходило в 2,53 раза больше, чем при переводе одних финальных фраз, и час созвона стоил около $2,55.

Выход, провайдер, который распознаёт и переводит в одном потоке. Я перешёл на Soniox, модель stt-rt-v5. Сервер минтит временный ключ: TTL ограничивает, сколько ключ может открывать потоки, а не сколько живёт уже открытый, поэтому звонок любой длины укладывается в TTL в несколько минут. Конфиг сессии уходит первым сообщением, до первого байта звука:

{
  api_key, model: 'stt-rt-v5',
  audio_format: 'pcm_s16le', sample_rate: 48000, num_channels: 1,
  language_hints: ['en'], enable_language_identification: true,
  enable_endpoint_detection: true,
  translation: { type: 'one_way', target_language: 'ru' },
}

Чего не было в документации:

  1. Конец реплики объявляет сам сервер токеном , но перевод отстаёт от речи на несколько токенов. Закрыть реплику сразу по нельзя, хвост перевода приклеится к следующей фразе, поэтому держим окно 700 мс.

  2. Коды языков идут без регионов: pt-BR и pt-PT становятся pt, zh-Hans и zh-Hant становятся zh. Для пикера с региональными вариантами это регресс.

  3. Ошибки 400, 401, 402 и 403 переподключением не лечатся, повторы дают только почти двадцать секунд пустого экрана. Считаем их фатальными сразу и уходим на запасной путь, медленный конвейер кусками по 3 секунды на ключе другого провайдера.

Сколько стоит час по счёту

Данные из /v1/usage/summary с 30 августа по 17 сентября: stt-rt-v5, $31,23 за 202,6 часа звука, то есть $0,154 за час, из них $12,31 входной звук и $18,92 выходной текст. Против $2,55 на двух сервисах это в 17 раз меньше.

Цена за это не в деньгах, а в задержке. На стенде в августе финальная фраза на Deepgram появлялась через 2 с с небольшим, а 8 сентября на Soniox через 4,3 и 7,6 с: конец фразы теперь решает сервер, а не пауза в звуке.

Как проверять без живых созвонов

Всё интересное происходит в Chrome и на реальной площадке, юнит-тесты ловят тут мало. Поэтому на Linux-машине стоит стенд: три профиля Chrome заходят в один звонок и «говорят» своими голосами (сценарий собеседования на 118 реплик озвучен синтезом заранее), а четвёртый профиль, с расширением, пишет субтитры. Скрипт сверяет их со сценарием: сколько реплик распознано, тем ли именем подписаны, через сколько миллисекунд пришёл финал.

Так нашлась разница задержек между провайдерами и баг учёта: время списывалось с открытия сокета, а не с первой фразы, поэтому во вкладке без звука минуты уходили впустую. Сейчас счётчик ждёт первый финал: 43,7 с открытого сокета на молчащей вкладке, ноль списанных секунд.

Что осталось нерешённым

Задержка финала выше, чем хотелось бы для разговора. И отличить «во вкладке тишина» от «провайдер молчит» пока нечем: счётчик PCM растёт и в тишине.

Расширение лежит в Chrome Web Store: https://chromewebstore.google.com/detail/eaheoieoelghhmebennamldmjmmfppjk

Буду рад замечаниям по захвату в MV3, особенно если кто-то нашёл способ надёжнее, чем опрос getCapturedTabs