Web Audio API 第2章 完美的播放時機控制

池中物王二狗發表於2024-03-23

Web Audio API 第2章 完美的播放時機控制

相較於

低延時對於遊戲或互動式應用來說非常重要,因為互動操作時要快速響應給使用者的聽覺。如果響應的不及時,使用者就會察覺到延時,這種體驗相當不好。

在實踐中,由於人類聽覺的不完美,延遲的餘地可達20毫秒左右,但具體延遲多少取決於許多因素。精確的可控時間使得能夠在特定時間安排事件。這對於指令碼場景和音樂應用來說非常重要

時間模型

其中一個重要的點是,音訊上下文 AudioContext 提供了一致的計時模型和時間的幀率。重要的是此模型有別於我們常用的 Javascript 指令碼所用的計時器 如 setTimeout, setInterval, new Date()。也有別於 window.performance.now() 提供的效能分析時鐘

在 Web Audio API 音訊上下文系統座標中所有你打交道的的絕對時間單位是秒而不是毫秒。當前時間可透過音訊上下文的 currentTime 屬性獲取。同樣它也是秒為單位,時間儲存為高精度的浮點數儲存。

精確的播放與復播

在遊戲或其它需要精確時間控制的應用中 start() 方法用於控制安排精確的播放。為了保證正確執行,需要確保緩衝已提前載入。如果沒有提前緩衝, 為了 Web Audio API 能解碼,需要等等瀏覽器完成載入音訊檔案。如果沒有載入或解碼完畢就去播放或精準的控制播放那麼很有可能會失敗。

start() 方法的第一個引數可用於聲音精確定位控制在哪裡開始播放。此引數是 AudioContext 音訊上下文座標系內的 currentTime, 如果傳參小於 currentTIme, 則它會立即播放。因為 start(0) 就是直接開始播放的意思 ,如果想要控制延遲 5 秒後播放,則需要 start(context.currentTime + 5)。

聲音的緩衝也可以從特定位置開始播放,使用 start() 方法的第二個引數控制,第三個可選引數用於時長特殊限制。舉個例子,如果我們想暫停後在暫停的位置重新開始恢復播放,我們需要實現記錄聲音在當前 session 播放了多久並追蹤偏移量用於後面恢復播放

start 方法即 AudioBufferSourceNode.start([when][, offset][, duration]);

可參考 https://developer.mozilla.org/zh-CN/docs/Web/API/AudioBufferSourceNode/start

// 假定 context 是網頁 audio context 上下文
var startOffset = 0; 
var startTime = 0;
function pause() {
  source.stop();
  // 計算距離上次播放暫停時過去了多久
  startOffset += context.currentTime - startTime;
}

一旦源節點播放完畢,它無法再重播。為了重播底層的緩衝區,你需要新建一個新的源節點(AudioBufferSourceNode) 並呼叫 start():

function play() {
  startTime = context.currentTime;
  var source = context.createBufferSource();
  source.buffer = this.buffer;
  source.loop = true;
  source.connect(context.destination);
  // 開始播放,但確保我們限定在 buffer 緩衝區的範圍內 
  source.start(0, startOffset % buffer.duration);
  
}

儘管重新新建一個源節點看起來非常的低效,牢記,這種模式下源節點被著重最佳化過了。請記住,如果你在處理 AudioBuffer, 播放同一個聲音你無需重新請求資源。當 AudioBuffer 緩衝區與播放功能被分拆後,就可以實現同一時間內播放不同版本的緩衝區。如果你感覺需要重複這樣的方式呼叫 ,那麼你可以在將它封裝成一個簡單的方法函式比如 playSound(buffer) 就像在第一章程式碼片斷中有提到過的。

以上程式碼實現 demo 可參考 examples/ch02/index1.html

規劃精確的節奏

Web Audio API 允許開發人員在精確地規劃播放。為了演示,讓我們先設定一個簡單的節奏軌道。也許最簡單的要屬廣為人知的 爵士鼓模式(drumkit pattern) 如圖 2-1,hihat每8個音符演奏一次,kick和snare在四分音符上交替演奏

注: kick 是底鼓,就是架子鼓組裡面最下面最大的那個鼓,聲音是咚咚咚的;

