Звук в браузерной игре на Web Audio API: шины громкости на GainNode, автозапуск, лимит голосов и децибелы

Коротко

Звук браузерной игры на Web Audio API держится на четырёх решениях: шины громкости на GainNode, запуск AudioContext после первого действия игрока, громкость в децибелах и лимит одновременных голосов. Шины позволяют менять громкость музыки или эффектов одним вызовом. Политика автозапуска держит звук на паузе до клика, касания или нажатия клавиши, поэтому контекст «будят» через resume(). А лимит голосов и кулдаун не дают десяти одинаковым звукам в одном кадре сложиться в скачок до +20 дБ.

Содержание

  1. Шины громкости: граф из GainNode
  2. Автозапуск: почему звук молчит до первого клика
  3. Децибелы и линейная громкость
  4. Лимит голосов и защита от наложения
  5. Практический сценарий: монеты, музыка и фанфара победы
  6. Когда вам это не подойдёт

Шины громкости: граф из GainNode

Шина — это GainNode, через который проходят несколько звуков, поэтому громкость всей группы меняется одним значением. По MDN, GainNode — узел обработки звука с одним входом и одним выходом, а его параметр gain — безразмерный множитель, на который умножается каждый отсчёт сигнала (MDN, GainNode, проверено: ноябрь 2026).

Типовая схема шин 2D-игры
ШинаЧто в неё идётЗацикливаниеЧто с ней делают
masterВсе шины ниже—Общий ползунок громкости и выключение звука
musicТема уровня, тема боссаДаПолзунок «Музыка», приглушение на время фанфары и реплик
sfxПрыжок, монета, удар, взрывНетПолзунок «Эффекты», лимит голосов
uiКлик, открытие паузыНетОбычно вместе с эффектами
// Перевод между децибелами и линейной громкостью (множителем).
function dbToGain(db) { return Math.pow(10, db / 20); }
function gainToDb(g) { return 20 * Math.log10(g); }

// Шины: каждый источник подключают к своей шине, шины — к master, master — к выходу.
function createMixer(ctx) {
  const master = ctx.createGain();
  master.connect(ctx.destination);
  const buses = {};
  for (const name of ["music", "sfx", "ui"]) {
    const bus = ctx.createGain();
    bus.connect(master);
    buses[name] = bus;
  }
  // Плавная смена громкости: за 3 постоянных времени (60 мс) значение проходит около 95% пути.
  function setGain(node, value) {
    node.gain.setTargetAtTime(value, ctx.currentTime, 0.02);
  }
  return {
    master: master,
    buses: buses,
    setBusDb: function (name, db) { setGain(name === "master" ? master : buses[name], dbToGain(db)); },
    mute: function (on) { setGain(master, on ? 0 : 1); },
  };
}

Громкость меняют не присваиванием gain.value, а через setTargetAtTime(): метод планирует плавный переход, и после каждого отрезка длиной timeConstant значение приближается к цели ещё на ≈ 63,2%; для 95% пути MDN советует брать постоянную времени равной трети нужной длительности (MDN, AudioParam: setTargetAtTime(), проверено: ноябрь 2026). Резкий скачок громкости на слух часто даёт щелчок, поэтому плавный переход в 60 мс — наш стартовый выбор.

Автозапуск: почему звук молчит до первого клика

Браузеры не дают странице начать звук, пока пользователь с ней не взаимодействовал, и это касается Web Audio API. MDN пишет, что в Web Audio API звук запускают методом start() у источника, подключённого к AudioContext, и если это происходит вне обработки действия пользователя, применяются правила автозапуска; как правило, звук разрешён, если пользователь уже взаимодействовал с сайтом кликом, касанием или нажатием клавиши (MDN, Autoplay guide for media and Web Audio APIs, страница обновлена 10.09.2026, проверено: ноябрь 2026).

