Практический пример — Звуки гонщика

Введение

Racer — это многопользовательский эксперимент Chrome с поддержкой нескольких устройств. Игровой автомат в стиле ретро, ​​в который можно играть на разных экранах. На телефонах или планшетах, Android или iOS. Каждый может присоединиться. Никаких приложений. Никаких загрузок. Просто мобильный Интернет.

Plan8 вместе с нашими друзьями из 14islands создали динамичную музыку и звук на основе оригинальной композиции Джорджио Мородера. Racer включает в себя отзывчивые звуки двигателя, звуковые эффекты гонок и, что более важно, динамичный музыкальный микс, который распространяется на несколько устройств по мере присоединения гонщиков. Это инсталляция с несколькими динамиками, состоящая из смартфонов.

Соединение нескольких устройств вместе — это то, над чем мы некоторое время дурачились. Мы проводили музыкальные эксперименты, в которых звук разделялся на разных устройствах или перескакивал между устройствами, поэтому нам очень хотелось применить эти идеи к Racer.

В частности, мы хотели проверить, сможем ли мы создать музыкальную дорожку на всех устройствах по мере того, как все больше и больше людей присоединяются к игре — начиная с барабанов и баса, затем добавляя гитару, синтезаторы и так далее. Мы сделали несколько музыкальных демо и погрузились в программирование. Эффект нескольких динамиков был действительно полезным. На тот момент у нас не было правильной синхронизации, но когда мы услышали, как слои звука распределяются по устройствам, мы поняли, что на подходе что-то хорошее.

Создание звуков

Google Creative Lab наметила творческое направление по звуку и музыке. Мы хотели использовать аналоговые синтезаторы для создания звуковых эффектов, а не записывать настоящие звуки или обращаться к звуковым библиотекам. Мы также знали, что выходной динамик в большинстве случаев будет крошечным динамиком телефона или планшета, поэтому звуки должны были быть ограничены по частотному спектру, чтобы избежать искажений динамиков. Это оказалось довольно сложной задачей. Когда мы получили первые наброски музыки от Джорджио, мы почувствовали облегчение, потому что его композиция идеально сочеталась с созданными нами звуками.

Звук двигателя

Самой сложной задачей при программировании звуков было найти лучший звук двигателя и смоделировать его поведение. Гоночная трасса напоминала трассу Формулы-1 или Nascar, поэтому машины должны были чувствовать себя быстрыми и взрывными. В то же время машины были очень маленькими, поэтому громкий звук двигателя не мог связать звук с визуальными эффектами. В любом случае мы не могли добиться, чтобы в мобильном динамике играл мощный ревущий двигатель, поэтому нам пришлось придумать что-то еще.

Чтобы получить немного вдохновения, мы подключили модульные синтезаторы из коллекции нашего друга Джона Экстранда и начали возиться. Нам понравилось то, что мы услышали. Вот как это звучало с двумя генераторами, хорошими фильтрами и LFO.

Раньше аналоговое оборудование было с большим успехом переработано с использованием API веб-аудио, поэтому мы возлагали большие надежды и начали создавать простой синтезатор в веб-аудио. Генерируемый звук будет наиболее отзывчивым, но потребует дополнительной вычислительной мощности устройства. Нам нужно было быть предельно экономными, чтобы сэкономить все возможные ресурсы и обеспечить бесперебойную работу визуальных эффектов. Поэтому мы сменили технику на воспроизведение аудиосэмплов.

Модульный синтезатор для вдохновения звуком двигателя

Существует несколько методов, которые можно использовать для создания звука двигателя из семплов. Наиболее распространенным подходом для консольных игр было бы создание слоя из нескольких звуков движка (чем больше, тем лучше) на разных оборотах (с нагрузкой), а затем плавное затухание и изменение высоты тона между ними. Затем добавьте слой из нескольких звуков двигателя, просто вращающегося (без нагрузки) на одних и тех же оборотах, а также плавное затухание и изменение высоты тона между ними. Переход между этими слоями при переключении передач, если все сделано правильно, будет звучать очень реалистично, но только если у вас большое количество звуковых файлов. Кросспитч не может быть слишком широким, иначе он будет звучать очень синтетически. Поскольку нам нужно было избегать длительной загрузки, этот вариант нам не подходил. Мы попробовали использовать пять или шесть звуковых файлов для каждого слоя, но звук разочаровал. Нам пришлось найти способ с меньшим количеством файлов.

