Как AI помог браузеру услышать музыку: Web Audio API + React в семейной викторине

Когда я написал «а можно ли сделать реальный микшер, реагирующий на музыку?» - честно не понимал, насколько это нетривиально. До этого проекта я ни разу не работал с Web Audio API. Мы с Claude Code прошли полный путь за 26 промптов: от fake CSS-анимаций к логарифмическому анализу частот, от лагов в каждом кадре к плавной 60fps визуализации.


Предыстория: полоски, которые не слушали музыку

Проект называется ugaday-melodiu - семейная музыкальная викторина, в которую мы играли на новогодние праздники. Работает по локальной сети: телевизор показывает игру, телефон - пульт управления. Атмосфера важна, поэтому когда играет музыка, на экране должны пульсировать красивые полоски.

Изначально это был компонент MusicBars с CSS-классом animate-music-bars. Обычная keyframe-анимация. Полоски дёргались в ритм - но сами по себе, независимо от музыки. Как танцовщики в наушниках, которых не слышит зал.

Работало. Выглядело сносно. Пока не прозвучал тот самый вопрос.


Первая попытка: AI сломал не то

Ответ AI на запрос про «реальный микшер» был прямолинейным - подключить AnalyserNode из Web Audio API. Но реализация пошла не туда: код встроили прямо в KaraokeOverlay, компонент отображения слов песни. Это был кратчайший путь к аудиоэлементу. Примерно на 12-м промпте сессии.

Визуализация появилась. Зато пропало слово «Кто» из начала первой строки.

«Пропало отображение слов песни», - написал я. AI-ответ был честным: «Понял - не надо было трогать KaraokeOverlay. Верну карaoке к рабочей скролл-версии, а реактивный анализатор подключу только к MusicBars».

Из этой ошибки родился отдельный хук useAudioAnalyser - правильная архитектура с единственной ответственностью. Хук получает ссылку на <audio>, создаёт AudioContext, подключает AnalyserNode, возвращает данные частот. KaraokeOverlay не знает о его существовании.


Первая рабочая версия: работает, но медленно

Первая версия хука была очевидной: пять полосок, useState для хранения данных, цикл requestAnimationFrame читает частоты из AnalyserNode и вызывает setEnergy на каждом кадре.

Проблема стала видна сразу. Каждый вызов setEnergy - это рендер GameScreen. Весь экран с таймером, счётом, вопросом, плеером, кнопками перерисовывался 60 раз в секунду. Chrome DevTools показывал 60 рендеров компонента в секунду вместо нуля - и fps проседал до 40-45 на слабых устройствах.

«Стало немного лучше, но как будто не хватает полосок и дёргание слишком резкое», - написал я после первой итерации. Мягкое описание. Реальная проблема была архитектурной.

Решение - убрать React из цикла анимации полностью. Никакого setState. Вместо этого - прямая манипуляция DOM: el.style.height на каждом кадре. Полоски стали обычными div-элементами с ref, высота которых меняется напрямую в requestAnimationFrame. React не знает, что что-то происходит. Рендеры компонента упали до нуля на кадр, fps вернулся к стабильным 60.

// Вместо setEnergy(value) в цикле
barRefs.current[b].style.height = `${height}%`;

Снизили smoothingTimeConstant с 0.7 до 0.3 - полоски стали реагировать быстрее.


Логарифмическая шкала: почему 5 полосок выглядят плохо

После исправления производительности вернулся вопрос визуала.

getByteFrequencyData() возвращает 128 бинов, каждый примерно 172 Гц при fftSize = 256. Бин 1 - это около 172 Гц, бин 127 - около 21 кГц. Если взять 5 равных диапазонов из этого массива, большая часть придётся на высокие частоты, которые ухо практически не различает в музыке.

Человеческое ухо работает логарифмически. Разница между 100 и 200 Гц воспринимается так же, как между 1000 и 2000 Гц - одна октава в обоих случаях. Поэтому профессиональные эквалайзеры всегда используют логарифмическую шкалу. Это не оптимизация - это физиология.

В коде это реализовано через makeBandRanges с Math.log. Берём диапазон от бина 1 до бина 25 (примерно 172 Гц - 4300 Гц, музыкальный диапазон) и делим его логарифмически на 12 полосок. Первые получают больше низких частот, последние - высоких.

Параметры итоговой версии:

  • fftSize = 256 (128 частотных бинов, низкая латентность)
  • smoothingTimeConstant = 0.5 (баланс реактивности и плавности)
  • 12 полосок вместо 5

Добавили асимметричное сглаживание: быстрый attack (коэффициент 0.4 когда уровень растёт), медленный decay (0.85 когда падает). Стандартная техника из аудиооборудования - пики появляются мгновенно, спадают плавно.

if (raw > prev) {
  smoothed[b] = prev + (raw - prev) * 0.4;  // fast attack
} else {
  smoothed[b] = prev * 0.85;  // slow decay
}

Нюанс, который я не предвидел: connectedElementRef предотвращает повторное подключение. createMediaElementSource бросает ошибку если вызвать его дважды для одного элемента. При перемонтировании компонента без этой защиты всё падало.


Финальный штрих: «выглядит дёшево»

Двенадцать полосок, логарифмическая шкала, плавная анимация. Казалось бы, готово.

«Сделай полоски шире, а то они слишком тонкие, выглядит дёшево», - написал я. Самый простой фидбек за всю сессию и самая короткая правка: ширина полоски 14px, зазор 5px, одна строка CSS. Но разница была заметна сразу - из набора иголок получился настоящий эквалайзер.

CSS fallback тоже остался: класс animate-music-bars снимается только когда smoothed[b] > 0.01. Если аудио не инициализировалось или браузер заблокировал AudioContext до пользовательского жеста - полоски продолжают анимироваться по CSS. Деградация без потери вида.

Финальная реализация: 12 обычных div-элементов, никакого canvas, никакого SVG. Uint8Array переиспользуется между кадрами - нет аллокации памяти на кадр. AudioContext создаётся лениво, только после первого взаимодействия пользователя, потому что браузеры с Chrome 66 блокируют его создание до user gesture.


Что из этого следует

AudioContext нельзя создавать в useEffect без пользовательского события. Браузер его заблокирует или создаст в состоянии suspended. Инициализируй лениво - при первом клике или начале воспроизведения.

useState в animation loop - это катастрофа. Для визуализации в реальном времени: useRef для данных и прямая манипуляция DOM через el.style.height. React должен знать о визуализации как можно меньше - в идеале вообще ничего.

createMediaElementSource захватывает элемент навсегда. Вызовешь дважды - ошибка. Храни флаг подключения и проверяй перед инициализацией.

Логарифмическая шкала - это правильность, а не украшение. Линейное распределение даёт неинформативную картинку, потому что большинство музыкальной информации сосредоточено в низком диапазоне.

Если ты тоже разбираешься с Web Audio API впервые - начни с useRef + requestAnimationFrame, поставь smoothingTimeConstant = 0.5, добавь логарифмическое распределение. Правильных значений нет. Есть только те, которые хорошо выглядят в твоей игре.