高级技巧:创建和排序音频

在本教程中,我们将介绍声音创建和修改,以及时间和调度。我们将介绍样本加载、包络、滤波器、波表和频率调制。如果您熟悉这些术语并希望了解如何在 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 实验室的 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);
  },
  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,持续时间设置为释放输入的值。

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

创建第二个低频振荡器

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

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);
  },
  false,
);

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

最终的 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);
  },
  false,
);

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

最终的 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);
  },
  false,
);

最终的 playSample() 函数

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

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);
  },
  false,
);

然后,我们将创建变量来定义我们想要查看多远以及我们想要安排多远。

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

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

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

总结

我们现在在浏览器中拥有了一个乐器!继续播放和试验——您可以扩展任何这些技术来创建更复杂的东西。