高级技术:创建和编排音频

在本教程中,我们将介绍声音的创建和修改,以及时间安排和调度。我们将介绍采样加载、包络、滤波器、波表和频率调制。如果你熟悉这些术语,并且正在寻找关于它们在 Web Audio API 中应用入门,那么你来对地方了。

注意:你可以在 GitHub 上的 MDN webaudio-examples 仓库的 step-sequencer 子目录中找到以下演示的源代码。你也可以查看在线演示

演示

我们将研究一个非常简单的步进音序器

A sound sequencer application featuring play and BPM master controls and 4 different voices with controls for each.

实际上,使用库来完成这个操作会更容易——Web Audio API 的构建就是为了在其基础上进行构建。如果你即将开始构建更复杂的东西,tone.js 将是一个绝佳的起点。然而,我们希望通过一个学习练习,从基本原理出发,演示如何创建这样一个演示。

该界面由主控器组成,它允许我们播放/停止音序器,并调整 BPM(每分钟节拍数)以加快或减慢“音乐”。

可以播放四种不同的声音或音色。每个音色有四个按钮,一个用于一个音乐小节中的每个节拍。当它们启用时,音符会发声。当乐器演奏时,它会在这一组节拍中移动并循环该小节。

每个音色还具有本地控制,允许你操纵我们用来创建这些音色的每种特定技术的效果或参数。我们使用的方法是

音色名称 技术 相关的 Web Audio API 功能
“扫描” 振荡器,周期波 OscillatorNodePeriodicWave
“脉冲” 多个振荡器 OscillatorNode
“噪声” 随机噪声缓冲区,双二阶滤波器 AudioBufferAudioBufferSourceNodeBiquadFilterNode
“拨号” 加载要播放的声音样本 BaseAudioContext/decodeAudioDataAudioBufferSourceNode

注意:我们创建这个乐器不是为了好听,而是为了提供演示代码。这个演示代表了这种乐器的一个非常简化的版本。这些声音基于拨号调制解调器。如果你不知道这种设备的声音,你可以在这里听一下

创建音频上下文

正如你现在应该习惯的那样,每个 Web Audio API 应用程序都从一个音频上下文开始

js
const audioCtx = new AudioContext();

“扫描”——振荡器、周期波和包络

对于我们称之为“扫描”的声音,也就是你拨号时听到的第一个声音,我们将创建一个振荡器来生成声音。

OscillatorNode 附带了开箱即用的基本波形——正弦波、方波、三角波或锯齿波。然而,我们不使用默认的标准波形,而是使用 PeriodicWave 接口和波表中的值创建自己的波形。我们可以使用 PeriodicWave() 构造函数将此自定义波形与振荡器一起使用。

周期波

首先,我们将创建我们的周期波。为此,我们需要将实部和虚部值传递给 PeriodicWave() 构造函数

js
const wave = new PeriodicWave(audioCtx, {
  real: wavetable.real,
  imag: wavetable.imag,
});

注意:在我们的示例中,波表保存在一个单独的 JavaScript 文件(wavetable.js)中,因为值实在太多了。我们从 波表仓库 中获取了它,该仓库位于 Google Chrome Labs 的 Web Audio API 示例中。

振荡器

现在我们可以创建一个 OscillatorNode 并将其波形设置为我们创建的波形

js
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);
}

我们在这里向函数传递一个时间参数,稍后我们将用它来调度扫描。

控制幅度

这很好,但是如果我们有一个幅度包络来配合它,是不是会更好呢?让我们创建一个,这样我们就能习惯使用 Web Audio API 创建包络所需的方法。

假设我们的包络有起音和衰减。我们可以让用户使用界面上的 范围输入 来控制这些参数

html
<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 中创建一些变量,并在输入值更新时更改它们

js
let attackTime = 0.2;
const attackControl = document.querySelector("#attack");
attackControl.addEventListener("input", (ev) => {
  attackTime = parseInt(ev.target.value, 10);
});