В Chrome политика автозапуска для Web Audio API действует с версии 71. Если AudioContext создан до действия пользователя, он находится в состоянии "suspended", и после действия пользователя нужно вызвать resume() (Chrome for Developers, Autoplay policy in Chrome, проверено: ноябрь 2026). resume() возвращает Promise, который выполняется, когда контекст возобновлён (MDN, AudioContext: resume()).

// Разблокировка звука по первому действию игрока.
function unlockOnFirstGesture(ctx, target, onUnlocked) {
  const events = ["pointerdown", "keydown", "touchend"];
  function unlock() {
    events.forEach(function (e) { target.removeEventListener(e, unlock); });
    const p = ctx.state === "suspended" ? ctx.resume() : Promise.resolve();
    p.then(function () { if (onUnlocked) onUnlocked(); });
  }
  events.forEach(function (e) { target.addEventListener(e, unlock); });
}

// В браузере:
// const ctx = new AudioContext();
// const mixer = createMixer(ctx);
// unlockOnFirstGesture(ctx, window, startMusic);

Практический вывод для игры: экран «Нажмите, чтобы начать» — не только дизайн, но и способ получить первое действие игрока, после которого музыка уровня запустится без ошибок.

Децибелы и линейная громкость

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

дБ = 20 × log10(g)        g = 10^(дБ ÷ 20)

g = 1,00  →   0 дБ   (исходная громкость файла)
g = 0,50  →  −6 дБ
g = 0,25  → −12 дБ
g = 0,10  → −20 дБ
2 одинаковых сигнала в один момент:  20 × log10(2)  ≈ +6 дБ
10 одинаковых сигналов:              20 × log10(10) = +20 дБ

Отсюда частая ошибка ползунков: если ползунок 0…1 напрямую задаёт gain, почти весь слышимый диапазон сжимается в правую часть шкалы, а середина 0,5 — это лишь −6 дБ. Удобнее, чтобы ползунок шёл по децибелам:

// Ползунок 0…1 → громкость: линейно по децибелам от −40 дБ до 0 дБ, в нуле — тишина.
function sliderToGain(s, floorDb) {
  if (s <= 0) return 0;
  return dbToGain((floorDb || -40) * (1 - s));
}

console.log(gainToDb(0.5).toFixed(1), gainToDb(0.25).toFixed(1), gainToDb(0.1).toFixed(1));
// -6.0 -12.0 -20.0
console.log(sliderToGain(1), sliderToGain(0.5), sliderToGain(0.75).toFixed(3), sliderToGain(0));
// 1 0.1 0.316 0

Нижняя граница −40 дБ — наш выбор, а не стандарт. Для общего уровня игры есть индустриальный ориентир: рекомендация ASWG-R001 рабочей группы Sony PlayStation по звуковым стандартам (ASWG, версия 1.2, март 2024) советует нормировать среднюю громкость к −24 LKFS (±2) для домашних платформ и к −18 LKFS (±2) для портативных, а истинный пик — не выше −1 dBTP. Это ориентир для консолей; для браузерной игры он полезен как точка сравнения, а не как требование.

Лимит голосов и защита от наложения

Одинаковый короткий звук нельзя запускать при каждом событии без ограничений: копии складываются, и ряд монет в одном кадре даёт скачок громкости до +20 дБ. Ограничений обычно три: кулдаун между запусками одного звука, лимит копий одного звука и общий лимит голосов с вытеснением наименее важного.

Лимит голосов — старая практика: у звукового блока NES было 5 каналов (два прямоугольных, треугольный, шумовой и канал DPCM-сэмплов), поэтому композиторы заранее решали, какой звук вытесняет какой (Wikipedia, Ricoh 2A03, проверено: ноябрь 2026). В Web Audio API жёсткого лимита нет, но слышимая каша и нагрузка на процессор растут с каждым голосом.

Для каждого запуска нужен новый источник: AudioBufferSourceNode можно запустить только один раз, но такие узлы дешёвы, а сам AudioBuffer можно переиспользовать для многих запусков (MDN, AudioBufferSourceNode, проверено: ноябрь 2026). Событие ended срабатывает, когда источник перестал играть (MDN, AudioScheduledSourceNode: ended event), — по нему голос освобождается.

