Игровое аудио — Sound, голоса, шины

Звуковые эффекты для игр. Sound — это клип, который загружен и декодирован заранее, так что его воспроизведение ничего не стоит; audio.play() запускает его на фиксированном пуле голосов и возвращает Voice — отбрось его (одиночный звук) или держи (цикл, которым ты управляешь и который останавливаешь). Каждый голос играет через микшерную шину Bus с громкостью, mute и цепочкой low-pass → эхо → реверберация. Звук, размещённый в 3D-мире — эмиттеры на узлах, зоны реверберации, слушатель — это 3D-аудио, на том же движке.

Не AudioPlayer: это потоковый медиаплеер платформы, один файл на плеер, для музыки, озвучки и длинных треков. Sound держит весь клип в памяти — он для эффектов: много одновременно, мгновенно, с питчем и вариантами.

Note

зависит от хоста — audio.supported равен true на десктопном хосте и iOS (как только откроется аудиоустройство), false на Android и в вебе. Без него ничего не бросает исключений: Sound.load сразу резолвится в немой клип (ready === false) без загрузки, play возвращает Voice, который уже завершён, каждый сеттер — no-op.

Обзор

TypeScript
const main = async () => {
  const click = await Sound.load(asset('./sfx/click.wav'))
  const steps = await Sound.load([asset('./sfx/step1.wav'), asset('./sfx/step2.wav'), asset('./sfx/step3.wav')])
  const hum = await Sound.load(asset('./sfx/hum.wav'))

  audio.play(click, { bus: 'ui' })                                       // запустил и забыл
  audio.play(steps, { volume: 0.6, pitch: 0.9 + Math.random() * 0.2 })  // случайный вариант при каждом play

  const engine = audio.play(hum, { loop: true, fade: 0.5 })               // держим Voice, чтобы управлять им
  engine.pitch = 1.3
  engine.addEventListener('ended', () => console.log('engine off'))
  setTimeout(() => engine.stop({ fade: 1 }), 3000)

  audio.bus('sfx').reverb = 'cave'
  audio.bus('music').volume = 0.5
}
main()

Загрузка — Sound

TypeScript
Sound.load(src: string | string[], options?: { stereo?: boolean }): Promise<Sound>
sound.duration      // секунды (первого варианта); 0 для немого клипа
sound.channels      // 1, или 2 с { stereo: true }
sound.variants      // из скольких файлов он загружен
sound.urls          // источники, как переданы
sound.ready         // false без аудио хоста или после dispose()
sound.disposed      // true после dispose()
sound.dispose()     // освободить декодированный клип сейчас; играющие его голоса сразу останавливаются. Идемпотентно

src — всё, что принимает fetch — asset('./x.wav'), удалённый URL. WAV, MP3 и OGG Vorbis; файл, который не декодируется, отклоняет промис (Sound.load: <url>: cannot decode (wav / mp3 / ogg vorbis)). Декодирование идёт вне главного потока: дождись загрузок до старта уровня.

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

Клип декодируется в моно, если не передан { stereo: true }: моно — это то, что пространственно размещает 3D-источник, и то, чем эффекты и так являются. Оставь стерео для 2D-подложек и эмбиентов; стереоклипы играют на своём, меньшем пуле голосов.

Note

Sound держит весь клип в памяти как 32-битный float на частоте движка — 192 КБ на секунду моно при 48 кГц, вдвое больше в стерео (audio.stats.clipBytes). Он живёт всё время работы приложения: потеря ссылки ничего не освобождает. Делай dispose() тому, что завершённому уровню больше не нужно.

Воспроизведение — audio.play и Voice

TypeScript
audio.play(sound: Sound, options?: {
  volume?: number     // линейное усиление; по умолчанию 1 (больше 1 усиливает)
  pitch?: number      // скорость воспроизведения, 0.01 … 10: 2 = на октаву выше и вдвое быстрее; по умолчанию 1
  loop?: boolean      // по умолчанию false
  bus?: string        // по умолчанию 'sfx'
  priority?: number   // перевешивает меньшие числа, когда пул полон; по умолчанию 0
  fade?: number       // fade-in, секунды; по умолчанию 0
  startAt?: number    // секунды от начала клипа; по умолчанию 0
  pan?: number        // -1 слева … 1 справа; по умолчанию 0
}): Voice
audio.stopAll(fade?: number)   // каждый голос на каждой шине; fade в секундах, по умолчанию 0

audio.play — 2D: одинаков, что бы ни делала камера, размещается только через pan. Для звука в точке мира используй AudioSource или audio.playAt.

TypeScript
voice.playing        // true пока играет; false, когда закончился, остановлен или был отобран
voice.time           // секунды от начала клипа
voice.volume = 0.5   // вживую, те же единицы, что в опциях
voice.pitch = 1.2
voice.pan = -1       // только 2D-голоса
voice.stop(options?: { fade?: number })     // секунды; по умолчанию 0 (сразу)
voice.addEventListener('ended', () => { })  // один раз: клип закончился, stop() или пул забрал слот

Voice — это билет в пул движка, а не ресурс: потеря его ничего не освобождает, а одиночный звук, который никто не держит, играет до конца. Как только голос завершён, playing читается как false и его сеттеры ничего не делают — его слот, возможно, уже играет другой звук.

Note

ended срабатывает на кадре после завершения голоса — после stop() сразу, не дожидаясь fade. Добавляй слушатель при запуске: добавленный после завершения голоса никогда не сработает. Голос, который так и не заиграл (нет аудио хоста, Sound после dispose, отклонённый play), завершён с самого начала, и слушатель, добавленный к нему, срабатывает немедленно.

Пул голосов