Самым эффективным решением оказалось следующее:

  • Один звуковой файл с ускорением и переключением передач, синхронизированный с визуальным ускорением автомобиля, заканчивающийся запрограммированным циклом на самом высоком шаге/об/мин. API веб-аудио очень хорош в точном зацикливании, поэтому мы можем делать это без сбоев и хлопков.
  • Один звуковой файл с замедлением/снижением оборотов двигателя.
  • И, наконец, один звуковой файл, воспроизводящий звук покоя/холостого хода в цикле.

Выглядит так

Графика звука двигателя

Для события первого касания/ускорения мы воспроизводим первый файл с самого начала, и если игрок отпускает газ, мы вычисляем время с того места, где мы находились в звуковом файле при отпускании, чтобы, когда газ снова включится, он подскочил. в нужное место в файле ускорения после воспроизведения второго файла (с понижением оборотов).

function throttleOn(throttle) {
    //Calculate the start position depending 
    //on the current amount of throttle.
    //By multiplying throttle we get a start position 
    //between 0 and 3 seconds.
    var startPosition = throttle * 3;

    var audio = context.createBufferSource();
    audio.buffer = loadedBuffers["accelerate_and_loop"];

    //Sets the loop positions for the buffer source.
    audio.loopStart = 5;
    audio.loopEnd = 9;

    //Starts the buffer source at the current time
    //with the calculated offset.
    audio.start(context.currentTime, startPosition);
}

Попробуй

Запустите двигатель и нажмите кнопку «Дроссель».

<input type="button" id="playstop" value = "Start/Stop Engine" onclick='playStop()'>
<input type="button" id="throttle" value = "Throttle" onmousedown='throttleOn()' onmouseup='throttleOff()'>

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

Получение синхронизации

Вместе с Дэвидом Линдквистом из 14islands мы начали глубже изучать вопрос обеспечения идеальной синхронизации игр на устройствах. Основная теория проста. Устройство запрашивает у сервера время, учитывает задержку в сети, а затем вычисляет смещение локальных часов.

syncOffset = localTime - serverTime - networkLatency

При таком смещении каждое подключенное устройство использует одну и ту же концепцию времени. Легко, правда? (Опять же, теоретически.)

Расчет задержки в сети

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

networkLatency = (receivedTime - sentTime) × 0.5

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

К счастью, наш мозг устроен так, что не замечает, если звуки немного задерживаются. Исследования показали, что требуется задержка от 20 до 30 миллисекунд (мс), прежде чем наш мозг начнет воспринимать звуки как отдельные. Однако примерно через 12–15 мс вы начнете «чувствовать» эффект задержанного сигнала, даже если не сможете полностью «воспринимать» его. Мы исследовали пару устоявшихся протоколов синхронизации времени, более простые альтернативы и попытались реализовать некоторые из них на практике. В конце концов — благодаря инфраструктуре Google с низкой задержкой — мы смогли просто отобрать пакет запросов и использовать образец с наименьшей задержкой в ​​качестве эталона.

Борьба с дрейфом часов

Это сработало! У нас было более 5 устройств, воспроизводящих пульс идеально синхронно, но только на какое-то время. После пары минут игры устройства расходились, хотя мы планировали звук, используя высокоточное время контекста API веб-аудио. Задержка накапливалась медленно, всего на пару миллисекунд за раз и поначалу была незаметна, но приводила к полной рассинхронизации музыкальных слоев после длительного воспроизведения. Привет, дрейф часов.

Решением было выполнять повторную синхронизацию каждые несколько секунд, вычислять новое смещение тактовой частоты и плавно передавать его в планировщик звука. Чтобы снизить риск заметных изменений в музыке из-за задержки в сети, мы решили сгладить изменения, сохранив историю последних смещений синхронизации и вычислив среднее значение.