// Менеджер голосов: кулдаун, лимит копий одного звука, общий лимит с вытеснением по приоритету.
function createVoiceManager(ctx, maxTotal, random) {
  const rnd = random || Math.random;
  const active = [];
  const lastStart = {};

  function remove(v) {
    const i = active.indexOf(v);
    if (i !== -1) active.splice(i, 1);
  }
  function stopVoice(v) {
    remove(v);
    try { v.src.stop(); } catch (e) { /* уже остановлен */ }
  }

  return {
    // rule: { maxCopies, cooldown (с), priority (больше — важнее), pitchJitter (доля) }
    play: function (name, buffer, bus, rule) {
      const now = ctx.currentTime;
      if (lastStart[name] !== undefined && now - lastStart[name] < rule.cooldown) return null;

      const copies = active.filter(function (v) { return v.name === name; });
      if (copies.length >= rule.maxCopies) stopVoice(copies[0]); // самая старая копия

      if (active.length >= maxTotal) {
        let victim = null;
        for (const v of active) {
          if (!victim || v.priority < victim.priority ||
              (v.priority === victim.priority && v.startedAt < victim.startedAt)) victim = v;
        }
        if (victim.priority > rule.priority) return null; // новый звук менее важен
        stopVoice(victim);
      }

      const src = ctx.createBufferSource();
      src.buffer = buffer;
      src.playbackRate.value = 1 + (rnd() * 2 - 1) * rule.pitchJitter;
      src.connect(bus);
      const voice = { name: name, priority: rule.priority, startedAt: now, src: src };
      src.onended = function () { remove(voice); };
      active.push(voice);
      lastStart[name] = now;
      src.start();
      return voice;
    },
    count: function () { return active.length; },
  };
}

Разброс скорости воспроизведения ±5% (pitchJitter: 0.05) делает повторяющиеся звуки менее одинаковыми; это наш стартовый параметр, а не стандарт.

Стартовые правила для типичных звуков
ЗвукКопий одновременноКулдаунПриоритетРазброс скорости
Монета30,06 с1±5%
Выстрел игрока40,05 с2±3%
Взрыв20,1 с3±5%
Урон игроку10,2 с50
Фанфара победы1—90

Все значения в таблице — наши стартовые параметры: их подбирают на слух под длину своих звуков.

Практический сценарий: монеты, музыка и фанфара победы

Задача: музыка уровня стартует после «Нажмите, чтобы начать», ряд из 12 монет, собранных за 12 кадров, не даёт скачка громкости, а фанфара победы приглушает музыку и возвращает её после окончания.

  1. Контекст и шины. Создайте AudioContext, вызовите createMixer(ctx) и createVoiceManager(ctx, 16); общий лимит 16 голосов — наш выбор для 2D-игры.
  2. Разблокировка. Вызовите unlockOnFirstGesture(ctx, window, startMusic); в startMusic создайте источник музыки и подключите его к шине music: короткую петлю — как AudioBufferSourceNode с loop = true, длинный трек — через MediaElementAudioSourceNode (см. последний раздел).
  3. Громкость музыки. mixer.setBusDb("music", -6) — музыка на 6 дБ тише эффектов.
  4. Монеты. На каждую подобранную монету — voices.play("coin", coinBuffer, mixer.buses.sfx, { maxCopies: 3, cooldown: 0.06, priority: 1, pitchJitter: 0.05 }). При 60 кадрах в секунду и кулдауне 0,06 с из 12 монет за 12 кадров прозвучат 3 (на 0-м, 4-м и 8-м кадрах), что мы проверили в Node.js с поддельным контекстом.
  5. Фанфара. При касании финиша — mixer.setBusDb("music", -20), затем voices.play("fanfare", ...) с приоритетом 9; в обработчике ended фанфары — mixer.setBusDb("music", -6).
  6. Вкладка в фоне. По событию visibilitychange при скрытой вкладке вызывайте ctx.suspend(), при возврате — ctx.resume(), чтобы музыка не играла, пока игра стоит.
