Когда я написал «а можно ли сделать реальный микшер, реагирующий на музыку?» - честно не понимал, насколько это нетривиально. До этого проекта я ни разу не работал с 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, добавь логарифмическое распределение. Правильных значений нет. Есть только те, которые хорошо выглядят в твоей игре.