Один 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.