在本教程中,我们将介绍声音的创建和修改,以及定时和调度。我们将介绍样本加载、包络、滤波器、波表和调频。如果你熟悉这些术语,并且正在寻找 Web Audio API 应用程序的介绍,那么你就来对地方了。
注意:
你可以在GitHub
的 webaudio-examples 仓库的 step sequencer 子目录中找到下面演示的源代码。你也可以查看 在线示例。
示例
我们来看一个非常简单的步序器:
在实践中,借助一个库来做这件事会更加容易 —— Web Audio API 本就是为了能在其基础上进行构建而设计的。如果你打算着手构建更复杂的应用,tone.js 会是一个非常不错的开始。不过,我们想从基本原理出发,来演示如何创建一个这样的示例。
该界面包含主控制部件,可以通过它们启动 / 停止音序器,并调节 BPM
(每分钟节拍数)来加快或减慢 “音乐” 的节奏。
该示例可以播放四种不同的声音。每个声音都有四个按钮,对应音乐的一个小节中的每个节拍。当它们被激活时,相应的音符就会响起。当乐器演奏时,会依次奏响这一组节拍,并循环这个小节。
每个声音都有本地控件部件,能让你操纵每种用于创建这些声音的技术所特有的效果或参数。我们使用的方法是:
声音的名称 | 技术 | 关联的 Web Audio API 特性 |
---|---|---|
"Sweep" | 振荡器,周期波 | OscillatorNode , PeriodicWave |
"Pulse" | 多个振荡器 | OscillatorNode |
"Noise" | 随机噪声缓冲器、双二阶滤波器 | 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,
});
注意:
在我们的示例中,由于波表包含众多数值,所以它被存放在一个单独的JavaScript
文件(wavetable.js
)里。它是从 Web Audio API examples from Google Chrome Labs的 波表仓库 中获取的。
振荡器
现在我们可以创建一个 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);
}
我们将一个时间参数传递给这个函数,稍后我们会使用这个参数来进行声音调度。
控制振幅
很棒,不过要是我们能有一个与之配套的振幅包络岂不是更好?咱们来创建一个简单的振幅包络吧,这样我们就能熟悉使用 Web Audio API 创建包络所需的方法了。
假设我们的包络有起音(attack
)和释音(release
)这两个阶段。我们可以允许用户通过界面上的范围输入标签来控制它们:
<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 = parseFloat(ev.target.value);
},
false,
);
let releaseTime = 0.5;
const releaseControl = document.querySelector("#release");
releaseControl.addEventListener(
"input",
(ev) => {
releaseTime = parseFloat(ev.target.value);
},
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”(前文提到的拨号时听到的那种嗞嗞声)!咱们接着往下看,来瞧瞧那个好听的脉冲声吧。我们可以通过一个基本的振荡器,并利用第二个振荡器对其进行调制来实现这种声音。
初始化振荡器
我们将采用与设置 “Sweep” 相同的方式来设置我们的第一个 OscillatorNode
,不过我们不会使用波表来设置自定义波形 —— 我们只会使用默认的正弦(sine
)波:
const osc = new OscillatorNode(audioCtx, {
type: "sine",
frequency: pulseHz,
});
现在我们要创建一个 GainNode
,因为它是我们将用第二个低频振荡器振荡的增益值:
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);
注意:
对于我们正在创建的这两个振荡器,并非一定要使用默认的波形类型 —— 我们可以像之前那样使用波表以及周期波的方法。仅利用最少数量的节点,就存在众多的可能性。
用户控制
对于 UI
控件,我们将对外公开两个振荡器的频率,允许通过范围输入控件来对它们进行控制。其中一个频率会改变音调,另一个则会改变脉冲对第一个波形进行调制的方式。
<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 = parseFloat(ev.target.value);
},
false,
);
let lfoHz = 30;
const lfoControl = document.querySelector("#lfo");
lfoControl.addEventListener(
"input",
(ev) => {
lfoHz = parseFloat(ev.target.value);
},
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" —— 带有双二阶滤波器的随机噪声缓冲器
现在我们需要制造一些噪声!所有的调制解调器都会有噪声。就音频数据而言,噪声其实就是随机数,因此,用代码来生成噪声是一件相对简单的事。
创建音频缓冲区
我们需要创建一个空的容器来存放这些数字,而且这个容器得是 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
之间的随机数填充它:
const data = noiseBuffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = Math.random() * 2 - 1;
}
注意:
为什么是-1
到1
呢?当将声音输出到文件或扬声器时,我们需要一个代表满量程0
分贝的数值 —— 也就是定点媒体或数模转换器(DAC
)的数值极限。在浮点音频中,对于信号的数学运算而言,1
是一个便于映射到 “满量程” 的数值,所以振荡器、噪声发生器以及其他声源通常会输出范围在-1
到1
之间的双极性信号。浏览器会将超出此范围的值限制在这个范围内。
创建音频缓冲源
现在我们已经有了音频缓冲区,并且已经用数据填充了它;我们需要一个节点添加到我们的音频图中,该节点能够将这个缓冲区用作音频源。为此,我们将创建一个音频缓冲源节点(AudioBufferSourceNode
),并传入我们已经创建好的数据:
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滤波器 一文。
将它们连接起来的方式和我们之前看到的一样。我们创建双二阶滤波器节点(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
函数来设置示例 —— 我们可以将这两个异步函数以一种美妙的 Promise
模式结合起来,以便在这个文件加载并缓冲完成后执行更多操作:
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;
}
注意:
我们可以在AudioBufferSourceNode
上调用stop()
,但实际上,当示例播放完毕时,该动作将自动执行。
用户控制
音频缓冲源节点(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 = parseFloat(ev.target.value);
},
false,
);
最后的 playSample()
函数
然后,我们将添加一行代码,以便在 playSample()
函数中更新播放速率(playbackRate
)属性,最终版本看起来是这样的:
function playSample(audioContext, audioBuffer, time) {
const sampleSource = new AudioBufferSourceNode(audioCtx, {
buffer: audioBuffer,
playbackRate,
});
sampleSource.connect(audioContext.destination);
sampleSource.start(time);
return sampleSource;
}
按时播放音频
数字音频应用程序中一个常见的问题是如何让声音按时播放,以保证节拍保持一致,并且不会出现节奏错乱的情况。
我们可以在一个 for
循环内调度要播放的声音;然而,最大的问题是在播放时进行更新,我们已经实现了UI控件来实现这一点。此外,考虑一个适用于整个乐器的 BPM
控制真的很不错。让声音按节拍播放的最佳方式是创建一个调度系统,通过该系统,我们可以提前查看音符何时播放,并将其推入队列。我们可以利用 currentTime
属性在精确的时间点启动它们,同时也能考虑到任何变化情况。
注意:
这是 Chris Wilson 的 《两个钟的故事》(2013) 文章的精简版,该文章更详细地介绍了这种方法。这里没有必要重复这一切,但我们强烈建议阅读这篇文章并使用这种方法。这里的大部分代码都来自他在文章中引用的 节拍器示例。
让我们从设置默认 BPM
(每分钟节拍数)开始,用户也可以通过另一个范围输入来控制 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";
}
});
});
总结
我们现在在浏览器里有了一个乐器!继续演奏并进行试验吧 —— 你也可以拓展上述任何一种技术,来创作出更精妙复杂的应用。
评论0
暂时没有评论