ESPN DeportesReal Madrid protesta: "Pésimo arbitraje define derbi"ESPN'Did that really happen?' How the Red Sox went from 10-17 to the postseasonBusiness AMNieuw veiligheidspact moet verdediging van Groenland onder NAVO brengenVanguardWhatsApp lists Android phones that can no longer use the appOwngoal NigeriaThe Growing Link Between Football Fandom and Online GamingStraits Times SportAustralia bowling attack boosted as Cummins and Starc return v S AfricaRai NewsAfD primo in Pomerania, dramma Cdu fuori dal Parlamentino. Merz: "Cesura ma il voto non è su di me"BBC NewsAirlines criticise air traffic control as second glitch causes more disruptionPopular ScienceFragment of oldest known peace treaty discovered in Turkey20 MinutenWo kämpfen die Kinder der reichen Politiker, Herr Klitschko?ColliderPrime Video's New 'Reacher' Spin-Off Is Officially Abandoning the Show’s FormulaSBS 뉴스"회계부정 의혹 등 5건 전달"…"청와대 입장에선 가십"
The Daily Newsstand · Free, Always
Monday, September 21, 2026

Субтитры с переводом в расширении Chrome: звук вкладки в MV3, один поток вместо двух сервисов

Translate

Я один пишу расширение 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

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.