hihat是鼓手左邊兩片合在一起的鑔片 閉鑔 是次次次的聲音,開鑔是擦擦擦的聲音

snare 是離鼓手最*的*放的小鼓,叫軍鼓,打上去是咔咔咔的聲音;

image

假定我們已搞定了 kick, snare,和 hihat 緩衝,那麼程式碼實現就比較簡單:

for (var bar = 0; bar < 2; bar++) {
  var time = startTime + bar * 8 * eighthNoteTime; 
  // Play the bass (kick) drum on beats 1, 5 
  playSound(kick, time);
  playSound(kick, time + 4 * eighthNoteTime);
  // Play the snare drum on beats 3, 7
  playSound(snare, time + 2 * eighthNoteTime);
  playSound(snare, time + 6 * eighthNoteTime);
  // Play the hihat every eighth note.
  for (var i = 0; i < 8; ++i) {
    playSound(hihat, time + i * eighthNoteTime);
  } 
}

程式碼中對時間進行硬編碼是不明智的。所以如果你正在處理一個快速變化的應用程式,那是不可取的。處理此問題的一個好方法是使用JavaScript計時器和事件佇列建立自己的排程器。這種方法在《雙鐘的故事》中有描述

譯者注:《雙鐘的故事》即 《A Tale of Two Clocks》 寓言故事大致告訴人們不能依靠單獨一種方式,需要依靠多種方式方法解決問題

譯者注:具體音樂原理不重要,重要的是反應出可對音訊延時播放, 聽的就是個“動次打次”

以上程式碼實現 demo 可參考 https://github.com/willian12345/WebAudioAPI/tree/master/examples/ch02/index2.html

更改音訊引數

很多音訊節點類形的引數都是可配的。舉個例子,GainNode 擁有 gain 引數用於控制透過 gain 節點的聲音音量乘數。特別的一點是,引數如果是1則不影響幅度,0.5 降一半,2 則是雙倍。讓我們設定一個:

譯者注: gain節點或稱增益節點通常用於調節音訊訊號的音量

  // 建立 gain node.
  var gainNode = context.createGain();
  // 連線  source 到 gain node. 
  source.connect(gainNode);
  // 連線  gain node 至  destination. 
  gainNode.connect(context.destination);

在 context API 中,音訊引數用音訊例項表示。這些值可透過節點直接變更:

// 減小音量
gainNode.gain.value = 0.5;

當然也可以晚一點修改值,透過精確安排在後續更改。我們也可以使用 setTimeout 來延時修改,但它不夠精確,原因有幾點:

  1. 毫秒基數的計時可能不夠精確
  2. 主 JS 程序可能很忙需要處理更高優先順序的任務比如頁面佈局,垃圾回收以及其它 API 可能導致延時的回撥函式佇列等
  3. JS 計時器可能會受到瀏覽器 tab 的狀態影響。舉個例子,interval 計時器相比於 tab 在前臺執行時,tab 在後臺執行時觸發的更慢

我們可以直接呼叫 setValueAtTime() 方法來代替直接設值,它需要一個值與開始時間作為引數。舉個例子,下面的程式碼片斷一秒就搞定了 GainNode的 gain 值設定

gainNode.gain.setValueAtTime(0.5, context.currentTime + 1);

以上程式碼實現 demo 可參考 https://github.com/willian12345/WebAudioAPI/tree/master/examples/ch02/index3.html

漸變的音訊引數

在很多例子中,相較於直接硬生生設定一個引數,你可能更傾向於漸變設值。舉個例子,當開發音樂應用時,我們希望當前的聲音軌道漸隱,然後新的聲音軌道漸入而避免生硬的切換。當然你也可以利用多次呼叫 setValueAtTime 函式的方法實現類似的效果,但顯然這種方法不太方便。

Web Audio API 提供了一個堆方便的 RampToValue 方法,能夠漸變任何引數。 它們是 linearRampToValueAtTime() 和 exponentialRampToValueAtTime()。兩者的區別在於發生變換的方式。在一些用例中,exponential 變換更加敏感,因為我們以指數方式感知聲音的許多方面。

讓我們用一個例子來展示交叉變換吧。給定一個播放列表,我們可以在音軌間安排變換降低當前播放的音軌音量並且增加下一條音軌的音量。兩者都發生在當前曲目結束播放之前稍早的時候:

