Один AudioContext для всех звуков

03 Июн 2026

В HopperGame звук перестал быть разовым эффектом, который можно включить и забыть.

Один звук должен идти циклически, пока персонаж находится в определённом состоянии. Другой при повторном play() можно наложить поверх предыдущего. Третий нужно прервать или вообще не запускать повторно, пока он ещё звучит.

Здесь же снова проявилась особенность iOS. После возврата в Safari одного AudioContext.state оказалось недостаточно: контекст может сообщать running, хотя реального звука уже нет.

С моделью, где каждый ClickTone сам владеет своим AudioContext, разбирать всё это по экземплярам стало неудобно.

Общую часть вынести в движок

До 2.0.0 у каждого звука был свой контекст и свой кеш декодированных файлов. Для простого play() это нормально, но разблокировка и восстановление после возврата на страницу относятся уже не к конкретному эффекту. Это состояние всей аудиосистемы страницы.

Поэтому появился SharedAudioEngine:

export class SharedAudioEngine {
  #ctx: AudioContext | null = null;
  #decodeCache = new Map<string, Promise<AudioBuffer>>();

  // ...
}

export const engine = new SharedAudioEngine();

Экземпляры ClickTone теперь обращаются к нему за общими вещами:

engine.prime();

const buffer = await engine.decode(url);
const ctx = engine.context();

Кеш тоже стал общим. Если два звука используют один URL, загружать и декодировать один файл дважды смысла нет.

При этом настройки конкретного эффекта остались в ClickTone: volume, mute, throttle, pitch и собственный GainNode. Движок занимается только тем, что действительно должно жить на уровне страницы.

Одна разблокировка вместо обработчиков на каждом звуке

Движок устанавливает общий набор обработчиков пользовательских жестов и изменений состояния страницы:

prime(): void {
  if (this.#primed || !hasDOM) return;

  this.#primed = true;
  this.#installGestureUnlock();
  this.#installVisibilityHandler();
}

Первый подходящий жест вызывает unlock(). Если контекст приостановлен, движок вызывает resume(). После успешной разблокировки дополнительно запускается почти бесшумный односэмпловый буфер, чтобы помочь Web Audio окончательно проснуться.

Главное здесь не сам трюк с буфером, а то, что вопрос разблокировки решается один раз для всей страницы. Каждому звуку больше не нужны свои обработчики касаний и собственное представление о том, готов ли Web Audio.

Если AudioContext только выглядит рабочим

После 2.0.0 нашёлся неприятный кейс Safari/iOS: после ухода из браузера и возврата обратно AudioContext может остаться в state === 'running', но перестать реально воспроизводить звук.

Поэтому в 2.1.0 проверяю ещё и currentTime. После возврата на страницу движок запоминает значение, ждёт 200ms и смотрит, продвинулось ли аудиовремя:

const start = ctx.currentTime;

await new Promise((resolve) =>
  setTimeout(resolve, SharedAudioEngine.#ZOMBIE_PROBE_MS),
);

if (ctx.state === 'running' && ctx.currentTime <= start) {
  this.#needsHardRecovery = true;
}

Если состояние говорит running, а currentTime стоит на месте, обычного resume() уже недостаточно.

Сразу пересоздавать контекст на visibilitychange я не стал. Полное восстановление откладывается до следующего пользовательского действия:

if (this.#needsHardRecovery) this.#recreate();

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

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

new AudioContext() сам по себе проблему не заканчивает. GainNode и активные AudioBufferSourceNode были созданы в старом контексте и вместе с ним становятся бесполезны.

Поэтому ClickTone подписывается на пересоздание контекста:

this.#recreateUnsubscribe = engine.onRecreate(() =>
  this.#restartActiveLoops(),
);

GainNode пересоздаётся, если относится уже не к текущему контексту. Активные циклические воспроизведения собираются по URL и запускаются заново через новый движок.

Именно на этом месте стало понятно, что циклическое воспроизведение нельзя считать тем же коротким звуком, только с source.loop = true. Если звук живёт дольше одного вызова, библиотеке приходится помнить о нём.

Хранить активное воспроизведение

В 2.1.0 экземпляр начал отслеживать созданные источники звука:

type ActivePlayback = {
  source: AudioBufferSourceNode;
  url: string;
  loop: boolean;
  stopped: boolean;
};

#activePlaybacks = new Set<ActivePlayback>();

При запуске источник добавляется в набор:

source.buffer = buffer;
source.loop = loop;

const playback: ActivePlayback = {
  source,
  url,
  loop,
  stopped: false,
};

this.#activePlaybacks.add(playback);
source.start(0);

После этого у stop() появляется конкретный объект, который можно завершить:

stop(): void {
  this.#stopActivePlaybacks(true);
}

Для HopperGame это как раз тот API, которого не хватало:

const flying = new ClickTone({
  src: './flying.mp3',
  loop: true,
  preload: true,
});

await flying.play();

// состояние закончилось
flying.stop();

Длительность эффекта здесь задаёт уже состояние игры, а не длина аудиофайла.

Что означает повторный play()

Для короткого звука клика наложение обычно нормально. У игрового эффекта повторный вызов может означать совсем другое, поэтому в 2.1.0 это стало явной настройкой:

type ReplayBehavior =
  | 'overlap'
  | 'interrupt'
  | 'ignore-if-playing'
  | 'restart';

overlap оставляет прежнее поведение и создаёт новое воспроизведение. interrupt останавливает активный и запускает новый. ignore-if-playing ничего не делает, пока звук уже идёт. restart обязательно начинает воспроизведение заново.

Для restart обычный throttle пришлось обходить:

if (
  !skipThrottle &&
  replay !== 'restart' &&
  now - this.#lastPlay < this.#throttle
) {
  return;
}

Иначе явная команда перезапустить звук могла бы потеряться из-за ограничения, которое решает совсем другую задачу.

Поведение при повторном play() можно задать на экземпляре:

const terminal = new ClickTone({
  src: './terminal.mp3',
  replay: 'interrupt',
});

await terminal.play();

Что возвращает play() для loop

Для обычного звука play() возвращает Promise, который завершается после source.onended. У циклического воспроизведения естественного конца может не быть вообще.

Поэтому циклическое воспроизведение считается запущенным сразу после source.start(0), а ожидание onended остаётся только для обычного эффекта:

source.start(0);
this.#emit('play');

if (!loop) await ended;

Заодно stop получил отдельное событие. Естественное завершение даёт end, явное прерывание — stop.

В результате короткий UI-звук по-прежнему запускается обычным play(). Но если эффект связан с состоянием игры, его теперь можно зациклить, остановить и заранее определить, что должен означать повторный вызов. А восстановление Web Audio больше не размазано по экземплярам и живёт рядом с общим AudioContext.

Материалы