По требованиям Netflix к сведению диалог стоит на −27 LKFS, а пики допускаются до −2 dBTP, и дома на колонках ноутбука я весь фильм крутил громкость. Я написал расширение для Chrome, Edge, Яндекс Браузера, Opera и Firefox, которое каждые 100 мс измеряет громкость видео и подстраивает усиление. На тестовом фрагменте разница между диалогом и экшеном уменьшилась с 16,1 до 3,8 дБ.
Громкость фильмов дома
Фильмы я смотрю на колонках ноутбука и на внешних колонках, и в каждом фильме слышу один и тот же разброс: диалог тихий, взрывы и музыка громкие. Я ставлю громкость по диалогу и на первом же взрыве тянусь к регулятору. Громкие сцены могут мешать соседям, но больше всего разброс раздражает меня самого.
Звукорежиссёр сводит фильм для кинозала. Колонки там откалиброваны по SMPTE RP 200: розовый шум на −20 dBFS даёт 85 дБ SPL от каждого основного канала. Всё, что громче, вплоть до 0 dBFS, остаётся запасом для взрывов и музыки, это 20 дБ над опорным уровнем.
Если звук 5.1 сводится в стерео по ITU-R BS.775 или по правилам из спецификации Web Audio, центральный канал, в котором обычно записан диалог, входит в левый и правый с коэффициентом √0,5, то есть на 3 дБ тише. Левый и правый каналы с музыкой и эффектами входят без ослабления.
Звукорежиссёры сводят по этим стандартам, и выравнивать громкость под колонки ноутбука остаётся зрителю. Я написал для этого расширение.
До него я ставил Equalizer APO. Настраивать его неудобно, и он обрабатывает весь звук Windows. Мне нужна была обработка только видео в браузере, с выключателем для отдельного сайта.
Ещё мне нужно было усиление выше 100%. Ролик на YouTube, который я открыл в Edge 155, расширение измерило на −18 LUFS. Работать всё это должно в плеерах на разных сайтах, включая встроенные плееры с другого домена.

Цепочка обработки
Расширение ждёт событие play у элемента <video> или <audio> и подключает его через createMediaElementSource к цепочке Web Audio. Скрипт запускается в каждом фрейме страницы (all_frames, match_about_blank), и в каждом фрейме у цепочки свой AudioContext:
Два фильтра режима «Чётче речь»: минус 4 дБ ниже 160 Гц и плюс 4 дБ около 2,8 кГц.
Узел
GainNode, которым управляет автоматическая регулировка усиления.Компрессор
DynamicsCompressorNodeдля коротких скачков, которые регулировка не успевает поймать.Усиление из ползунка, от 100% до 600%.
Лимитер на втором
DynamicsCompressorNodeс порогом −2 дБ иWaveShaperNode, который мягко ограничивает сигнал выше 0,8 от полной шкалы.
AudioWorklet я не использовал. Модуль для него загружается отдельным скриптом, и на сайтах со строгой политикой CSP, например на YouTube, браузер может его заблокировать. Встроенные узлы Web Audio от CSP не зависят.
Некоторые элементы расширение не трогает. У элемента с srcObject идёт поток WebRTC, это звонки в Google Meet и Discord. Если страница уже подключила элемент к своему графу Web Audio, второй вызов createMediaElementSource для того же элемента заканчивается исключением, и такой элемент тоже остаётся без обработки.
Регулировка усиления
Громкость я измеряю по ITU-R BS.1770. Сигнал проходит K-фильтр из двух BiquadFilterNode: полка +4 дБ на 1681 Гц и срез ниже 38 Гц. После фильтра стоит AnalyserNode с окном 8192 отсчёта. Каждые 100 мс расширение считает средний квадрат сигнала и усредняет последние четыре замера, то есть громкость за 400 мс, как momentary loudness в EBU R128.
AnalyserNode сводит стерео в моно усреднением каналов. При одинаковых каналах средний квадрат получается на 3 дБ ниже суммы каналов, которую берёт BS.1770, и я прибавляю к результату 3,01 дБ.
const level = this.momentary; // LUFS за последние 400 мс const desired = clamp(p.target - level, -p.maxCut, p.maxBoost); // далеко от нужного усиления (диалог сразу после взрыва) возвращаемся быстрее const tc = desired < this.gainDb ? p.attack : p.release * (desired - this.gainDb > 8 ? 0.35 : 1); this.gainDb += (desired - this.gainDb) * (1 - Math.exp(-dt / tc)); this.agc.gain.setTargetAtTime(dbToGain(this.gainDb), now, 0.04);
Цель регулировки −20 LUFS. Если громкость ниже −50 LUFS, усиление остаётся прежним, чтобы в паузах не поднимались тишина и шум. Режимы отличаются диапазоном и скоростью:
Режим |
Подъём тихого, до |
Снижение громкого, до |
Время снижения |
Время возврата |
Компрессор |
|---|---|---|---|---|---|
Мягко |
+6 дБ |
−6 дБ |
0,5 с |
3 с |
порог −16 дБ, 2:1 |
Авто |
+12 дБ |
−12 дБ |
0,3 с |
2 с |
порог −22 дБ, 3:1 |
Ночь |
+20 дБ |
−20 дБ |
0,2 с |
1,5 с |
порог −28 дБ, 6:1 |
В разделе «Тонкая настройка» эти параметры можно поменять ползунками: целевую громкость, подъём и снижение, время возврата, порог и степень сжатия компрессора, потолок лимитера.