Движок микширует не более 80 моно- и 20 стереоголосов одновременно и ничего не аллоцирует на каждый play. Когда пул полон, новый звук забирает слот играющего голоса с меньшим или равным priority: сначала самый низкий приоритет, затем одиночные раньше циклов, затем самый тихий, затем самый старый. Вытесненный голос завершается (ended срабатывает, audio.stats.stolen считает его). Если у каждого играющего голоса приоритет выше, новый звук отклоняется: play возвращает уже завершённый голос.

Давай музыкальным подложкам, циклам двигателя и диалогам priority выше, чем пулям и шагам.

Шины

TypeScript
audio.bus(name: string): Bus   // 'master' | 'sfx' | 'music' | 'ui' | 'voice', или своя
audio.master                   // audio.bus('master')

bus.name
bus.volume = 0.7               // линейное усиление; по умолчанию 1
bus.muted = true               // по умолчанию false
bus.lowpass = 1200             // частота среза в Гц, 20 … 20000; null = выкл (по умолчанию)
bus.echo = { delay: 0.35, decay: 0.4, mix: 0.3 }   // null = выкл (по умолчанию)
bus.reverb = 'hall'            // пресет, параметры или null = выкл (по умолчанию)
bus.stopAll(fade?: number)     // голоса, играющие на этой шине; fade в секундах

Каждый голос играет через одну шину: sfx, если play (или его источник) не назвал другую; каждая другая шина идёт в master. Пять шин выше существуют с самого начала; любое другое имя создаёт шину при первом обращении — до 11 своих (16 всего). Сверх этого audio.bus() предупреждает и возвращает шину, которая ничего не делает, а голоса, отправленные на неё, играют на sfx. Bus получается только из audio.bus(); чтение настройки возвращает то, что было установлено.

Note

опечатка в имени шины — не ошибка: она тихо создаёт новую шину. А stopAll() на шине останавливает голоса, игравшие на ней, а не голоса шин, которые в неё идут: audio.master.stopAll() — это не audio.stopAll().

Цепочка инсертов идёт low-pass → эхо → реверберация. Изменения сглаживаются, так что переключение reverb в дверном проёме не щёлкает.

  • echo — delay секунд между повторами (до 2), decay обратная связь 0 … 0.95 (сколько повторов выживает), mix доля «мокрого» сигнала 0 … 1. Отсутствующие поля: 0.3 / 0.4 / 0.3.
  • reverb — имя пресета или { roomSize, damping, width, mix, preDelay }: roomSize 0 (чулан) … 1 (собор), damping потеря высоких частот 0 … 1, width стереоширина хвоста 0 … 1, mix доля «мокрого» сигнала 0 … 1, preDelay секунды до хвоста (до 0.25). Отсутствующие поля берутся из room.

REVERB_PRESETS содержит шесть пресетов как параметры:

пресет roomSize damping width mix preDelay (с)
room 0.45 0.6 0.8 0.18 0.005
bathroom 0.55 0.15 0.6 0.35 0.002
hall 0.82 0.4 1.0 0.3 0.025
cave 0.9 0.25 1.0 0.45 0.04
arena 0.95 0.5 1.0 0.3 0.06
outdoor 0.3 0.8 1.0 0.06 0
TypeScript
audio.bus('sfx').reverb = { ...REVERB_PRESETS.hall, mix: 0.5 }   // пресет, но «мокрее»

AudioZone подменяет реверберацию шины, пока слушатель внутри неё.

Пауза и замедленное время

Time.paused (или Time.scale = 0) заглушает шину sfx — она mute, а не замораживается: её голоса продолжают идти неслышно, и одиночный звук может закончиться во время паузы. Все остальные шины продолжают играть, так что меню паузы сохраняет свои клики ui и свою music. С audio.timeScalePitch = true Time.scale, отличный от 1, ещё и меняет питч шины sfx (замедление понижает тон каждого эффекта; 0.1 … 4×); по умолчанию false.

Note

игровые часы доходят до аудиодвижка через 3D-движок — это действует, как только существует Scene.

Статистика

TypeScript
audio.stats: {
  voicesPlaying: number   // живые голоса; голос, затухающий после stop(), уже не считается
  voicesMono: number
  voicesStereo: number
  stolen: number          // голоса, вытесненные полным пулом с момента старта движка
  clips: number           // декодированные клипы в памяти
  clipBytes: number       // их размер в байтах
  peak: number            // пик выхода master за последнюю секунду-две, линейный (1 = полная шкала)
  sampleRate: number      // частота микширования движка, Гц
  listenerZone: number    // зона реверберации, в которой слушатель (0 = нет) — см. 3D-аудио
  zoneBlend: number       // насколько глубоко внутри, 0 … 1
  hrtfVoices: number      // голоса, которые прямо сейчас рендерятся бинаурально — см. 3D-аудио
}

Каждое чтение запрашивает у движка свежий объект. Без аудио хоста — всё нули.

Note

в headless-флоу (lecodes render / lecodes test без --sim или --desktop) игровое аудио записывается, а не микшируется: Sound загружается сразу с duration 0, а шаг проверяет, что что-то проигралось — "expect": { "audio": { "played": "click" } } проходит, как только play использовал клип, чей источник содержит click. Безоконный сервер (--sim) запускает настоящий движок без аудиоустройства: загрузки, пул и stats настоящие, но голоса не продвигаются, так что одиночный звук там никогда не заканчивается — а декодирование занимает реальное время, пока кадры идут без ограничения, так что дай загрузкам длинный шаг frames.

Смотрите также

  • Медиа — AudioPlayer для музыки и длинных треков
  • 3D-аудио — AudioSource, AudioZone, scene.audio, audio.playAt, наушники
  • Time — Time.paused / Time.scale