Один play() для звука в мини-играх

17 Апр 2025

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

За таким эффектом быстро появляется однообразная обвязка: загрузить файл, декодировать его через Web Audio, создать источник звука, подключить его к выходу и не забыть про приостановленный AudioContext в мобильных браузерах.

Повторять это из проекта в проект не хотелось, поэтому clicktone я сформулировал с узкой задачей: приложение решает, когда должен прозвучать эффект, а библиотека даёт для этого play().

import ClickTone from 'clicktone';

const clickSound = new ClickTone('./sound.mp3');

myButton.addEventListener('click', () => clickSound.play());

До первого релиза API успел побыть более “умным”: ClickTone получал DOM-элемент и сам назначал обработчик через init(). Перед 1.0.0 я это убрал. Причина события библиотеке не нужна. click, клавиатура или внутренняя механика игры — это уже ответственность приложения.

Спрятать Web Audio, а не событие

Внутри первый play() делал обычную цепочку Web Audio:

fetch(url)
  .then((response) => response.arrayBuffer())
  .then((buffer) => this.audioContext.decodeAudioData(buffer))
  .then((audioData) => {
    const source = this.audioContext.createBufferSource();

    source.buffer = audioData;
    source.connect(this.audioContext.destination);
    source.start(0);
  });

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

Для touch-устройств в первой версии был отдельный путь восстановления приостановленного AudioContext:

if (this.audioContext.state === 'suspended' && 'ontouchstart' in window) {
  const unlock = () => {
    this.audioContext.resume().then(() => {
      document.body.removeEventListener('touchstart', unlock);
      document.body.removeEventListener('touchend', unlock);
    });
  };

  document.body.addEventListener('touchstart', unlock, false);
  document.body.addEventListener('touchend', unlock, false);
}

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

Что действительно понадобилось повторно

После нескольких использований библиотеки к play() добавились три вещи, которые снова начали повторяться уже поверх базового воспроизведения: громкость, ограничение слишком частых запусков и кеш декодированного аудио.

К 1.2.0 экземпляр можно настроить так:

const sound = new ClickTone({
  file: './click.mp3',
  volume: 0.7,
  throttle: 100,
  callback: () => console.log('done'),
  debug: true,
});

Для volume в аудиографе появился GainNode:

const source = this.audioContext.createBufferSource();
const gainNode = this.audioContext.createGain();

gainNode.gain.value = this.volume;
source.connect(gainNode);
gainNode.connect(this.audioContext.destination);

throttle проще. Если одно действие приходит слишком часто, новый звук можно не запускать:

const now = Date.now();

if (now - this.lastClickTime >= this.throttle) {
  func();
  this.lastClickTime = now;
}

Для коротких эффектов такой временной границы достаточно. Отдельный планировщик здесь ничего полезного не добавил бы.

Кеш нужен по другой причине. После первого fetch() и decodeAudioData() готовый AudioBuffer сохраняется по URL:

if (this.audioCache[url]) {
  return this.audioCache[url];
}

const response = await fetch(url);
const buffer = await response.arrayBuffer();
const audioData = await this.audioContext.decodeAudioData(buffer);

this.audioCache[url] = audioData;

Следующий play() может сразу использовать декодированный буфер. AudioBufferSourceNode при этом всё равно создаётся заново для каждого запуска.

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

Ранние версии создавали AudioContext прямо в конструкторе:

this.audioContext = new (
  window.AudioContext || window.webkitAudioContext
)();

Получалось, что одного new ClickTone(...) достаточно, чтобы поднять аудиоконтекст, даже если звук в этой сессии вообще не пригодится.

В 1.3.0 создание переехало в путь воспроизведения:

initAudioContext() {
  if (!this.audioContext) {
    this.audioContext = new (
      window.AudioContext || window.webkitAudioContext
    )();

    this.iOSFixAudioContext();
  }
}

Теперь экземпляр можно создать заранее, а Web Audio появляется только при первой реальной попытке что-то проиграть. Если после этого AudioContext окажется приостановлен, остаётся тот же обходной путь через resume().

В 1.8.0 источник стал гибче

До этого file был только строкой с URL. В текущем релизе тип расширился:

type FileSource = string | HTMLSourceElement | { id: string };

Прямой URL никуда не делся:

const sound = new ClickTone({
  file: './click.mp3',
});

Но теперь можно передать уже найденный <source>:

const source = document.querySelector(
  '#click-source',
) as HTMLSourceElement;

const sound = new ClickTone({ file: source });

Или попросить clicktone найти его по id:

const sound = new ClickTone({
  file: { id: 'click-source' },
});

Для { id } библиотека проверяет, что элемент найден, что это HTMLSourceElement и что у него есть src.

При необходимости источник можно заменить только для одного запуска:

sound.play('./alt.wav');

Это не меняет исходную границу библиотеки. Приложение по-прежнему знает, какой звук в какой момент ему нужен. ClickTone забирает себе только повторяющуюся часть вокруг Web Audio.

Материалы