Что проверить на слух
ПроверкаОжидаемый результат
Открыть страницу и ничего не нажиматьТишина, ошибок в консоли нет
Нажать «начать»Музыка стартует с первого действия
Собрать ряд монетЗвон без скачка громкости, звуки слегка различаются
Дойти до финишаМузыка уходит на задний план, после фанфары возвращается без щелчка
Переключить вкладку и вернутьсяМузыка стоит в фоне и продолжается при возврате

Когда вам это не подойдёт

Схема шин, разблокировки и лимита голосов покрывает звук большинства 2D-игр, но не всё:

  • Ритм-игра с точностью до миллисекунды. Синхронизация нажатий с долей такта требует калибровки задержки вывода звука на каждом устройстве; менеджер голосов из статьи этого не решает.
  • Длинная музыка в буфере. MDN описывает AudioBuffer как хранилище коротких фрагментов, обычно до 45 с, в формате 32-битных чисел с плавающей точкой, и для длинных звуков советует MediaElementAudioSourceNode (MDN, AudioBuffer). По нашему расчёту трек 3 мин стерео при 48 кГц занимает в буфере 180 × 48 000 × 2 × 4 байта ≈ 69 МБ.
  • Пространственный звук. Панорама и шины подходят для 2D-сцены, но не заменяют объёмный звук 3D-игр.
  • Нужно создавать звуки с нуля. Статья о воспроизведении и сведении; сами звуки готовят во внешних программах.

Мы разрабатываем J2DRU — браузерный 2D игровой движок с событийной логикой и путём к TypeScript; он в разработке и публично пока не открыт, заявку можно оставить на странице раннего доступа (подача заявки не гарантирует приглашение).

Источники (проверено: ноябрь 2026): MDN, GainNode; MDN, AudioParam: setTargetAtTime(); MDN, Autoplay guide for media and Web Audio APIs (обновлена 10.09.2026); Chrome for Developers, Autoplay policy in Chrome (Web Audio API — с Chrome 71, состояние suspended и resume()); MDN, AudioContext: resume(); MDN, AudioBufferSourceNode; MDN, AudioBuffer (до 45 с, 32-битные числа); MDN, AudioScheduledSourceNode: ended event; Sony ASWG, ASWG-R001 v1.2 (март 2024, репозиторий Sony-ASWG/Loudness на GitHub); Wikipedia, Ricoh 2A03. Формула 20 × log10(g) — стандартное определение уровня для амплитудных величин; таблица стартовых правил, −40 дБ, 60 мс и лимит 16 голосов, расчёт памяти буфера — наши значения.

Читайте также:

Частые вопросы

Почему звук в браузерной игре не играет до первого клика?

Браузеры применяют политику автозапуска: воспроизведение через Web Audio API вне обработчика действия пользователя подчиняется правилам блокировки. В Chrome AudioContext, созданный до действия пользователя, находится в состоянии suspended, и после клика, касания или нажатия клавиши нужно вызвать resume().

Что такое шина громкости в Web Audio API?

Это GainNode, к которому подключены несколько источников звука. Его параметр gain умножает сигнал всех этих источников, поэтому громкость музыки или эффектов меняется одним значением. Шины подключают к общему GainNode, а его — к выходу контекста.

Сколько децибел в громкости 0,5?

По формуле дБ = 20 × log10(g) множитель 0,5 даёт около −6 дБ, 0,25 — около −12 дБ, 0,1 — −20 дБ. Обратный перевод: g = 10^(дБ ÷ 20).

Сколько одинаковых звуков можно запускать одновременно?

Стандарта нет. Рабочий старт — 3–4 копии одного короткого звука и кулдаун около 60 мс между запусками, а также общий лимит голосов на всю игру с вытеснением наименее важного звука. Значения подбирают на слух.