let releaseTime = 0.5;
const releaseControl = document.querySelector("#release");
releaseControl.addEventListener("input", (ev) => {
  releaseTime = parseInt(ev.target.value, 10);
});

最终的 playSweep() 函数

现在我们可以扩展我们的 playSweep() 函数。我们需要添加一个 GainNode 并将其连接到我们的音频图中,以对我们的声音应用幅度变化。增益节点有一个属性:gain,它是 AudioParam 类型。

这很有用——现在我们可以开始利用增益值上的音频参数方法的力量。我们可以设置某个时间的值,或者通过诸如 AudioParam.linearRampToValueAtTime 之类的方法随时间改变它。

如上所述,我们将使用 linearRampToValueAtTime 方法进行起音和衰减。它需要两个参数——你想要将正在改变的参数设置到的值(在本例中为增益),以及你何时希望这样做。在我们的例子中,何时由我们的输入控制。因此,在下面的示例中,增益在起音范围输入定义的时间内以线性速率增加到 1。同样,对于我们的衰减,增益在衰减输入设置的时间内以线性速率设置为 0。

js
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);
}

“脉冲”——低频振荡器调制

太棒了,现在我们得到了扫描声!让我们继续看看那个美妙的脉冲声。我们可以通过一个基本的振荡器,用第二个振荡器进行调制来实现这一点。

初始振荡器

我们将设置我们的第一个 OscillatorNode,与我们的扫描声相同,只是我们不会使用波表来设置定制波形——我们只使用默认的 sine 波形

js
const osc = new OscillatorNode(audioCtx, {
  type: "sine",
  frequency: pulseHz,
});

现在我们将创建一个 GainNode,因为我们将用第二个低频振荡器来振荡 gain

js
const amp = new GainNode(audioCtx, {
  value: 1,
});

创建第二个低频振荡器

现在我们将创建第二个——方波——波(或脉冲)振荡器,以改变我们第一个正弦波的放大倍数

js
const lfo = new OscillatorNode(audioCtx, {
  type: "square",
  frequency: 30,
});

连接图

这里的关键是正确连接图表并启动两个振荡器

js
lfo.connect(amp.gain);
osc.connect(amp).connect(audioCtx.destination);
lfo.start();
osc.start(time);
osc.stop(time + pulseTime);

注意:我们也不必为这两个振荡器使用默认的波形类型——我们可以像以前一样使用波表和周期波方法。只需最少的节点,就有无数种可能性。

脉冲用户控制

对于 UI 控制,我们将暴露振荡器的两个频率,允许通过范围输入进行控制。一个将改变音调,另一个将改变脉冲如何调制第一个波

html
<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" />

与之前一样,当用户更改范围值时,我们将改变参数。

js
let pulseHz = 880;
const hzControl = document.querySelector("#hz");
hzControl.addEventListener("input", (ev) => {
  pulseHz = parseInt(ev.target.value, 10);
});

let lfoHz = 30;
const lfoControl = document.querySelector("#lfo");
lfoControl.addEventListener("input", (ev) => {
  lfoHz = parseInt(ev.target.value, 10);
});

最终的 playPulse() 函数

这是完整的 playPulse() 函数

js
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);
}

“噪声”——带双二阶滤波器的随机噪声缓冲区

现在我们需要制造一些噪音!所有调制解调器都有噪音。就音频数据而言,噪音只是随机数,因此用代码创建噪音相对简单。

创建音频缓冲区

然而,我们需要创建一个空容器来放置这些数字,一个 Web Audio API 可以理解的容器。这就是 AudioBuffer 对象发挥作用的地方。你可以获取一个文件并将其解码成一个缓冲区(我们将在本教程后面介绍),或者你可以创建一个空缓冲区并用你的数据填充它。

对于噪音,我们选择后者。我们首先需要计算缓冲区的大小来创建它。我们可以使用 BaseAudioContext.sampleRate 属性来完成此操作