function createSource(buffer) {
  var source = context.createBufferSource(); 
  var gainNode = context.createGainNode(); 
  source.buffer = buffer;
  // Connect source to gain. 
  source.connect(gainNode);
  // Connect gain to destination. 
  gainNode.connect(context.destination);
  return {
    source: source, 
    gainNode: gainNode
  }; 
}

function playHelper(buffers, iterations, fadeTime) { 
  var currTime = context.currentTime;
  for (var i = 0; i < iterations; i++) {
    for (var j = 0; j < buffers.length; j++) { 
      var buffer = buffers[j];
      var duration = buffer.duration;
      var info = createSource(buffer);
      var source = info.source;
      var gainNode = info.gainNode;
      // 漸入
      gainNode.gain.linearRampToValueAtTime(0, currTime); 
      gainNode.gain.linearRampToValueAtTime(1, currTime + fadeTime);
      // 漸出
      gainNode.gain.linearRampToValueAtTime(1, currTime + duration-fadeTime);
      gainNode.gain.linearRampToValueAtTime(0, currTime + duration);
      // 播放當前音訊.
      source.noteOn(currTime);
      // 為下次迭代累加時間
      currTime += duration - fadeTime;
    }
  } 
}

譯者注: 原文中的程式碼過時了, 實際實現請參考我的 demo 實現

標準 https://webaudio.github.io/web-audio-api/#dom-gainnode-gain

以上程式碼實現 demo 可參考 https://github.com/willian12345/WebAudioAPI/tree/master/examples/ch02/index4.html

定製時間曲線

如果線性曲線和指數曲線都無法滿足你的需求,你也可以自己定製自己的曲線值透過傳遞一個陣列給 setValueCurveAtTime 函式實現。有了這個函式,你可以透過傳遞陣列實現自定義時間曲線。它是建立一堆 setValueAtTime 函式呼叫的快捷呼叫。舉個例子,如果我們想建立顫音效果,我們可以透過傳遞振盪曲線作為 GainNode 的 gain 引數值,如圖 2-2

image

上圖的振盪曲線實現程式碼如下:


var DURATION = 2; 
var FREQUENCY = 1; 
var SCALE = 0.4;
// Split the time into valueCount discrete steps.
var valueCount = 4096;
// Create a sinusoidal value curve.
var values = new Float32Array(valueCount); 
for (var i = 0; i < valueCount; i++) {
  var percent = (i / valueCount) * DURATION*FREQUENCY;
  values[i] = 1 + (Math.sin(percent * 2*Math.PI) * SCALE);
  // Set the last value to one, to restore playbackRate to normal at the end. 
  if (i == valueCount - 1) {
    values[i] = 1;
  }
}
// Apply it to the gain node immediately, and make it last for 2 seconds.
this.gainNode.gain.setValueCurveAtTime(values, context.currentTime, DURATION);

上面的程式碼片斷我們手動計算出了正弦曲線並將其設定到 gain 的引數內創造出顫音效果。好吧,它用了一點點數學..

這給我們帶來了 Web Audio API 的一個非常重要的特性, 它使得我們建立像顫音這樣的特效變的非常容易。這個重要的點子是很多音訊特效的基礎。上述的程式碼實際上是被稱為低頻振盪(LFO)效果應用的一個例子, LFO 經常用於建立特效,如 vibrato 震動 phasing 分隊 和 tremolo 顫音。透過對音訊節點應用振盪,我們很容易重寫之前的例子:

// Create oscillator.
var osc = context.createOscillator(); 
osc.frequency.value = FREQUENCY;
var gain = context.createGain(); 
gain.gain.value = SCALE; 
osc.connect(gain); 
gain.connect(this.gainNode.gain);
// Start immediately, and stop in 2 seconds.
osc.start(0);
osc.stop(context.currentTime + DURATION);

createOscillator https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode

相比於我們之前建立的自定義曲線後面的程式碼要更高效,重現了效果但它幫我們省了用手動迴圈建立正弦函式

以上振盪器節點的程式碼實現 demo 可參考 https://github.com/willian12345/WebAudioAPI/tree/master/examples/ch02/index5.html


注:轉載請註明出處部落格園:王二狗Sheldon池中物 (willian12345@126.com)

相關文章