高階技術:建立和編排音訊

在本教程中,我們將介紹聲音的建立和修改,以及時間安排和排程。我們將介紹取樣載入、包絡、濾波器、波表和頻率調製。如果你熟悉這些術語,並且正在尋找關於它們在 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";
    }
  });
});

總結

我們現在在瀏覽器中擁有了一臺樂器!繼續演奏和實驗——你可以擴充套件這些技術中的任何一個,以創造出更精緻的東西。