В отличие от тега <audio>, Web Audio API предлагает модель с низкой задержкой и высокой
точностью управления временем.
Низкая задержка очень важна для игр и других интерактивных приложений, так как в них часто требуется максимально быстро озвучивать действия пользователя. Если обратная связь не произойдёт немедленно, пользователь ощутит задержку, что может привести к разочарованию и фрустрации. На практике, по причине несовершенства человеческого слухового аппарата, допустима задержка примерно до 20 мс, но этот предел зависит от многих факторов.
Точное управление временем позволяет планировать события в конкретном времени в будущем. Это очень важно для заранее подготовленных сцен и в музыкальных приложениях.
Одним из ключевых элементов, которые предоставляет аудиоконтекст, является согласованная модель измерения времени и
единая система отсчёта. Важно, что эта модель отличается от привычных в JavaScript таймеров, таких как
setTimeout, setInterval и new Date(). Она также отличается от счётчика
времени, предоставляемого window.performance.now().
Любое абсолютное время, с которым вы будете работать внутри Web Audio API, измеряется в секундах и находится внутри
координатной системы конкретного аудиоконтекста. Текущее время может быть извлечено из аудиоконтекста с помощью
свойства currentTime. Хоть время измеряется в секундах, оно хранится в виде чисел с плавающей точкой с
большой степенью точности.
Функция start() позволяет легко запланировать проигрывание звука в точное время. Для начала необходимо
проверить, что ваши звуковые буферы предзагружены (см. Загрузка и воспроизведение звуков). Без этого вам придётся
ждать неизвестное количество времени, пока
браузер загрузит звуковой файл и пока Web Audio API декодирует его. Если вы хотите, чтобы звук проигрался в
конкретное время, это может не сработать, если аудиобуфер всё ещё не готов.
Звуки могут быть запланированы к проигрыванию в конкретное время с помощью первого параметра when
функции start(). Значение этого параметра указывается относительно свойства currentTime
текущего AudioContext. Если указанное время меньше currentTime, воспроизведение начнётся
немедленно. Таким
образом, start(0) всегда проиграет звук сразу после вызова. Для того, чтобы запланировать
проигрывание звука через 5 секунд — вызовите start(context.currentTime + 5).
Аудиобуферы могут быть воспроизведены начиная с заданного смещения, если функция start() вызвана с
вторым
параметром offset, а если передан ещё и третий параметр duration, звук будет
воспроизводиться только заданное количество секунд. Например, если мы хотим приостановить звук и затем воспроизвести
его с того же места, где остановились, мы можем реализовать паузу, отслеживая, сколько времени звук воспроизводился
в текущей сессии, а также запоминая последний смещённый момент времени для последующего возобновления проигрывания:
// Предположим, что буфер предзагружен внутри заранее созданного контекста
var startOffset = 0;
var startTime = 0;
function pause() {
source.stop();
// Измеряем, сколько времени прошло с последней паузы
startOffset += context.currentTime - startTime;
}
Когда аудио закончит проигрываться, его уже нельзя будет воспроизвести повторно. Для того, чтобы воспроизвести
аудиобуфер ещё
раз, необходимо создать новый узел аудиоисточника (AudioBufferSourceNode) и вызвать
start():
function play() {
startTime = context.currentTime;
var source = context.createBufferSource();
// Подключаем граф
source.buffer = this.buffer;
source.loop = true;
source.connect(context.destination);
// Начинаем воспроизведение, но только убедившись,
// что мы остаёся в пределах исходного аудиобуфера
source.start(0, startOffset % buffer.duration);
}
Хотя пересоздание узла аудиоисточника может показаться неэффективным, важно помнить, что эти узлы отлично
оптимизированы для работы в подобном стиле. Запомните, что если вы продолжаете работать с тем же аудиобуфером, вам
не нужно обращаться за новым ресурсом, чтобы снова воспроизвести тот же самый звук. Таким образом, вы получаете
чёткое разделение между буфером и проигрывателем и можете спокойно воспроизводить разные версии одного и того же
буфера. Если вы заметите, что постоянно повторяете этот паттерн, вы можете реализовать простую функцию
playSound(buffer) с ранее продемонстрированными примерами кода.
Web Audio API позволяет разработчикам точно планировать воспроизведение в будущем. Давайте настроим простой ритмический трек, чтобы это продемонстрировать. Возможно простейший и самый известный ритмический паттерн (см. Рисунок 2-1) — это хай-хет, который играется каждую восьмую ноту и кик со снэйром, которые играются чередующимися четверными нотами в тактовом размере 4/4.
Предположим, мы уже предзагрузили звуки кика, снэйра и хай-хета в буфер и теперь напишем код, который реализует этот ритмический рисунок:
for (var bar = 0; bar < 2; bar++) {
var time = startTime + bar * 8 * eighthNoteTime;
// Проиграем басовый барабан (кик) на счёт 1 и 5
playSound(kick, time);
playSound(kick, time + 4 * eighthNoteTime);
// Проиграем малый барабан (снэйр) на счёт 3 и 7
playSound(snare, time + 2 * eighthNoteTime);
playSound(snare, time + 6 * eighthNoteTime);
// Проиграем хай-хет каждую восьмую ноту
for (var i = 0; i < 8; ++i) {
playSound(hihat, time + i * eighthNoteTime);
}
}
После того как вы запланировали воспроизведение звука, отменить это событие уже нельзя. Поэтому, если вы работаете с приложением, в котором всё быстро меняется, не рекомендуется планировать звуки слишком надолго вперёд. Хорошее решение этой проблемы — реализовать собственный планировщик с помощью таймеров JavaScript и очереди событий. Этот подход описан в Сказке о двух часах.
У разных аудиоузлов есть возможность менять параметры. Например, у узла GainNode можно менять параметр
усиления, задающий коэффициент громкости для всех звуков, которые проходят через этот узел. Например, значение
усиления равное 1 никак не повлияет на амплитуду звука, значение 0.5 уменьшит её вдвое, а
значение 2 увеличит её в два раза (см. Громкость, усиление и воспринимаемая звучность). Для
демонстрации
этого настроим аудиограф:
// Создадим узел усиления
var gainNode = context.createGain();
// Подключим аудиоисточник к узлу усиления
source.connect(gainNode);
// Подключим узел усиления к аудиовыходу
gainNode.connect(context.destination);
В Web Audio API параметры аудио представлены в виде экземпляров интерфейса AudioParam. Значения
параметров можно менять напрямую через свойство value конкретного параметра:
// Уменьшим громкость в два раза
gainNode.gain.value = 0.5;
Значения также могут быть изменены отложенно. Мы можем использовать setTimeout для отложенного
планирования изменения значений параметров, но это не даст необходимой точности по следующим причинам:
Тайминг с точностью до миллисекунд может оказаться недостаточным.
Основной поток выполнения JS может быть занят высокоприоритетными задачами, такими как: отрисовка страницы, сбор мусора и вызов колбеков из других API, что может замедлить работу таймера.
JS-таймеры зависят от состояния вкладки браузера. В фоновых вкладках таймеры замедляются.
Вместо того, чтобы устанавливать значения напрямую, мы можем использовать метод
setValueAtTime(), который принимает в аргументах нужное для установки значение и стартовое
время. К примеру, следующий код установит заданное значение усиления через одну секунду:
gainNode.gain.setValueAtTime(0.5, context.currentTime + 1);
Очень часто нужно менять параметры аудио не резко, а постепенно. К примеру, когда вы строите
приложение, которое проигрывает музыку, вы можете захотеть добавить эффект затухания в конце проигрывания старого
трека и эффект набирания громкости в начале проигрывания нового, чтобы избежать резкого перехода между ними.
Конечно, можно добиться этого множественными вызовами метода setValueAtTime(), однако это неудобно.
Web Audio API предоставляет набор методов RampToValue, позволяющих
менять любые параметры постепенно. Это
методы linearRampToValueAtTime() и exponentialRampToValueAtTime(). Различие между ними
заключается в характере перехода: линейном или экспоненциальном. В некоторых случаях экспоненциальный
переход оказывается более уместным, поскольку многие свойства звука мы воспринимаем именно экспоненциально.
Давайте возьмём пример реализации кроссфейда (плавного перехода между аудиотреками с постепенным изменением громкости звука) и планирования его в будущем. При наличии плейлиста мы можем переходить между треками, запланировав затухание громкости для текущего трека и нарастание громкости для следующего, выполняя обе операции чуть раньше завершения текущего трека:
function createSource(buffer) {
var source = context.createBufferSource();
var gainNode = context.createGainNode();
source.buffer = buffer;
// Подключим источник к узлу усиления
source.connect(gainNode);
// Подключим узел усиления к аудиовыходу
gainNode.connect(context.destination);
return {
source: source,
gainNode: gainNode
};
}
function playHelper(buffers, iterations, fadeTime) {
var currTime = context.currentTime;
for (var i = 0; i < iterations; i++) {
// Для каждого буфера запланируем его воспроизведение в будущем
for (var j = 0; j < buffers.length; j++) {
var buffer = buffers[j];
var duration = buffer.duration;
var info = createSource(buffer);
var source = info.source;
var gainNode = info.gainNode;
// Запланируем постепенное нарастание громкости
gainNode.gain.linearRampToValueAtTime(0, currTime);
gainNode.gain.linearRampToValueAtTime(1, currTime + fadeTime);
// Запланируем постепенное затухание громкости
gainNode.gain.linearRampToValueAtTime(1, currTime + duration-fadeTime);
gainNode.gain.linearRampToValueAtTime(0, currTime + duration);
// Запускаем проигрывание трека
source.noteOn(currTime);
// Увеличиваем время для следующей итерации
currTime += duration - fadeTime;
}
}
}
Если вам не подходят методы для линейного или экспоненциального переходов, вы можете задать свою кривую изменения
через задание массива чисел с помощью метода setValueCurveAtTime(). Этот метод позволит объединить
множественные вызовы метода setValueAtTime(). Для примера, мы можем создать эффект тремоло, применив
кривую осцилляции к параметру усиления звука через узел GainNode (см. Рисунок
2-2).
Давайте реализуем этот эффект в коде:
var DURATION = 2;
var FREQUENCY = 1;
var SCALE = 0.4;
// Разделим время на дискретные шаги в размере valueCount
var valueCount = 4096;
// Создадим кривую в форме синусоиды
var values = new Float32Array(valueCount);
for (var i = 0; i < valueCount; i++) {
var percent = (i / valueCount) * DURATION*FREQUENCY;
values[i] = 1 + (Math.sin(percent * 2*Math.PI) * SCALE);
// Установим последнее значение в 1, чтобы восстановить
// значение playbackRate к нормальному в конце
if (i == valueCount - 1) {
values[i] = 1;
}
}
// Применим это к узлу усиления незамедлительно,
// и укажем время работы функции в 2 секунды
this.gainNode.gain.setValueCurveAtTime(
values,
context.currentTime,
DURATION
);
В данном примере кода мы вручную задали синусоиду и применили её к параметру усиления, чтобы создать эффект тремоло. Это потребовало некоторых математических знаний.
Это приводит нас к довольно изящной возможности Web Audio API, которая позволяет реализовать этот эффект намного
проще. Мы можем взять любой аудиопоток, который подключён к другому AudioNode и вместо этого подключить
его к любому AudioParam. Эта важная возможность позволяет создавать множество интересных эффектов.
Предыдущий код — это пример так называемого низкочастотного осциллятора (LFO), который применён к узлу усиления.
LFO используется в создании таких эффектов как вибрато, смещение фазы (фейзинг) и тремоло. Используя встроенный узел
осциллятора (см. Прямой синтез
звука на основе осциллятора), мы можем легко перестроить предыдущий пример с тремоло:
// Создадим осциллятор
var osc = context.createOscillator();
osc.frequency.value = FREQUENCY;
var gain = context.createGain();
gain.gain.value = SCALE;
osc.connect(gain);
gain.connect(this.gainNode.gain);
// Запускаем эффект сразу и останавливаем через 2 секунды
osc.start(0);
osc.stop(context.currentTime + DURATION);
Этот подход более эффективен, чем создание произвольных кривых и позволяет избежать ручного вычисления синусоид только для того, чтобы просто зациклить эффект.