В современных веб-приложениях для потокового видео всё чаще требуется не просто воспроизводить контент, но и анализировать аудиодорожку в реальном времени. Например, строить индикаторы уровня громкости (VU/PPM метры), визуализировать спектрограммы или детектировать тишину. В этой статье разберём, как корректно объединить hls.js и Web Audio API для анализа аудио из HLS-потока в браузере, избежав типичных подводных камней.

Проблематика

При работе с Web Audio API и HTML-медиа-элементами разработчики часто сталкиваются с тремя основными проблемами:

1. Ошибка повторного создания MediaElementAudioSourceNode

Самая распространённая ошибка выглядит так:​

Cannot create multiple MediaElementAudioSourceNode from the same HTMLMediaElement

Это происходит потому, что браузер позволяет создать только один MediaElementAudioSourceNode для каждого <video> или <audio> элемента. Попытка создать второй приведёт к ошибке, при этом оригинальный элемент может потерять звук.

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

Современные браузеры блокируют автоматический запуск аудио до первого пользовательского взаимодействия. AudioContext создаётся в состоянии suspended, и разработчику необходимо явно вызвать resume() после жеста пользователя (клик, тап).

3. Синхронизация с загрузкой HLS

HLS-манифест загружается асинхронно через hls.js. Если создать MediaElementAudioSourceNode до того, как манифест полностью загружен и подключён к видеоэлементу, можно получить пустой звук или ошибки инициализации.

Архитектура Web Audio API для анализа HLS-потока
Архитектура Web Audio API для анализа HLS-потока

Решение: Singleton-паттерн для управления аудиоконтекстом

Чтобы гарантировать единственность AudioContext и корректную работу с MediaElementAudioSourceNode, используем паттерн Singleton. Это обеспечит:​

  • Единственный экземпляр AudioContext на всё приложение

  • Единственный MediaElementAudioSourceNode для каждого медиаэлемента

  • Централизованное управление состоянием аудиографа

  • Простоту переиспользования в разных компонентах приложения

Базовая реализация

export class HlsAudioService {
  private static instance: HlsAudioService;
  public audioContext: AudioContext;
  public source?: MediaElementAudioSourceNode;
  public splitter?: ChannelSplitterNode;
  public analysers: AnalyserNode[] = [];
  
  private constructor() {
    const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext;
    this.audioContext = new AudioContextClass();
  }

  static getInstance(): HlsAudioService {
    if (!HlsAudioService.instance) {
      HlsAudioService.instance = new HlsAudioService();
    }
    return HlsAudioService.instance;
  }

  async resumeContext(): Promise<void> {
    if (this.audioContext.state === 'suspended') {
      await this.audioContext.resume();
    }
  }
}

Здесь мы создаём приватный конструктор, который инициализирует AudioContext с учётом кросс-браузерной совместимости (используем префикс webkit для старых версий Safari). Статический метод getInstance() гарантирует, что во всём приложении будет только один экземпляр сервиса.​

Интеграция с hls.js

Ключевой момент — правильная последовательность инициализации. Необходимо дождаться события MANIFEST_PARSED от hls.js перед созданием аудионод:

const videoElement = document.querySelector('video') as HTMLVideoElement;
const hls = new Hls();
const audioService = HlsAudioService.getInstance();

hls.on(Hls.Events.MANIFEST_PARSED, () => {
  // Манифест загружен, можно создавать аудиограф
  if (!audioService.source) {
    audioService.source = audioService.audioContext.createMediaElementSource(videoElement);
    setupAudioGraph(audioService);
  }
});

hls.loadSource('https://example.com/stream.m3u8');
hls.attachMedia(videoElement);

// Обработка пользовательского жеста
videoElement.addEventListener('play', async () => {
  await audioService.resumeContext();
});

Такой подход гарантирует, что MediaElementAudioSourceNode создаётся только после того, как hls.js полностью инициализировал поток, и только один раз.​

Построение аудиографа для анализа

После создания source node необходимо построить цепочку обработки. Для стереоанализа типичная архитектура выглядит так:

function setupAudioGraph(service: HlsAudioService) {
  const { audioContext, source } = service;
  
  // Разделяем стереосигнал на левый и правый каналы
  service.splitter = audioContext.createChannelSplitter(2);
  
  // Создаём анализаторы для каждого канала
  const analyserLeft = audioContext.createAnalyser();
  const analyserRight = audioContext.createAnalyser();
  
  analyserLeft.fftSize = 2048; // Размер FFT для частотного анализа
  analyserRight.fftSize = 2048;
  
  // Строим цепочку: source → splitter → analysers → destination
  source!.connect(service.splitter);
  service.splitter.connect(analyserLeft, 0); // Левый канал
  service.splitter.connect(analyserRight, 1); // Правый канал
  
  // Подключаем к выходу для воспроизведения
  analyserLeft.connect(audioContext.destination);
  analyserRight.connect(audioContext.destination);
  
  service.analysers = [analyserLeft, analyserRight];
}

ChannelSplitterNode разделяет стереосигнал на моноканалы. Каждый AnalyserNode предоставляет данные о частотах и амплитуде для своего канала в реальном времени. Параметр fftSize определяет детализацию частотного анализа — чем больше значение, тем выше разрешение, но больше нагрузка на процессор.