В первой версии регулировку вёл трёхсекундный замер, как short-term loudness в EBU R128. На тестовом фрагменте после 12 секунд экшена усиление поднималось с −10 до +10 дБ около десяти секунд, и первые фразы диалога после взрыва звучали тише остальных. Я перевёл регулировку на замер за 400 мс, и в первые две секунды диалога после экшена громкость стала на 5,8 дБ ниже остального диалога. Потом я добавил ускоренный возврат: если до нужного усиления больше 8 дБ, постоянная времени умножается на 0,35. Провал сократился до 3,6 дБ.
Компенсирующее усиление в DynamicsCompressorNode
DynamicsCompressorNode сам добавляет компенсирующее усиление, это прописано в спецификации Web Audio. Я заметил его на тестовом стенде: тихий синус на −50 дБ проходил через компрессоры громче, чем входил.
Узел |
Добавочное усиление на тихом сигнале |
|---|---|
Компрессор режима «Мягко» |
+3,6 дБ |
Компрессор режима «Авто» |
+6,9 дБ |
Компрессор режима «Ночь» |
+11,8 дБ |
Лимитер с порогом −2 дБ |
+1,1 дБ |
В компрессорах режимов я его оставил: режим «Ночь» звучит громче «Мягкого», и для ночного просмотра это подходит. В лимитере оно мешало. При потолке −6 дБ пики на выходе доходили до −1,3 дБ. По формуле из спецификации прибавка равна (1 / curve(0 дБ))^0.6, и при нулевом колене кривая в нуле равна порог × (1 − 1/ratio). Я поставил после лимитера GainNode, который вычитает эту прибавку, и пики при потолке −6 дБ опустились до −4,7 дБ.
Политика автозапуска звука
AudioContext, созданный без действия пользователя, браузер оставляет в состоянии suspended. Если подключить видео к такому контексту, звук пропадёт совсем. Расширение вызывает resume() и ждёт 300 мс. Если контекст не запустился, элемент ждёт первого pointerdown или keydown на странице и подключается после него.
Видео с другого сервера
Многие плееры берут файл видео с сервера раздачи на другом домене. Если ответ сервера не разрешает чтение заголовками CORS, MediaElementAudioSourceNode выдаёт тишину. Видео при этом продолжает играть, но без звука.
В версии 1.0.0 расширение делало пробный запрос к файлу с mode: 'cors'. Если сервер разрешал чтение, расширение перезагружало элемент с атрибутом crossorigin с той же секунды и подключало к обработке. Если нет, видео оставалось без обработки, а окно расширения писало об этом. На встроенном плеере VK Видео расширение так и написало: «Видео грузится с другого сервера, который запрещает обработку». Сервер VK с видеофайлами отдаёт заголовок Access-Control-Allow-Origin: https://vkvideo.ru, и встроенный плеер на другом домене это разрешение не получает.
В версии 1.1.0 расширение добавляет заголовки само, правилом declarativeNetRequest на время сессии браузера:
await chrome.declarativeNetRequest.updateSessionRules({ removeRuleIds: [id], addRules: [{ id, priority: 1, action: { type: 'modifyHeaders', responseHeaders: [ { header: 'access-control-allow-origin', operation: 'set', value: pageOrigin }, { header: 'access-control-allow-credentials', operation: 'set', value: 'true' }, ], }, condition: { requestDomains: [mediaHost], initiatorDomains: [pageHost], resourceTypes: ['media'] }, }], });
Правило действует только для пары «страница и сервер с видео» и только для запросов типа media, которые делают элементы <video> и <audio>. Номер правила я считаю хешем FNV-1a от этой пары, поэтому повторный запрос для той же пары заменяет правило и не плодит новые. После этого расширение ставит элементу crossorigin="use-credentials", чтобы cookie уходили на сервер так же, как до перезагрузки, и возвращает воспроизведение на ту же секунду и с той же скоростью. Если за 8 секунд метаданные не загрузились или элемент выдал ошибку, расширение снимает атрибут и перезагружает видео в прежнем виде.
Встроенные плееры VK Видео на версии 1.1.0 я проверил сам в Firefox, звук обрабатывается.
Плееры в Shadow DOM
Плееры, собранные из веб-компонентов, держат <video> внутри Shadow DOM. Событие play не всплывает и не выходит за границу теневого дерева (composed: false), и обработчик на document его не видит. Расширение раз в 2 секунды обходит страницу, заходит в теневые деревья и вешает на каждое свой обработчик play.
Закрытое теневое дерево из скрипта страницы недоступно, но расширению браузеры его отдают, и делают это по-разному. В Chromium это функция chrome.dom.openOrClosedShadowRoot(el). В Firefox это свойство el.openOrClosedShadowRoot. Сначала я вызывал его как функцию в обоих браузерах, и тест в Firefox упал с TypeError: el.openOrClosedShadowRoot is not a function.
Выключатель для сайта во фреймах
Переключатель «На этом сайте» хранит адрес вкладки, а видео часто играет во фрейме плеера с другого домена. Скрипт во фрейме узнаёт адрес вкладки так: сначала читает window.top.location, а если фрейм с другого домена и браузер это запрещает, берёт последний элемент location.ancestorOrigins. Если и этого свойства нет, скрипт берёт document.referrer.
DRM и захват вкладки
Видео с защитой DRM браузер отдаёт в Web Audio тишиной, и элементы с mediaKeys расширение не подключает. Для них в браузерах на Chromium есть захват звука всей вкладки: chrome.tabCapture.getMediaStreamId в фоновом скрипте и getUserMedia в offscreen-документе, где звук проходит ту же цепочку. Пока захват включён, вкладка звучит только через расширение, а скрипты на странице пропускают свои элементы без обработки, чтобы звук не прошёл цепочку дважды.
Без действия пользователя tabCapture не работает. Захват включается, когда пользователь открывает окно расширения на вкладке с таким видео, кнопкой в окне или сочетанием Alt+Shift+V: нажатие сочетания браузер тоже считает обращением к расширению.
В Chromium фоновый скрипт работает как service worker, который браузер останавливает между событиями, поэтому список вкладок с захватом лежит в storage.session.
Я проверил захват на примере dash.js с шифрованием ClearKey. Это тот же механизм EME, что у Netflix, но с открытым ключом. Через захват в обработку пришёл звук громкостью −12,4 LUFS, и регулировка опустила его на 7,6 дБ.
Одна сборка на два браузера
Манифест в репозитории написан для Chromium. Скрипт сборки делает из него версию для Firefox: фоновый скрипт объявлен через background.scripts и работает как event page, из разрешений уходят tabCapture и offscreen, из манифеста уходит сочетание клавиш, из архива offscreen-документ. Для AMO в манифесте есть browser_specific_settings.gecko с data_collection_permissions: none, без этого поля новые расширения в каталог не принимают.
Версию для Firefox проверяет web-ext lint с предупреждениями в роли ошибок. Из-за него обращение к chrome.dom в коде записано как chrome['dom']: в Firefox такого API нет, и на прямой вызов линтер выдавал предупреждение. По тегу v* GitHub Actions собирает оба архива и прикладывает их к релизу. Если в секретах репозитория лежат ключи API addons.mozilla.org, тот же процесс отправляет новую версию в каталог через web-ext sign --channel listed. Эти ключи я пока не добавил. Версию 1.2.1 Mozilla одобрила 13 октября 2026 года.
Как я проверял
Тестовый стенд прогоняет через цепочку в OfflineAudioContext синтетический фрагмент: 12 секунд тихого диалога, 12 секунд экшена со взрывами и снова 12 секунд диалога. Регулировку я вызываю через suspend() каждые 100 мс, как таймер в расширении. Уровни в таблице посчитаны как медиана среднеквадратичного уровня по окнам 400 мс, в дБ от полной шкалы, при громкости 100%.