js
const bufferSize = audioCtx.sampleRate * noiseDuration;
// Create an empty buffer
const noiseBuffer = new AudioBuffer({
  length: bufferSize,
  sampleRate: audioCtx.sampleRate,
});

现在我们可以用介于 -1 和 1 之间的随机数填充它

js
// 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 dB 满量程的数字——固定点媒体或 DAC 的数值限制。在浮点音频中,1 是一个方便的数字,可以映射到信号的“满量程”以进行数学运算,因此振荡器、噪声发生器和其他声源通常输出 -1 到 1 范围内的双极性信号。浏览器将限制超出此范围的值。

创建缓冲区源

现在我们有了音频缓冲区并用数据填充了它;我们需要一个节点添加到我们的图中,该节点可以使用该缓冲区作为源。我们将为此创建一个 AudioBufferSourceNode,并传入我们创建的数据

js
// Create a buffer source for our created data
const noise = new AudioBufferSourceNode(audioCtx, {
  buffer: noiseBuffer,
});

当我们通过我们的音频图连接它并播放它时

js
noise.connect(audioCtx.destination);
noise.start();

你会注意到它非常嘶哑或刺耳。我们创建的是白噪声;它就应该是这样。我们的值分布在 -1 到 1 之间,这意味着我们有所有频率的峰值,这些峰值实际上非常剧烈和刺耳。我们可以修改函数,使其仅将值分布在 0.5 到 -0.5 或类似范围内,以消除峰值并减少不适感;然而,这样做有什么乐趣呢?让我们通过一个滤波器来处理我们创建的噪声。

在混音中添加双二阶滤波器

我们想要的是粉红噪声或棕噪声范围内的东西。我们想要切掉那些高频,可能还有一些低频。让我们选择一个带通双二阶滤波器来完成这项任务。

注意:Web Audio API 提供了两种类型的滤波器节点:BiquadFilterNodeIIRFilterNode。在大多数情况下,双二阶滤波器就足够了——它有低通、高通和带通等不同类型。但是,如果你想做一些更定制化的事情,IIR 滤波器可能是一个不错的选择——有关更多信息,请参阅使用 IIR 滤波器

连接起来和我们之前看到的一样。我们创建 BiquadFilterNode,配置我们想要的属性,然后通过我们的图将其连接起来。不同类型的双二阶滤波器具有不同的属性——例如,在带通类型上设置频率会调整中频。然而,在低通类型上,它会设置最高频率。

js
// Filter the output
const bandpass = new BiquadFilterNode(audioCtx, {
  type: "bandpass",
  frequency: bandHz,
});

// Connect our graph
noise.connect(bandpass).connect(audioCtx.destination);

噪音用户控制

在 UI 上,我们将公开噪声持续时间和我们想要带通的频率,允许用户通过范围输入和事件处理程序来调整它们,就像前面的部分一样

html
<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" />
js
let noiseDuration = 1;
const durControl = document.querySelector("#duration");
durControl.addEventListener("input", (ev) => {
  noiseDuration = parseFloat(ev.target.value);
});

let bandHz = 1000;
const bandControl = document.querySelector("#band");
bandControl.addEventListener("input", (ev) => {
  bandHz = parseInt(ev.target.value, 10);
});

最终的 playNoise() 函数

这是完整的 playNoise() 函数

js
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);
}

“拨号”——加载声音样本

通过我们已经使用过的方法,将几个振荡器一起播放,模拟电话拨号 (DTMF) 声音是相当简单的。相反,我们将在本节中加载一个示例文件,以了解其中涉及的内容。

加载样本

我们想确保我们的文件在被使用之前已经加载并解码到缓冲区中,所以让我们创建一个 async 函数来完成这个任务

js
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 函数来设置样本——我们可以将这两个异步函数以一种漂亮的 Promise 模式组合起来,以便在该文件加载和缓冲完成后执行进一步的操作