Обработка типичных edge cases

CORS и кросс-доменные потоки

Если HLS-поток находится на другом домене, MediaElementAudioSourceNode будет выдавать нулевые значения из соображений безопасности. Решение — добавить атрибут crossOrigin к видеоэлементу и убедиться, что сервер отдаёт заголовок Access-Control-Allow-Origin:

videoElement.crossOrigin = 'anonymous';

Без корректной настройки CORS анализ аудио невозможен, хотя воспроизведение будет работать.

Состояние suspended на мобильных устройствах

На мобильных платформах (особенно iOS) AudioContext может переходить в состояние suspended для экономии батареи. Необходимо проверять состояние перед каждым использованием:​

async function ensureAudioContextRunning(service: HlsAudioService) {
  if (service.audioContext.state === 'suspended') {
    await service.audioContext.resume();
    console.log('AudioContext resumed');
  }
}

Рекомендуется вызывать эту функцию в обработчиках событий play и canplay.

Переключение между потоками

При смене источника HLS (например, переключение качества или канала) не нужно пересоздавать MediaElementAudioSourceNode. Достаточно вызвать hls.loadSource() с новым URL — существующий аудиограф продолжит работать:

function switchStream(newUrl: string) {
  hls.loadSource(newUrl);
  // source node остаётся прежним, пересоздание не требуется
}

Попытка пересоздать source приведёт к ошибке, так как он уже привязан к элементу.

Оптимизация производительности

Для снижения нагрузки на CPU при визуализации можно использовать несколько техник:

Уменьшение частоты обновления: вместо обновления на каждом кадре (60 FPS) можно ограничить частоту до 30 FPS с помощью requestAnimationFrame и счётчика кадров.​

Настройка размера FFT: для простых индикаторов уровня достаточно fftSize = 256, для детальной спектрограммы — 2048 или 4096. Меньшее значение снижает задержку и нагрузку.​

Web Workers: можно вынести обработку данных из AnalyserNode в воркер, чтобы не блокировать основной поток. Однако сами аудионоды должны оставаться в основном потоке.

Примеры применения

С помощью полученных данных можно реализовать различные виды визуализации и анализа:

  • PPM/VU метры — для отображения текущего уровня сигнала с разным временем интеграции​

  • Спектрограммы — визуализация частотного спектра в реальном времени с помощью getByteFrequencyData()

  • Детектор тишины — анализ амплитуды для определения пауз в аудио

  • Эквалайзеры — разделение частот на полосы для управления громкостью​

Все эти задачи используют данные из AnalyserNode, который предоставляет массив значений от 0 до 255 для каждой частотной полосы.

Совместимость браузеров

Браузер

Web Audio API

HLS (нативная)

HLS.js (MSE)

Chrome (Desktop)

✅ v14+

❌ Нет

✅ Да

Firefox (Desktop)

✅ v25+

❌ Нет

✅ Да

Safari (Desktop)

✅ v6.1+

✅ Да

⚠️ Не требуется

Edge

✅ Полная

❌ Нет

✅ Да

Chrome Mobile

✅ Полная

✅ Да (Android)

✅ Да

Safari iOS

✅ v6+

✅ Да

❌ Нет (MSE)

Firefox Android

✅ Полная

❌ Нет

✅ Да

Важно: Safari на iOS не поддерживает Media Source Extensions, поэтому hls.js там не работает. К счастью, iOS имеет нативную поддержку HLS, и можно напрямую использовать video.src. Web Audio API при этом работает корректно.

Справочник распространённых ошибок

Ошибка

Причина

Решение

Cannot create multiple MediaElementAudioSourceNode

Попытка создать MediaElementAudioSourceNode дважды для одного элемента

Использовать Singleton-паттерн для хранения единственной ссылки

AudioContext was not allowed to start

AudioContext создан до пользовательского взаимодействия

Вызвать audioContext.resume() после user gesture (click, touch)

MediaElementAudioSourceNode outputs zeroes

CORS-ограничения для кросс-доменного аудио

Добавить crossOrigin="anonymous" к video элементу

HLS manifest not loading

HLS-манифест загружается до инициализации AudioContext

Дождаться события MANIFEST_PARSED перед созданием source node

Заключение

Анализ аудио из HLS-потоков в браузере — решаемая задача при правильном подходе. Ключевые моменты:

  1. Singleton для AudioContext предотвращает ошибки повторного создания нод и упрощает управление состоянием​

  2. Ожидание события MANIFEST_PARSED от hls.js гарантирует корректную инициализацию​

  3. Обработка autoplay policy через resume() обеспечивает работу на всех платформах​

  4. Настройка CORS необходима для кросс-доменных потоков​

Полный рабочий пример реализации доступен в репозитории github.com/ABurov30/AudioContext, где можно изучить готовую интеграцию всех описанных техник.

Такой подход позволяет создавать надёжные веб-приложения для потокового видео с продвинутым аудиоанализом, не требуя от пользователей установки дополнительных плагинов или расширений.

Комментарии (0)