Сказка о двух часах

Точное планирование веб-аудио

Крис Уилсон
Chris Wilson

Введение

Одной из самых больших проблем при создании отличного аудио- и музыкального программного обеспечения с использованием веб-платформы является управление временем. Не как «время для написания кода», а как время на часах — одна из наименее изученных тем о веб-аудио — как правильно работать со звуковыми часами. Объект Web Audio AudioContext имеет свойство currentTime, которое предоставляет эти звуковые часы.

В частности, для музыкальных приложений веб-аудио — не только для написания секвенсоров и синтезаторов, но и для любого ритмического использования аудиособытий, таких как драм-машины , игры и другие приложения — очень важно иметь последовательную и точную синхронизацию аудиособытий; не только запуск и остановка звуков, но и планирование изменений звука (например, изменение частоты или громкости). Иногда желательно иметь слегка рандомизированные по времени события — например, в демонстрации пулемета в разделе «Разработка игрового аудио с помощью API веб-аудио» , — но обычно мы хотим иметь согласованное и точное время для музыкальных нот.

Мы уже показали вам, как планировать заметки с помощью параметра времени методов Web Audio noteOn и noteOff (теперь переименованных в start и stop) в разделе «Начало работы с веб-аудио» , а также в разделе «Разработка игрового аудио с помощью API веб-аудио» ; однако мы не исследовали более сложные сценарии, такие как воспроизведение длинных музыкальных последовательностей или ритмов. Чтобы углубиться в это, сначала нам нужно немного узнать о часах.

Лучшие времена — часы веб-аудио

API веб-аудио предоставляет доступ к аппаратным часам аудиоподсистемы. Эти часы отображаются в объекте AudioContext через его свойство .currentTime как число с плавающей запятой, прошедшее с момента создания AudioContext. Это позволяет этим часам (далее называемым «аудио часами») быть очень точными; он разработан так, чтобы иметь возможность определять выравнивание на уровне отдельного звукового сэмпла, даже при высокой частоте дискретизации. Поскольку в «двойном» значении точность составляет около 15 десятичных цифр, даже если звуковые часы работают уже несколько дней, у них все равно должно оставаться достаточно битов, чтобы указать на конкретный сэмпл даже при высокой частоте дискретизации.

Звуковые часы используются для планирования параметров и аудиособытий в API веб-аудио — конечно, для start() и stop() , а также для методов set*ValueAtTime() в AudioParams. Это позволяет нам заранее настроить очень точно синхронизированные аудиособытия. На самом деле, очень заманчиво просто настроить все в Web Audio как время начала/остановки, однако на практике с этим возникает проблема.

Например, посмотрите на этот сокращенный фрагмент кода из нашего веб-аудио-введения, который устанавливает два такта паттерна хай-хэта восьмой ноты:

for (var bar = 0; bar < 2; bar++) {
  var time = startTime + bar * 8 * eighthNoteTime;

  // Play the hi-hat every eighth note.
  for (var i = 0; i < 8; ++i) {
    playSound(hihat, time + i * eighthNoteTime);
  }

Этот код будет отлично работать. Однако, если вы хотите изменить темп в середине этих двух тактов или прекратить игру до того, как эти два такта пройдут, вам не повезло. (Я видел, как разработчики делали такие вещи, как вставка узла усиления между заранее запланированными узлами AudioBufferSourceNodes и выходом, просто чтобы они могли отключить свои собственные звуки!)

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

Худшие времена — часы JavaScript

У нас также есть любимые и столь порицаемые часы JavaScript, представленные Date.now() и setTimeout(). Хорошая сторона часов JavaScript заключается в том, что они имеют несколько очень полезных методов «перезвони мне позже» window.setTimeout() и window.setInterval(), которые позволяют системе вызывать наш код обратно в определенное время.

Плохая сторона часов JavaScript в том, что они не очень точны. Во-первых, Date.now() возвращает значение в миллисекундах — целое число миллисекунд — поэтому лучшая точность, на которую вы можете надеяться, — это одна миллисекунда. В некоторых музыкальных контекстах это не так уж и плохо — если ваша нота началась на миллисекунду раньше или позже, вы можете даже этого не заметить — но даже при относительно низкой частоте аудиооборудования (44,1 кГц) она примерно в 44,1 раза медленнее для использования в качестве часы планирования звука. Помните, что удаление любых сэмплов вообще может вызвать сбои в звуке, поэтому, если мы объединяем сэмплы в цепочку, нам может потребоваться, чтобы они были точно последовательными.

Многообещающая спецификация времени высокого разрешения на самом деле дает нам гораздо лучшую точность текущего времени с помощью window. Performance.now(); он даже реализован (хотя и с префиксом) во многих современных браузерах. Это может помочь в некоторых ситуациях, хотя на самом деле это не имеет отношения к худшей части API синхронизации JavaScript.

Худшая часть API-интерфейсов синхронизации JavaScript заключается в том, что, хотя миллисекундная точность Date.now() кажется не такой уж плохой, фактический обратный вызов событий таймера в JavaScript (через window.setTimeout() или window.setInterval) может быть легко искажено на десятки миллисекунд или более из-за макета, рендеринга, сборки мусора, а также XMLHTTPRequest и других обратных вызовов - короче говоря, из-за любого количества событий, происходящих в основном потоке выполнения. Помните, как я упоминал «аудиособытия», которые мы могли бы запланировать с помощью API веб-аудио? Что ж, все они обрабатываются в отдельном потоке - поэтому, даже если основной поток временно останавливается из-за выполнения сложной компоновки или другой долгой задачи, звук все равно будет воспроизводиться именно в то время, когда ему было приказано - фактически, даже если вы остановились на точке останова в отладчике, аудиопоток продолжит воспроизводить запланированные события!

Использование JavaScript setTimeout() в аудиоприложениях

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

Чтобы продемонстрировать это, я написал пример «плохого» приложения метронома, то есть того, которое использует setTimeout непосредственно для планирования нот, а также выполняет множество макетов. Откройте это приложение, нажмите «Воспроизвести», а затем быстро измените размер окна во время воспроизведения; вы заметите, что ритм заметно колеблется (вы можете услышать, что ритм не остается постоянным). «Но это надумано!» ты говоришь? Ну, конечно, но это не значит, что этого не происходит и в реальном мире. Даже относительно статический пользовательский интерфейс будет иметь проблемы с синхронизацией в setTimeout из-за релеаутов - например, я заметил, что быстрое изменение размера окна приведет к заметному заиканию времени на отличном в остальном WebkitSynth . Теперь представьте, что произойдет, когда вы попытаетесь плавно прокрутить полную музыкальную партитуру вместе со звуком, и вы легко сможете представить, как это повлияет на сложные музыкальные приложения в реальном мире.

Один из наиболее часто задаваемых вопросов, который я слышу: «Почему я не могу получать обратные вызовы из аудиособытий?» Хотя эти типы обратных вызовов могут быть использованы, они не решат конкретную проблему — важно понимать, что эти события будут запускаться в основном потоке JavaScript, поэтому они будут подвержены всем тем же потенциальным задержкам, что и установитьТаймаут; то есть они могут быть задержаны на какое-то неизвестное и переменное количество миллисекунд от точного времени, когда они были запланированы, до их фактической обработки.

Так что же мы можем сделать? Что ж, лучший способ управлять временем — настроить взаимодействие между таймерами JavaScript (setTimeout(), setInterval() или requestAnimationFrame() — подробнее об этом позже) и планированием аудиооборудования.

Как найти надежный график, заглядывая в будущее

Давайте вернемся к этой демонстрации метронома — на самом деле, я написал первую версию этой простой демонстрации метронома правильно, чтобы продемонстрировать эту технику совместного планирования. ( Код также доступен на Github. Эта демонстрация воспроизводит звуковые сигналы (генерируемые генератором) с высокой точностью на каждой шестнадцатой, восьмой или четвертной ноте, изменяя высоту звука в зависимости от доли. Она также позволяет изменять темп и интервал нот. во время воспроизведения или остановить воспроизведение в любой момент — что является ключевой функцией любого реального ритмического секвенсора. Было бы довольно легко добавить код для изменения звуков, которые этот метроном использует на лету.

Способ, которым ему удается обеспечить контроль температуры, сохраняя при этом надежную синхронизацию, - это сотрудничество: таймер setTimeout, который срабатывает один раз очень часто и настраивает планирование веб-аудио в будущем для отдельных нот. Таймер setTimeout по сути просто проверяет, нужно ли запланировать какие-либо ноты «в ближайшее время» на основе текущего темпа, а затем планирует их, например:

setTimeout() и взаимодействие со звуковыми событиями.
setTimeout() и взаимодействие аудиособытий.

На практике вызовы setTimeout() могут задерживаться, поэтому время вызовов планирования может колебаться (и искажаться, в зависимости от того, как вы используете setTimeout) с течением времени — хотя события в этом примере происходят с интервалом примерно в 50 мс, они часто немного отличаются друг от друга. более того (а иногда и намного больше). Однако во время каждого вызова мы планируем события Web Audio не только для любых нот, которые необходимо воспроизвести сейчас (например, самую первую ноту), но также для любых нот, которые необходимо воспроизвести между текущим моментом и следующим интервалом.

На самом деле, мы не хотим просто заглядывать вперед по интервалу между вызовами setTimeout() — нам также необходимо некоторое перекрытие планирования между этим вызовом таймера и следующим, чтобы учесть худшее поведение основного потока, то есть худший случай сбора мусора, компоновки, рендеринга или другого кода, происходящего в основном потоке, задерживает наш следующий вызов таймера. Нам также необходимо учитывать время планирования аудиоблока — то есть, сколько звука операционная система хранит в своем буфере обработки — которое варьируется в зависимости от операционной системы и оборудования: от нескольких миллисекунд до примерно 50 мс. Каждый вызов setTimeout(), показанный выше, имеет синий интервал, показывающий весь диапазон времени, в течение которого он будет пытаться запланировать события; например, четвертое событие веб-аудио, запланированное на диаграмме выше, могло быть воспроизведено «с опозданием», если бы мы ждали его воспроизведения до следующего вызова setTimeout, если бы этот вызов setTimeout произошел всего на несколько миллисекунд позже. В реальной жизни дрожание в такие моменты может быть еще более сильным, и это совпадение становится еще более важным по мере того, как ваше приложение становится более сложным.

Общая задержка просмотра влияет на то, насколько жестким может быть контроль темпа (и другие элементы управления в реальном времени); интервал между вызовами планирования — это компромисс между минимальной задержкой и частотой воздействия вашего кода на процессор. Насколько упреждающий просмотр перекрывается со временем начала следующего интервала, определяет, насколько устойчивым будет ваше приложение на разных компьютерах, а также по мере того, как оно становится более сложным (а макетирование и сбор мусора могут занять больше времени). В общем, чтобы быть устойчивым к более медленным машинам и операционным системам, лучше всего иметь большой общий прогноз и достаточно короткий интервал. Вы можете настроить более короткие перекрытия и более длинные интервалы, чтобы обрабатывать меньше обратных вызовов, но в какой-то момент вы можете услышать, что большая задержка приводит к тому, что изменения темпа и т. д. не вступают в силу немедленно; и наоборот, если вы слишком сильно уменьшили просмотр вперед, вы можете начать слышать некоторое дрожание (поскольку вызову планирования может потребоваться «восстановить» события, которые должны были произойти в прошлом).

Следующая временная диаграмма показывает, что на самом деле делает демонстрационный код метронома: интервал setTimeout составляет 25 мс, но гораздо более устойчивое перекрытие: каждый вызов запланирован на следующие 100 мс. Обратной стороной этого долгого просмотра является то, что изменения темпа и т. д. вступят в силу за десятую долю секунды; однако мы гораздо более устойчивы к прерываниям:

Планирование с длительными перекрытиями.
планирование с длительными перекрытиями

Фактически, вы можете сказать, что в этом примере у нас было прерывание setTimeout в середине — у нас должен был быть обратный вызов setTimeout примерно через 270 мс, но по какой-то причине он был задержан примерно до 320 мс — на 50 мс позже, чем должно было быть! Тем не менее, большая задержка упреждающего просмотра без проблем поддерживала синхронизацию, и мы не пропустили ни одного удара, даже несмотря на то, что незадолго до этого мы увеличили темп до воспроизведения шестнадцатых нот со скоростью 240 ударов в минуту (даже за пределами хардкорных темпов драм-н-бэйса!)

Также возможно, что каждый вызов планировщика может в конечном итоге запланировать несколько нот — давайте посмотрим, что произойдет, если мы используем более длинный интервал планирования (просмотр вперед 250 мс, с интервалом 200 мс) и увеличение темпа в середине:

setTimeout() с длинным просмотром вперед и длинными интервалами.
setTimeout() с длинным просмотром вперед и длинными интервалами

Этот случай демонстрирует, что каждый вызов setTimeout() может в конечном итоге запланировать несколько аудиособытий — по сути, этот метроном представляет собой простое приложение, воспроизводящее одну ноту за раз, но вы можете легко увидеть, как этот подход работает для драм-машины ( где часто одновременно звучит несколько нот) или секвенсор (который часто может иметь нерегулярные интервалы между нотами).

На практике вам понадобится настроить интервал планирования и просмотр вперед, чтобы увидеть, как на него влияют макет, сборка мусора и другие вещи, происходящие в основном потоке выполнения JavaScript, а также настроить степень детализации контроля над темпом и т. д. Например, если у вас очень сложный макет, который встречается часто, вам, вероятно, захочется увеличить размер просмотра. Суть в том, что мы хотим, чтобы количество «предварительного планирования», которое мы делаем, было достаточно большим, чтобы избежать каких-либо задержек, но не настолько большим, чтобы создавать заметную задержку при настройке контроля темпа. Даже приведенный выше случай имеет очень небольшое перекрытие, поэтому он не будет очень устойчивым на медленной машине со сложным веб-приложением. Хорошее место для начала — это, вероятно, 100 мс времени «просмотра вперед» с интервалами, установленными на 25 мс. Это может по-прежнему вызывать проблемы в сложных приложениях на машинах с большой задержкой аудиосистемы, и в этом случае вам следует увеличить время просмотра; или, если вам нужен более жесткий контроль с потерей некоторой устойчивости, используйте более короткий просмотр вперед.

Основной код процесса планирования находится в функции Scheduler():

while (nextNoteTime < audioContext.currentTime + scheduleAheadTime ) {
  scheduleNote( current16thNote, nextNoteTime );
  nextNote();
}

Эта функция просто получает текущее время аудиооборудования и сравнивает его со временем следующей ноты в последовательности — большую часть времени* в этом конкретном сценарии это ничего не даст (поскольку нет «нот» метронома, ожидающих планирования , но в случае успеха он запланирует эту ноту с помощью API веб-аудио и перейдет к следующей ноте.

Функция ScheduleNote() отвечает за фактическое планирование воспроизведения следующей «ноты» веб-аудио. В этом случае я использовал генераторы, чтобы издавать звуковые сигналы на разных частотах; вы можете так же легко создать узлы AudioBufferSource и установить их буферы для звуков барабанов или любых других звуков, которые вы пожелаете.

currentNoteStartTime = time;

// create an oscillator
var osc = audioContext.createOscillator();
osc.connect( audioContext.destination );

if (! (beatNumber % 16) )         // beat 0 == low pitch
  osc.frequency.value = 220.0;
else if (beatNumber % 4)          // quarter notes = medium pitch
  osc.frequency.value = 440.0;
else                              // other 16th notes = high pitch
  osc.frequency.value = 880.0;
osc.start( time );
osc.stop( time + noteLength );

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

Метод nextNote() отвечает за переход к следующей шестнадцатой ноте, то есть за установку переменных nextNoteTime и current16thNote для следующей ноты:

function nextNote() {
  // Advance current note and time by a 16th note...
  var secondsPerBeat = 60.0 / tempo;    // picks up the CURRENT tempo value!
  nextNoteTime += 0.25 * secondsPerBeat;    // Add 1/4 of quarter-note beat length to time

  current16thNote++;    // Advance the beat number, wrap to zero
  if (current16thNote == 16) {
    current16thNote = 0;
  }
}

Это довольно просто — хотя важно понимать, что в этом примере планирования я не отслеживаю «время последовательности» — то есть время с момента начала запуска метронома. Все, что нам нужно сделать, это запомнить, когда мы сыграли последнюю ноту, и выяснить, когда запланировано сыграть следующую ноту. Таким образом, мы можем очень легко изменить темп (или прекратить игру).

Этот метод планирования используется рядом других аудиоприложений в сети — например, Web Audio Drum Machine , очень забавной игрой Acid Defender и еще более подробными примерами аудио, такими как демо-версия Granular Effects .

Еще одна система времени

Теперь, как знает любой хороший музыкант, каждому аудиоприложению нужно больше колокольчиков, ну, больше таймеров. Стоит отметить, что правильный способ визуального отображения – это использование ТРЕТЬЕЙ системы синхронизации!

Почему, почему, о Боже, зачем нам нужна еще одна система времени? Ну, этот синхронизируется с визуальным отображением — то есть частотой обновления графики — через API requestAnimationFrame . Для рисования блоков в нашем примере с метрономом это может показаться не такой уж большой проблемой, но по мере того, как ваша графика становится все более и более сложной, становится все более и более важным использовать requestAnimationFrame() для синхронизации с частотой визуального обновления - и на самом деле с самого начала его так же легко использовать, как и использовать setTimeout()! При очень сложной синхронизированной графике (например, точном отображении плотных музыкальных нот при их воспроизведении в пакете нотной записи) requestAnimationFrame() обеспечит наиболее плавную и точную синхронизацию графики и звука.

Мы отслеживали удары в очереди в планировщике:

notesInQueue.push( { note: beatNumber, time: time } );

Взаимодействие с текущим временем нашего метронома можно найти в методе draw(), который вызывается (с использованием requestAnimationFrame) всякий раз, когда графическая система готова к обновлению:

var currentTime = audioContext.currentTime;

while (notesInQueue.length && notesInQueue[0].time < currentTime) {
  currentNote = notesInQueue[0].note;
  notesInQueue.splice(0,1);   // remove note from queue
}

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

Конечно, я мог бы просто вообще не использовать обратный вызов setTimeout() и поместить свой планировщик заметок в обратный вызов requestAnimationFrame - тогда мы снова вернулись бы к двум таймерам. Это тоже нормально, но важно понимать, что в данном случае requestAnimationFrame — это просто замена setTimeout(); вам по-прежнему потребуется точность планирования синхронизации веб-аудио для реальных заметок.

Заключение

Я надеюсь, что это руководство помогло объяснить часы, таймеры и то, как встроить отличный тайминг в веб-аудиоприложения. Эти же методы можно легко экстраполировать для создания проигрывателей секвенций, драм-машин и многого другого. До следующего раза…