Расширенные методы: Создание и упорядочивание звука
В этом учебнике мы рассмотрим создание и модификацию звука, а также управление временем и планирование. Мы рассмотрим загрузку сэмплов, огибающие, фильтры, таблицы волн и модуляцию частоты. Если вы знакомы с этими терминами и ищете введение в их применение с API Web Audio, вы попали по адресу.
Примечание: Исходный код демонстрации ниже доступен на GitHub в подкаталоге step-sequencer репозитория MDN webaudio-examples. Также вы можете посмотреть живую демонстрацию.
Демо
Мы рассмотрим очень простой секвенсор шагов:
На практике это легче сделать с помощью библиотеки — API Web Audio был создан для того, чтобы его можно было расширять. Если вы собираетесь создать что-то более сложное, tone.js — отличная отправная точка. Однако мы хотим продемонстрировать, как создать такую демонстрацию с нуля в качестве учебного упражнения.
Интерфейс состоит из основных элементов управления, которые позволяют воспроизводить/останавливать секвенсор и изменять BPM (удары в минуту), чтобы ускорить или замедлить «музыку».
Можно воспроизводить четыре разных звука или голоса. Каждый голос имеет четыре кнопки, по одной на каждый удар в одной такте музыки. Когда они включены, звучит нота. Когда инструмент воспроизводится, он перемещается по этим ударам и циклически воспроизводит такт.
Каждый голос также имеет локальные элементы управления, позволяющие манипулировать эффектами или параметрами, специфичными для каждого метода, который мы используем для создания этих голосов. Методы, которые мы используем, следующие:
| Название голоса | Метод | Соответствующая функция Web Audio API |
|---|---|---|
| "Sweep" | Осциллятор, периодическая волна | OscillatorNode, PeriodicWave |
| "Pulse" | Несколько осцилляторов | OscillatorNode |
| "Noise" | Случайный буфер шума, фильтр Biquad | AudioBuffer, AudioBufferSourceNode, BiquadFilterNode |
| "Dial up" | Загрузка звукового сэмпла для воспроизведения | BaseAudioContext/decodeAudioData, AudioBufferSourceNode |
Примечание: Мы не создавали этот инструмент для получения хорошего звучания, а для демонстрации кода. Эта демонстрация представляет собой очень упрощённую версию такого инструмента. Звуки основаны на звуке модема. Если вы не знаете, как звучит такое устройство, вы можете прослушать его здесь.
Создание контекста аудио
Как вы, должно быть, уже привыкли, каждое приложение Web Audio API начинается с контекста аудио:
const audioCtx = new AudioContext();
Звук «sweep» — осцилляторы, периодические волны и огибающие
Для звука «sweep», этого начального шума при наборе номера, мы создадим осциллятор для генерации звука.
Осциллятор OscillatorNode поставляется с базовыми формами волн — синусоидальной, прямоугольной, треугольной или пилообразной. Однако вместо использования стандартных волн по умолчанию мы создадим свои собственные, используя интерфейс PeriodicWave и значения, заданные в таблице волн. Мы можем использовать конструктор PeriodicWave() для использования этой пользовательской волны с осциллятором.
Периодическая волна
В первую очередь, мы создадим периодическую волну. Для этого нам нужно передать вещественные и мнимые значения в конструктор PeriodicWave():
const wave = new PeriodicWave(audioCtx, {
real: wavetable.real,
imag: wavetable.imag,
});
Осциллятор
Теперь мы можем создать OscillatorNode и установить его волну на созданную нами:
function playSweep(time) {
const osc = new OscillatorNode(audioCtx, {
frequency: 380,
type: "custom",
periodicWave: wave,
});
osc.connect(audioCtx.destination);
osc.start(time);
osc.stop(time + 1);
}
Мы передаём параметр времени в функцию, который мы будем использовать позже для планирования «sweep».
Управление амплитудой
Это отлично, но не было бы замечательно, если бы у нас была огибающая амплитуды? Давайте создадим её, чтобы мы привыкли к методам, необходимым для создания огибающей с помощью Web Audio API.
Предположим, наша огибающая имеет атаку и спад. Мы можем позволить пользователю управлять ими с помощью элементов управления диапазоном на интерфейсе:
<label for="attack">Attack</label> <input name="attack" id="attack" type="range" min="0" max="1" value="0.2" step="0.1" /> <label for="release">Release</label> <input name="release" id="release" type="range" min="0" max="1" value="0.5" step="0.1" />
Теперь мы можем создать переменные в JavaScript и заставить их изменяться при обновлении значений ввода:
let attackTime = 0.2;
const attackControl = document.querySelector("#attack");
attackControl.addEventListener(
"input",
(ev) => {
attackTime = parseInt(ev.target.value, 10);
},
false,
);
let releaseTime = 0.5;
const releaseControl = document.querySelector("#release");
releaseControl.addEventListener(
"input",
(ev) => {
releaseTime = parseInt(ev.target.value, 10);
},
false,
);
Функция playSweep()
Теперь мы можем расширить функцию playSweep(). Нам нужно добавить GainNode и подключить его через нашу аудио-графику для применения амплитудных вариаций к нашему звуку. У узла усиления есть одно свойство: gain, которое имеет тип AudioParam.
Это полезно — теперь мы можем начать использовать возможности методов аудио-параметров для значения усиления. Мы можем установить значение в определённое время или изменить его со временем с помощью таких методов, как AudioParam.linearRampToValueAtTime.
Как упоминалось выше, мы будем использовать метод linearRampToValueAtTime для нашей атаки и спада. Он принимает два параметра — значение, которое вы хотите установить для параметра (в данном случае — усиление), и когда вы хотите это сделать. В нашем случае когда контролируется нашими вводами. Таким образом, в примере ниже усиление увеличивается до 1 с линейной скоростью в течение времени, определяемого вводом диапазона атаки. Аналогично, для спада усиление устанавливается в 0 с линейной скоростью в течение времени, заданного вводом диапазона спада.
const sweepLength = 2;
function playSweep(time) {
const osc = new OscillatorNode(audioCtx, {
frequency: 380,
type: "custom",
periodicWave: wave,
});
const sweepEnv = new GainNode(audioCtx);
sweepEnv.gain.cancelScheduledValues(time);
sweepEnv.gain.setValueAtTime(0, time);
sweepEnv.gain.linearRampToValueAtTime(1, time + attackTime);
sweepEnv.gain.linearRampToValueAtTime(0, time + sweepLength - releaseTime);
osc.connect(sweepEnv).connect(audioCtx.destination);
osc.start(time);
osc.stop(time + sweepLength);
}
Звук «pulse» — модуляция низкочастотным осциллятором
Отлично, теперь у нас есть «sweep»! Перейдём к звуку «pulse». Мы можем добиться этого с помощью базового осциллятора, модулированного вторым осциллятором.
Исходный осциллятор
Мы настроим первый OscillatorNode так же, как и звук «sweep», за исключением того, что мы не будем использовать таблицу волн для задания специальной волны — мы будем использовать только стандартную sine волну:
const osc = new OscillatorNode(audioCtx, {
type: "sine",
frequency: pulseHz,
});
Теперь мы создадим GainNode, поскольку это значение gain , которое мы будем изменять с помощью второго, низкочастотного осциллятора:
const amp = new GainNode(audioCtx, {
value: 1,
});
Создание второго низкочастотного осциллятора
Теперь мы создадим второй — square — осциллятор (или импульс), чтобы изменять усиление нашей первой синусоиды:
const lfo = new OscillatorNode(audioCtx, {
type: "square",
frequency: 30,
});
Подключение графика
Ключ здесь — правильное подключение графика, а также запуск обоих осцилляторов:
lfo.connect(amp.gain); osc.connect(amp).connect(audioCtx.destination); lfo.start(); osc.start(time); osc.stop(time + pulseTime);
Примечание: Также мы не обязаны использовать стандартные типы волн для этих создаваемых нами осцилляторов — мы можем использовать таблицу волн и метод периодической волны, как и раньше. Есть множество возможностей всего лишь с минимумом узлов.
Элементы управления для звука "pulse"
Для элементов управления пользовательским интерфейсом давайте выведем обе частоты наших осцилляторов, позволяя управлять ими с помощью элементов управления диапазоном. Один будет изменять тон, а другой — то, как импульс модулирует первую волну:
<label for="hz">Hz</label> <input name="hz" id="hz" type="range" min="660" max="1320" value="880" step="1" /> <label for="lfo">LFO</label> <input name="lfo" id="lfo" type="range" min="20" max="40" value="30" step="1" />
Как и раньше, мы будем изменять параметры при изменении пользователем значений диапазонов.
let pulseHz = 880;
const hzControl = document.querySelector("#hz");
hzControl.addEventListener(
"input",
(ev) => {
pulseHz = parseInt(ev.target.value, 10);
},
false,
);
let lfoHz = 30;
const lfoControl = document.querySelector("#lfo");
lfoControl.addEventListener(
"input",
(ev) => {
lfoHz = parseInt(ev.target.value, 10);
},
false,
);
Функция playPulse()
Вот вся функция playPulse():
const pulseTime = 1;
function playPulse(time) {
const osc = new OscillatorNode(audioCtx, {
type: "sine",
frequency: pulseHz,
});
const amp = new GainNode(audioCtx, {
value: 1,
});
const lfo = new OscillatorNode(audioCtx, {
type: "square",
frequency: lfoHz,
});
lfo.connect(amp.gain);
osc.connect(amp).connect(audioCtx.destination);
lfo.start();
osc.start(time);
osc.stop(time + pulseTime);
}
Звук «noise» — буфер случайного шума с фильтром Biquad
Теперь нам нужно сделать шум! Все модемы имеют шум. Шум — это просто случайные числа в отношении аудиоданных, поэтому его относительно легко создать с помощью кода.
Создание буфера аудио
Нам нужно создать пустой контейнер для размещения этих чисел, но такой, который понимает Web Audio API. Именно здесь появляются объекты AudioBuffer. Вы можете загрузить файл и декодировать его в буфер (мы рассмотрим это позже в руководстве), или вы можете создать пустой буфер и заполнить его своими данными.
Для шума давайте сделаем последнее. Сначала нам нужно рассчитать размер нашего буфера, чтобы создать его. Для этого мы можем использовать свойство BaseAudioContext.sampleRate:
const bufferSize = audioCtx.sampleRate * noiseDuration;
// Create an empty buffer
const noiseBuffer = new AudioBuffer({
length: bufferSize,
sampleRate: audioCtx.sampleRate,
});
Теперь мы можем заполнить его случайными числами между -1 и 1:
// Fill the buffer with noise
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
Примечание: Почему от -1 до 1? При выводе звука в файл или на колонки нам нужно число, представляющее 0 дБ полной шкалы — числовой предел фиксированной точки медиа или ЦАП. В плавающей точке аудио 1 — удобное число для сопоставления с «полной шкалой» для математических операций над сигналами, поэтому генераторы осцилляторов, шума и другие источники звука обычно выдают биполярные сигналы в диапазоне от -1 до 1. Браузер будет ограничивать значения, выходящие за этот диапазон.
Создание источника буфера
Теперь у нас есть буфер аудио и мы заполнили его данными; нам нужен узел, который можно добавить в нашу диаграмму и который может использовать буфер в качестве источника. Для этого мы создадим AudioBufferSourceNode и передадим созданные нами данные:
// Create a buffer source for our created data
const noise = new AudioBufferSourceNode(audioCtx, {
buffer: noiseBuffer,
});
Когда мы подключим это через нашу диаграмму аудио и воспроизведем:
noise.connect(audioCtx.destination); noise.start();
Вы заметите, что он довольно шипящий или звонкий. Мы создали белый шум; это и должно быть. Наши значения распределены от -1 до 1, что означает, что у нас есть пики всех частот, которые на самом деле довольно драматичны и пронзительны. Мы могли изменить функцию, чтобы разбросать значения только от 0,5 до -0,5 или аналогично, чтобы убрать пики и уменьшить дискомфорт; однако где здесь веселье? Давайте пропустим созданный нами шум через фильтр.
Добавление фильтра биквадратной формы в смесь
Мы хотим чего-то в диапазоне от розового до бурого шума. Мы хотим срезать эти высокие частоты и, возможно, некоторые низкие. Давайте выберем полосовой фильтр биквадратной формы для этой задачи.
Примечание: Web Audio API поставляется с двумя типами узлов фильтра: BiquadFilterNode и IIRFilterNode. В большинстве случаев биквадратный фильтр будет достаточно хорош — он поставляется с различными типами, такими как полосовой, высокочастотный и полосовой. Однако, если вы хотите сделать что-то более индивидуальное, фильтр IIR может быть хорошим вариантом — см. Использование фильтров IIR для получения дополнительной информации.
Подключение этого аналогично тому, что мы видели ранее. Мы создаем BiquadFilterNode, настраиваем желаемые свойства и подключаем его через нашу диаграмму. Различные типы биквадратных фильтров имеют разные свойства — например, установка частоты на типе полосового пропуска регулирует среднюю частоту. Однако в случае низкочастотного фильтра это задаст верхнюю частоту.
// Filter the output
const bandpass = new BiquadFilterNode(audioCtx, {
type: "bandpass",
frequency: bandHz,
});
// Connect our graph
noise.connect(bandpass).connect(audioCtx.destination);
Управление шумом
В пользовательском интерфейсе мы предоставим продолжительность шума и частоту пропускания, позволяя пользователю настраивать их с помощью диапазонов и обработчиков событий так же, как в предыдущих разделах:
<label for="duration">Duration</label> <input name="duration" id="duration" type="range" min="0" max="2" value="1" step="0.1" /> <label for="band">Band</label> <input name="band" id="band" type="range" min="400" max="1200" value="1000" step="5" />
let noiseDuration = 1;
const durControl = document.querySelector("#duration");
durControl.addEventListener(
"input",
(ev) => {
noiseDuration = parseFloat(ev.target.value);
},
false,
);
let bandHz = 1000;
const bandControl = document.querySelector("#band");
bandControl.addEventListener(
"input",
(ev) => {
bandHz = parseInt(ev.target.value, 10);
},
false,
);
Функция playNoise()
Вот вся функция playNoise():
function playNoise(time) {
const bufferSize = audioCtx.sampleRate * noiseDuration; // set the time of the note
// Create an empty buffer
const noiseBuffer = new AudioBuffer({
length: bufferSize,
sampleRate: audioCtx.sampleRate,
});
// Fill the buffer with noise
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
// Create a buffer source for our created data
const noise = new AudioBufferSourceNode(audioCtx, {
buffer: noiseBuffer,
});
// Filter the output
const bandpass = new BiquadFilterNode(audioCtx, {
type: "bandpass",
frequency: bandHz,
});
// Connect our graph
noise.connect(bandpass).connect(audioCtx.destination);
noise.start(time);
}
"Dial-up" — загрузка звукового образца
Достаточно просто смоделировать звуки телефонного набора (DTMF), воспроизводя несколько осцилляторов вместе с помощью методов, которые мы уже использовали. Вместо этого в этом разделе мы загрузим файл образца, чтобы посмотреть, что в этом задействовано.
Загрузка образца
Мы хотим убедиться, что наш файл загружен и декодирован в буфер, прежде чем мы его будем использовать, поэтому давайте создадим функцию async, чтобы предоставить нам возможность сделать это:
async function getFile(audioContext, filepath) {
const response = await fetch(filepath);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return audioBuffer;
}
Затем мы можем использовать оператор await при вызове этой функции, что гарантирует, что мы можем выполнить последующий код только после завершения его выполнения.
Давайте создадим еще одну функцию async для настройки образца — мы можем объединить две асинхронные функции в красивом шаблоне обещаний, чтобы выполнить дополнительные действия после загрузки и буферизации этого файла:
async function setupSample() {
const filePath = "dtmf.mp3";
const sample = await getFile(audioCtx, filePath);
return sample;
}
Примечание: Вы легко можете изменить приведенную выше функцию так, чтобы она принимала массив файлов и перебирала их, чтобы загрузить более одного образца. Этот метод будет удобен для более сложных инструментов или игр.
Теперь мы можем использовать setupSample() следующим образом:
setupSample().then((sample) => {
// sample is our buffered file
// …
});
Когда образец готов к воспроизведению, программа настраивает пользовательский интерфейс, чтобы он был готов к работе.
Воспроизведение образца
Давайте создадим функцию playSample() аналогично тому, как мы делали это с другими звуками. На этот раз мы создадим AudioBufferSourceNode, поместим данные буфера, которые мы загрузили и декодировали, в него и воспроизведем его:
function playSample(audioContext, audioBuffer, time) {
const sampleSource = new AudioBufferSourceNode(audioContext, {
buffer: audioBuffer,
playbackRate,
});
sampleSource.connect(audioContext.destination);
sampleSource.start(time);
return sampleSource;
}
Примечание: Мы можем вызвать stop() на AudioBufferSourceNode, однако это произойдет автоматически, когда образец закончит воспроизведение.
Управление диалоговым окном
AudioBufferSourceNode поставляется со свойством playbackRate. Давайте сделаем это доступным в нашем пользовательском интерфейсе, чтобы мы могли ускорять и замедлять образец. Сделаем это так же, как и раньше:
<label for="rate">Rate</label> <input name="rate" id="rate" type="range" min="0.1" max="2" value="1" step="0.1" />
let playbackRate = 1;
const rateControl = document.querySelector("#rate");
rateControl.addEventListener(
"input",
(ev) => {
playbackRate = parseInt(ev.target.value, 10);
},
false,
);
Функция playSample()
Затем мы добавим строку для обновления свойства playbackRate в нашу функцию playSample(). Окончательный вариант выглядит так:
function playSample(audioContext, audioBuffer, time) {
const sampleSource = new AudioBufferSourceNode(audioCtx, {
buffer: audioBuffer,
playbackRate,
});
sampleSource.connect(audioContext.destination);
sampleSource.start(time);
return sampleSource;
}
Примечание: Звуковой файл был получен с soundbible.com.
Воспроизведение аудио во времени
Распространённая проблема в приложениях цифрового аудио — заставить звуки играть во времени, чтобы ритм оставался постоянным, и ничего не сбивалось.
Мы могли бы запланировать воспроизведение наших голосов внутри цикла for; однако главная проблема с этим — обновление во время воспроизведения, а мы уже реализовали элементы управления пользовательским интерфейсом для этого. Кроме того, было бы неплохо рассмотреть возможность управления темпом инструмента в целом. Лучший способ заставить наши голоса играть в такт — создать систему планирования, в которой мы будем предвосхищать время воспроизведения нот и помещать их в очередь. Мы можем начать их в точное время с помощью свойства currentTime и также учитывать любые изменения.
Примечание: Это сильно упрощенная версия статьи Крис Уилсон «A Tale Of Two Clocks (2013)», которая более подробно описывает этот метод. Нет смысла повторять всё здесь, но мы настоятельно рекомендуем прочитать эту статью и использовать этот метод. Большая часть кода здесь взята из его примера метронома, который он упоминает в статье.
Давайте начнём с установки значения по умолчанию BPM (ударов в минуту), которое также будет настраиваемым пользователем через — вы угадали — ещё один диапазон ввода.
let tempo = 60.0;
const bpmControl = document.querySelector("#bpm");
bpmControl.addEventListener(
"input",
(ev) => {
tempo = parseInt(ev.target.value, 10);
},
false,
);
Затем мы создадим переменные, чтобы определить, как далеко вперёд мы хотим смотреть и как далеко вперёд хотим запланировать:
const lookahead = 25.0; // How frequently to call scheduling function (in milliseconds) const scheduleAheadTime = 0.1; // How far ahead to schedule audio (sec)
Давайте создадим функцию, которая перемещает ноту вперёд на один такт и возвращается к первой, когда она достигает четвёртой (последней):
let currentNote = 0;
let nextNoteTime = 0.0; // when the next note is due.
function nextNote() {
const secondsPerBeat = 60.0 / tempo;
nextNoteTime += secondsPerBeat; // Add beat length to last beat time
// Advance the beat number, wrap to zero when reaching 4
currentNote = (currentNote + 1) % 4;
}
Мы хотим создать справочную очередь для нот, которые должны быть воспроизведены, и функциональность для их воспроизведения с использованием функций, которые мы создали ранее:
const notesInQueue = [];
function scheduleNote(beatNumber, time) {
// Push the note on the queue, even if we're not playing.
notesInQueue.push({ note: beatNumber, time });
if (pads[0].querySelectorAll("input")[beatNumber].checked) {
playSweep(time);
}
if (pads[1].querySelectorAll("input")[beatNumber].checked) {
playPulse(time);
}
if (pads[2].querySelectorAll("input")[beatNumber].checked) {
playNoise(time);
}
if (pads[3].querySelectorAll("input")[beatNumber].checked) {
playSample(audioCtx, dtmf, time);
}
}
Здесь мы смотрим на текущее время и сравниваем его со временем для следующей ноты; когда два значения совпадают, вызываются две предыдущие функции.
Объекты AudioContext имеют свойство currentTime, которое позволяет получить количество секунд после первоначального создания контекста. Мы будем использовать его для временных параметров в нашем секвенсоре шагов. Он чрезвычайно точен, возвращая значение с плавающей точкой с точностью до примерно 15 десятичных знаков.
let timerID;
function scheduler() {
// While there are notes that will need to play before the next interval,
// schedule them and advance the pointer.
while (nextNoteTime < audioCtx.currentTime + scheduleAheadTime) {
scheduleNote(currentNote, nextNoteTime);
nextNote();
}
timerID = setTimeout(scheduler, lookahead);
}
Нам также нужна функция draw() для обновления пользовательского интерфейса, чтобы мы могли видеть, как прогрессирует такт.
let lastNoteDrawn = 3;
function draw() {
let drawNote = lastNoteDrawn;
const currentTime = audioCtx.currentTime;
while (notesInQueue.length && notesInQueue[0].time < currentTime) {
drawNote = notesInQueue[0].note;
notesInQueue.shift(); // Remove note from queue
}
// We only need to draw if the note has moved.
if (lastNoteDrawn !== drawNote) {
pads.forEach((pad) => {
pad.children[lastNoteDrawn * 2].style.borderColor = "var(--black)";
pad.children[drawNote * 2].style.borderColor = "var(--yellow)";
});
lastNoteDrawn = drawNote;
}
// Set up to draw again
requestAnimationFrame(draw);
}
Сборка всего воедино
Теперь всё, что нам осталось сделать, — это убедиться, что мы загрузили образец, прежде чем мы сможем воспроизвести инструмент. Мы добавим экран загрузки, который исчезнет, когда файл будет загружен и декодирован. Затем мы можем позволить планировщику начать работу, используя событие щелчка по кнопке воспроизведения.
// When the sample has loaded, allow play
const loadingEl = document.querySelector(".loading");
const playButton = document.querySelector("#playBtn");
let isPlaying = false;
setupSample().then((sample) => {
loadingEl.style.display = "none";
dtmf = sample; // to be used in our playSample function
playButton.addEventListener("click", (ev) => {
isPlaying = !isPlaying;
if (isPlaying) {
// Start playing
// Check if context is in suspended state (autoplay policy)
if (audioCtx.state === "suspended") {
audioCtx.resume();
}
currentNote = 0;
nextNoteTime = audioCtx.currentTime;
scheduler(); // kick off scheduling
requestAnimationFrame(draw); // start the drawing loop.
ev.target.dataset.playing = "true";
} else {
clearTimeout(timerID);
ev.target.dataset.playing = "false";
}
});
});
Заключение
Теперь у нас есть инструмент внутри браузера! Продолжайте играть и экспериментировать — вы можете расширить любой из этих методов, чтобы создать что-то гораздо более сложное.
© 2005–2024 MDN contributors.
Licensed under the Creative Commons Attribution-ShareAlike License v2.5 or later.
https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Advanced_techniques