---
title: "Как AI помог браузеру услышать музыку: Web Audio API + React в семейной викторине"
description: "История визуализации музыки в реальном времени через Web Audio API: от CSS-анимаций к логарифмическому анализу частот за 26 промптов с Claude Code"
url: "https://ifonin.ru/blog/web-audio-react-vizualizacija/"
date: 2026-03-26
tags: ["vibe-coding","web-audio-api","react","visualization"]
---

# Как 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.

```javascript
// Вместо 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 когда падает). Стандартная техника из аудиооборудования - пики появляются мгновенно, спадают плавно.

```javascript
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`, добавь логарифмическое распределение. Правильных значений нет. Есть только те, которые хорошо выглядят в твоей игре.
