Игровое аудио — Sound, голоса, шины
Звуковые эффекты для игр. Sound — это клип, который загружен и декодирован заранее, так что его
воспроизведение ничего не стоит; audio.play() запускает его на фиксированном пуле голосов и
возвращает Voice — отбрось его (одиночный звук) или держи (цикл, которым ты управляешь и который
останавливаешь). Каждый голос играет через микшерную шину Bus с громкостью, mute и цепочкой
low-pass → эхо → реверберация. Звук, размещённый в 3D-мире — эмиттеры на узлах, зоны реверберации,
слушатель — это 3D-аудио, на том же движке.
Не AudioPlayer: это потоковый медиаплеер платформы, один файл на
плеер, для музыки, озвучки и длинных треков. Sound держит весь клип в памяти — он для эффектов:
много одновременно, мгновенно, с питчем и вариантами.
зависит от хоста — audio.supported равен true на десктопном хосте и iOS (как только откроется
аудиоустройство), false на Android и в вебе. Без него ничего не бросает исключений: Sound.load
сразу резолвится в немой клип (ready === false) без загрузки, play возвращает Voice, который
уже завершён, каждый сеттер — no-op.
Обзор
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
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-подложек и эмбиентов; стереоклипы играют на своём, меньшем пуле голосов.
Sound держит весь клип в памяти как 32-битный float на частоте движка — 192 КБ на секунду моно
при 48 кГц, вдвое больше в стерео (audio.stats.clipBytes). Он живёт всё время работы приложения:
потеря ссылки ничего не освобождает. Делай dispose() тому, что завершённому уровню больше не
нужно.
Воспроизведение — audio.play и Voice
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 в секундах, по умолчанию 0audio.play — 2D: одинаков, что бы ни делала камера, размещается только через pan. Для звука в
точке мира используй AudioSource или audio.playAt.
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
и его сеттеры ничего не делают — его слот, возможно, уже играет другой звук.
ended срабатывает на кадре после завершения голоса — после stop() сразу, не дожидаясь fade.
Добавляй слушатель при запуске: добавленный после завершения голоса никогда не сработает. Голос,
который так и не заиграл (нет аудио хоста, Sound после dispose, отклонённый play), завершён с
самого начала, и слушатель, добавленный к нему, срабатывает немедленно.
Пул голосов
Движок микширует не более 80 моно- и 20 стереоголосов одновременно и ничего не аллоцирует на
каждый play. Когда пул полон, новый звук забирает слот играющего голоса с меньшим или равным
priority: сначала самый низкий приоритет, затем одиночные раньше циклов, затем самый тихий, затем
самый старый. Вытесненный голос завершается (ended срабатывает, audio.stats.stolen считает его).
Если у каждого играющего голоса приоритет выше, новый звук отклоняется: play возвращает уже
завершённый голос.
Давай музыкальным подложкам, циклам двигателя и диалогам priority выше, чем пулям и шагам.
Шины
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(); чтение настройки возвращает то, что было
установлено.
опечатка в имени шины — не ошибка: она тихо создаёт новую шину. А 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 }:roomSize0 (чулан) … 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 |
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.
игровые часы доходят до аудиодвижка через 3D-движок — это действует, как только существует
Scene.
Статистика
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-аудио
}Каждое чтение запрашивает у движка свежий объект. Без аудио хоста — всё нули.
в headless-флоу (lecodes render / lecodes test без --sim или --desktop) игровое аудио
записывается, а не микшируется: Sound загружается сразу с duration 0, а шаг проверяет, что
что-то проигралось — "expect": { "audio": { "played": "click" } } проходит, как только play
использовал клип, чей источник содержит click. Безоконный сервер (--sim) запускает настоящий
движок без аудиоустройства: загрузки, пул и stats настоящие, но голоса не продвигаются, так что
одиночный звук там никогда не заканчивается — а декодирование занимает реальное время, пока кадры
идут без ограничения, так что дай загрузкам длинный шаг frames.