js
async function setupSample() {
  const filePath = "dtmf.mp3";
  const sample = await getFile(audioCtx, filePath);
  return sample;
}

注意:你可以轻松修改上面的函数,使其接受一个文件数组并循环它们以加载多个样本。这种技术对于更复杂的乐器或游戏来说会很方便。

我们现在可以这样使用 setupSample()

js
setupSample().then((sample) => {
  // sample is our buffered file
  // …
});

当样本准备播放时,程序会设置 UI,使其准备就绪。

播放样本

让我们创建一个 playSample() 函数,类似于我们处理其他声音的方式。这次我们将创建一个 AudioBufferSourceNode,将我们获取和解码的缓冲区数据放入其中,然后播放它

js
function playSample(audioContext, audioBuffer, time) {
  const sampleSource = new AudioBufferSourceNode(audioContext, {
    buffer: audioBuffer,
    playbackRate,
  });
  sampleSource.connect(audioContext.destination);
  sampleSource.start(time);
  return sampleSource;
}

注意:我们可以在 AudioBufferSourceNode 上调用 stop(),但是当样本播放结束后,它会自动停止。

拨号用户控制

AudioBufferSourceNode 带有一个 playbackRate 属性。让我们将它暴露给我们的 UI,这样我们就可以加快和减慢样本。我们将以与之前类似的方式来完成

html
<label for="rate">Rate</label>
<input
  name="rate"
  id="rate"
  type="range"
  min="0.1"
  max="2"
  value="1"
  step="0.1" />
js
let playbackRate = 1;
const rateControl = document.querySelector("#rate");
rateControl.addEventListener("input", (ev) => {
  playbackRate = parseInt(ev.target.value, 10);
});

最终的 playSample() 函数

然后我们将在 playSample() 函数中添加一行来更新 playbackRate 属性。最终版本如下所示

js
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 循环中安排我们的声音播放;然而,最大的问题是它在播放时进行更新,而我们已经实现了 UI 控件来完成此操作。此外,考虑一个乐器范围的 BPM 控制会非常好。让我们的声音按节拍播放的最佳方法是创建一个调度系统,通过该系统,我们提前查看音符何时播放并将它们推入队列。我们可以使用 currentTime 属性在精确的时间启动它们,并考虑任何更改。

注意:这比 Chris Wilson 的《两只时钟的故事》(2013) 文章简化了很多,后者更详细地介绍了这种方法。这里没有必要重复所有内容,但我们强烈建议阅读这篇文章并使用这种方法。这里的许多代码都取自他的 节拍器示例,他在文章中引用了该示例。

让我们从设置我们的默认 BPM(每分钟节拍数)开始,它也将通过——你猜对了——另一个范围输入由用户控制。

js
let tempo = 60.0;
const bpmControl = document.querySelector("#bpm");

bpmControl.addEventListener("input", (ev) => {
  tempo = parseInt(ev.target.value, 10);
});

然后我们将创建变量来定义我们想要提前查看多远以及我们想要提前调度多远

js
const lookahead = 25.0; // How frequently to call scheduling function (in milliseconds)
const scheduleAheadTime = 0.1; // How far ahead to schedule audio (sec)

让我们创建一个函数,它将音符向前移动一个节拍,当它到达第四个(最后一个)节拍时,它将循环回到第一个节拍

js
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;
}

我们希望为要播放的音符创建一个参考队列,并使用我们之前创建的函数播放它们的功能

js
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 位小数的浮点值。

js
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() 函数来更新 UI,这样我们就可以看到节拍何时前进。

js
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);
}

整合起来

现在剩下的就是确保我们已经加载了样本,然后才能演奏乐器。我们将添加一个加载屏幕,当文件被获取和解码后它就会消失。然后我们可以允许调度器使用播放按钮点击事件开始工作。

js
// 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";
    }
  });
});

总结

我们现在在浏览器中拥有了一台乐器!继续演奏和实验——你可以扩展这些技术中的任何一个,以创造出更精致的东西。