AudioContext и управляемое воспроизведение
После декодирования у нас есть буфер, но сам по себе он не звучит. Теперь построим управляемый проигрыватель: кнопка начинает прослушивание выбранного участка, другая останавливает его, а интерфейс показывает позицию. Основная задача — согласовать одноразовые звуковые источники с повторными действиями пользователя и асинхронным разрешением браузера.
Класс Player находится в audio.mjs архива серии. Во время подготовки звук не запускался. Мы разбираем исходники и ожидаемые переходы состояния. Паузы с продолжением в первой версии нет: повторное нажатие запускает фрагмент с его начала. Это поведение важно не путать с полноценным медиаплеером.
Буфер и источник имеют разный срок жизни
Один AudioBuffer можно использовать многократно. AudioBufferSourceNode создаётся на каждое воспроизведение заново. Согласно описанию start() в MDN, один источник нельзя запускать повторно. После окончания или остановки создаём следующий узел с тем же буфером.
const source = context.createBufferSource();
source.buffer = buffer;
source.connect(envelope);
envelope.connect(master);
Это фрагмент графа внутри play(). envelope отвечает за начало и окончание фрагмента, master — за пользовательский уровень. Их подробное устройство рассмотрим далее. Сейчас важно, что источник не подключается напрямую к выходу параллельно обходному пути: иначе звук мог бы воспроизводиться дважды или миновать регулировку.
Сведения об активном воспроизведении хранятся в this.active: источник, огибающая, время старта, диапазон и частота. Это временное состояние проигрывателя. Оно не изменяет исходный буфер и не записывается в будущий WAV. Когда активного объекта нет, позиция возвращается как null.
Перед запуском проверяем фрагмент
play(buffer, options) начинает с checkRegion(). Границы должны быть целыми и допустимыми для конкретного буфера. После этого останавливаем прежний источник. Нажатия не должны накапливать несколько одновременно звучащих копий одной записи, если такой режим не является явной задачей приложения.
Затем вызываем context.resume() и ждём. Браузер может разрешить работу по пользовательскому действию, а может оставить контекст недоступным. После ожидания проверяем context.state === 'running'. Ошибка выводится интерфейсом; не показываем «воспроизведение», если фактический запуск ещё не подготовлен.
Во время ожидания пользователь способен нажать «Стоп» или начать другой запуск. Поэтому у класса есть счётчик intent. Остановка увеличивает его, а play() сохраняет номер перед await. Если после разрешения номера уже не совпадают, прежнее намерение не продолжает создание источника.
this.stop();
const intent = this.intent;
await this.context.resume();
if (intent !== this.intent) return;
Это защита состояния, а не отмена Promise. Без неё быстрая последовательность «Слушать», «Стоп» могла бы закончиться неожиданным звуком после завершения resume(). Такие гонки часто незаметны на быстром компьютере, но договор должен учитывать их независимо от обычного времени ответа.
Время запуска и смещение
Для старта используем звуковые часы context.currentTime. Добавляем небольшой запас 0.015 секунды и передаём его как when. Смещение внутри буфера получается из startFrame / sampleRate, длительность — из разницы границ. Эти три числа относятся к разным понятиям: когда начать, откуда читать и сколько читать.
source.start(
when,
region.startFrame / buffer.sampleRate,
(region.endFrame - region.startFrame) / buffer.sampleRate
);
Фрагмент запуска расположен после настройки графа и огибающей. when не является номером кадра файла. Это время жизни контекста, которое может быть намного больше длительности записи. Временной запас нужен для планирования ближайшего запуска, но не является измеренной задержкой вывода на устройство.
При участке 0.25–0.75 секунды источник читает половину секунды начиная с четверти. Он не обязан сначала проиграть всё до 0.25. Исходный буфер остаётся целым; ограничение чтения задаётся параметрами запуска. Фактический экспорт этого участка будет выполнен отдельным офлайн-графом.
Остановка и естественное окончание
stop() сначала увеличивает счётчик, забирает активный объект и обнуляет ссылку. Если источник существовал, вызывает его остановку и отключает узлы. Обработчик onended также освобождает соединения. При этом он меняет общее состояние только если завершившийся объект всё ещё является текущим.
Такой порядок защищает новый запуск от события старого источника. Представьте, что пользователь остановил A и начал B. Позднее A сообщает ended. Без сравнения объектов обработчик мог бы очистить this.active и спрятать позицию B. Идентичность активного объекта здесь важнее того, что событие вообще произошло.
Вызов остановки может встретиться с уже завершившимся источником; поэтому локально допускается безопасное завершение без распространения такой ошибки. Но мы не используем пустой catch для декодирования или неизвестного состояния всего приложения. Узкое подавление относится только к освобождению уже ненужного источника.
Позиция для интерфейса
Позицию рассчитываем как начало фрагмента плюс прошедшее время currentTime - when. До запланированного старта отрицательное время заменяем нулём, после конца ограничиваем позицией окончания. Не двигаем курсор прибавлением фиксированного шага на каждом кадре экрана: браузер может пропускать визуальные кадры.
const elapsed = Math.max(0, context.currentTime - active.when);
const position = Math.min(
active.region.endFrame / active.sampleRate,
active.region.startFrame / active.sampleRate + elapsed
);
Это поясняющая запись формулы геттера. Интерфейс читает её через requestAnimationFrame, но часы остаются звуковыми. Положение не означает точный момент, уже услышанный человеком: аппаратная задержка вывода существует отдельно. Для учебной шкалы мы показываем позицию обработки без компенсации этой задержки.
При приостановке AudioContext его звуковые часы не продолжают обычное движение. Визуальная позиция, рассчитанная от этих часов, тоже остановится. Это лучше отдельного таймера, который мог бы показывать завершение, пока контекст ещё не проиграл участок. Однако первая версия не создаёт полноценный интерфейс возобновления после любой системной приостановки; такую ситуацию нужно проверить и оформить отдельно.
Кнопка «Стоп» не означает, что пользовательская запись удаляется. Она освобождает текущий источник, но оставляет буфер и параметры доступными для следующего запуска. Такое различие стоит сохранять в названиях действий. Если добавить кнопку очистки файла, она должна дополнительно обнулить буфер, диапазон и визуальное представление, а не просто вызвать метод проигрывателя.
Проверка переходов
При самостоятельном запуске нажмите «Слушать» несколько раз: ожидается один активный источник и старт участка заново. Затем быстро нажмите «Стоп», особенно пока контекст возобновляется. Старое намерение не должно начать звук позднее. После естественного окончания управление должно вернуться к остановленному состоянию и убрать индикатор позиции.
Эти сценарии пока не выполнены. Мы получили проигрыватель с ясным жизненным циклом, а не просто вызов start(). В следующем уроке рассмотрим звуковой граф и плавное изменение уровня, чтобы регулятор не создавал резкий скачок коэффициента во время прослушивания.