Планирование песни и переключение аранжировок

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

  • Client(1) запускает песню.
  • Client(n) спрашивает первого клиента, когда была запущена песня.
  • Client(n) вычисляет контрольную точку, когда песня была запущена, используя контекст веб-аудио, учитывая syncOffset и время, прошедшее с момента создания ее аудиоконтекста.
  • playDelta = Date.now() - syncOffset - songStartTime - context.currentTime
  • Client(n) вычисляет, как долго длится песня, используя playDelta. Планировщик песен использует это, чтобы узнать, какой такт в текущей аранжировке следует воспроизвести следующим.
  • playTime = playDelta + context.currentTime nextBar = Math.ceil((playTime % loopDuration) ÷ barDuration) % numberOfBars

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

Смотреть вперед

Всегда важно планировать заранее при использовании setTimeout или setInterval в JavaScript. Это связано с тем, что часы JavaScript не очень точны, а запланированные обратные вызовы могут легко искажаться на десятки миллисекунд и более из-за макета, рендеринга, сборки мусора и XMLHTTPRequests. В нашем случае нам также пришлось учитывать время, необходимое всем клиентам для получения одного и того же события по сети.

Аудио спрайты

Объединение звуков в один файл — отличный способ уменьшить количество HTTP-запросов как для HTML Audio, так и для API веб-аудио. Это также лучший способ оперативного воспроизведения звуков с использованием объекта Audio, поскольку перед воспроизведением не требуется загружать новый аудиообъект. Уже есть несколько хороших реализаций , которые мы использовали в качестве отправной точки. Мы расширили наш спрайт, чтобы он надежно работал как на iOS, так и на Android, а также обрабатывал некоторые странные случаи, когда устройства засыпают.

На Android элементы Audio продолжают воспроизводиться, даже если вы переводите устройство в спящий режим. В спящем режиме выполнение JavaScript ограничено для экономии заряда батареи, и вы не можете полагаться на requestAnimationFrame , setInterval или setTimeout для запуска обратных вызовов. Это проблема, поскольку звуковые спрайты используют JavaScript для проверки необходимости остановки воспроизведения. Что еще хуже, в некоторых случаях currentTime элемента Audio не обновляется, хотя звук все еще воспроизводится.

Ознакомьтесь с реализацией AudioSprite, которую мы использовали в Chrome Racer в качестве запасного варианта, отличного от Web Audio.

Аудио элемент

Когда мы начали работать над Racer, Chrome для Android еще не поддерживал API веб-аудио. Логика использования HTML Audio для некоторых устройств и API веб-аудио для других в сочетании с расширенным выводом звука, которого мы хотели достичь, создала некоторые интересные проблемы. К счастью, теперь все это уже история. API веб-аудио реализован в бета-версии Android M28.

  • Задержки/проблемы со временем. Элемент Audio не всегда воспроизводится именно тогда, когда вы ему приказываете воспроизводиться. Поскольку JavaScript является однопоточным, браузер может быть занят, что приводит к задержкам воспроизведения до двух секунд.
  • Задержки воспроизведения означают, что плавное зацикливание не всегда возможно. На настольных компьютерах вы можете использовать двойную буферизацию, чтобы добиться циклов без пробелов, но на мобильных устройствах это невозможно, потому что:
    • Большинство мобильных устройств не воспроизводят более одного элемента Audio одновременно.
    • Фиксированный объем. Ни Android, ни iOS не позволяют изменять громкость объекта Audio.
  • Никакой предварительной загрузки. На мобильных устройствах элемент Audio не начнет загружать свой источник, пока воспроизведение не будет инициировано в обработчике touchStart .
  • Ищем проблемы . Получение duration или установка currentTime не удастся, если ваш сервер не поддерживает HTTP-диапазон байтов. Обратите внимание на это, если вы создаете звуковой спрайт, как это сделали мы.
  • Базовая аутентификация на MP3 не удалась. Некоторые устройства не загружают файлы MP3, защищенные базовой аутентификацией , независимо от того, какой браузер вы используете.

Выводы

Мы прошли долгий путь с момента нажатия кнопки отключения звука как лучшего варианта управления звуком в Интернете, но это только начало, и веб-аудио вот-вот начнет сильно падать. Мы лишь коснулись того, что можно сделать, когда дело доходит до синхронизации нескольких устройств. У нас не было вычислительной мощности в телефонах и планшетах, чтобы погрузиться в обработку сигналов и эффекты (например, реверберацию), но по мере увеличения производительности устройств веб-игры также будут использовать эти функции. Сейчас захватывающее время для продолжения расширения возможностей звука.