Режим |
Диалог |
Экшен |
Разница |
Пик |
|---|---|---|---|---|
Без расширения |
−40,9 |
−24,8 |
16,1 дБ |
+0,7 дБ |
Выравнивание выключено |
−41,1 |
−24,8 |
16,2 дБ |
−1,4 дБ |
Мягко |
−33,9 |
−23,5 |
10,4 дБ |
−1,2 дБ |
Авто |
−25,2 |
−21,4 |
3,8 дБ |
−1,2 дБ |
Ночь |
−22,8 |
−19,8 |
3,0 дБ |
−2,7 дБ |
Сквозные тесты ставят расширение в Edge и Firefox. Edge 155 я запускал через Playwright, Firefox 157 через WebDriver BiDi: команда webExtension.install ставит расширение временно, без подписи. Тесты проверяют подъём диалога и снижение экшена, подключение видео с чужого сервера с CORS и без, плеер в закрытом Shadow DOM и захват вкладки. Для видео с чужого сервера тест сравнивает уровень на входе обработки с порогом −50 LUFS, чтобы отличить звук от тишины.
На YouTube видео идёт через MSE с адресом blob:, и расширение подключается к нему без правил CORS. На ролике, который я открыл в Edge 155, регулировка опустила громкость на 4 дБ. Сам я смотрю через расширение в Firefox RuTube, YouTube и VK Видео, и на всех трёх сайтах оно подключается к плееру.
Ограничения
Обработка Web Audio добавляет задержку вывода звука, при захвате вкладки задержка больше. Я её не замерял, на тестовых видео рассинхрон губ и звука я на слух не заметил. Firefox для Android поддерживается манифестом, на телефоне я расширение не проверял.
Код и установка
Расширение открытое, лицензия MIT. Большую часть кода написал Claude от Anthropic, я ставил задачи и проверял результат в своих браузерах.
Расширение не собирает данные и само не делает сетевых запросов. Настройки лежат в storage.sync. Окно переведено на 18 языков.
Исходный код и архивы для установки: https://github.com/olerast67/steady-volume
Firefox: https://addons.mozilla.org/firefox/addon/steady-volume/
Chrome, Edge, Яндекс Браузер, Opera: пока из архива в режиме разработчика, инструкция в README.
YouTube я проверял в Edge 155 и Firefox, RuTube и VK Видео вручную в Firefox, встроенный плеер VK Видео в Firefox на версии расширения 1.1.0, тестовую страницу в Edge 155 и Firefox 157 на Windows 10. Если на каком-то сайте расширение не подключилось, напишите в комментариях или в issue адрес страницы, браузер с версией и строку из нижней части окна расширения во время воспроизведения.