博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

Web Audio API 二:精确的时序和延时

Posted on 2022-05-22 08:55  pencilCool  阅读(587)  评论(0编辑  收藏  举报

翻译自:https://webaudioapi.com/book/Web_Audio_API_Boris_Smus_html/ch02.html

<audio>标签相比,Web Audio API的优势之一是它有一个低延迟的精确计时模型。

低延迟对于游戏和其他交互式应用非常重要,因为你经常需要对用户的行为作出快速的听觉反应。如果反馈不是即时的,用户会感觉到延迟,这将导致挫败感。在实践中,由于人类听觉的不完善,延迟最多20毫秒左右是能接受的,但这个数字因许多因素而异。

精确的计时使你能够将事件安排在未来的特定时间。这对脚本场景和音乐应用非常重要。

定时模型

音频上下文提供的关键能力:一个一致的计时模型和时间参考框架。重要的是,这个模型与用于JavaScript定时器的模型不同,比如setTimeout、setInterval和new Date()。它也不同于window.performance.now()所提供的性能时钟。

你在Web Audio API中要处理的所有绝对时间都是以秒为单位(而不是以毫秒为单位!),在指定的音频上下文的坐标系中。当前的时间可以通过currentTime属性从音频上下文中检索出来。虽然单位是秒,但时间被存储为高精度的浮点值。

精确的播放和恢复

start()函数使得为游戏和其他有时间要求的应用程序安排精确的声音播放变得很容易。为了使其正常工作,要确保你的声音缓冲区被预先加载[见加载和播放声音]。如果没有预装缓冲区,你将不得不等待一段未知的时间,让浏览器获取声音文件,然后让Web Audio API对其进行解码。这种情况下的失败模式是你想在一个精确的瞬间播放一个声音,但缓冲区仍在加载或解码中。

通过指定start()调用的第一个(when)参数,声音可以被安排在一个精确的时间播放。这个参数是在AudioContext的currentTime的坐标系统中。如果这个参数小于currentTime,它就会被立即播放。因此start(0)总是立即播放声音,但要在5秒内播放声音,你会调用start(context.currentTime + 5)。

声音缓冲区也可以通过指定start()调用的第二个参数从一个特定的时间偏移开始播放,并通过第三个可选参数限制到一个特定的持续时间。例如,如果我们想暂停一个声音,并从暂停的位置开始播放,我们可以通过跟踪声音在当前会话中的播放时间,同时跟踪最后的偏移量来实现暂停,以便以后恢复。

// 假设context是一个网络音频context,buffer是一个预装的音频缓冲区。
var startOffset = 0;
var startTime = 0;

function pause() {
  source.stop()。
// 计算自上一次暂停后过去了多少时间。
  startOffset += context.currentTime - startTime。
}

一旦一个源节点完成了播放,它就不能再播放了。要再次播放底层缓冲区,你需要创建一个新的源节点(AudioBufferSourceNode)并调用start()。

funcation play() {
  startTime = context.currentTime;
  var source = context.createBufferSource();
// Connect graph
  source.buffer = this.buffer;
  source.loop = true;
  source.connect(context.destination);
// 开始播放,但要确保我们保持在缓冲区的范围内。
  source.start(0, startOffset % buffer.duration)。
}

尽管重新创建源节点起初可能看起来效率不高,但请记住,源节点为这种模式进行了大量的优化。记住,如果你保留了AudioBuffer的句柄,你就不需要再向资产发出请求来播放同样的声音。通过保留这个AudioBuffer,你在缓冲区和播放器之间有一个干净的分离,可以很容易地播放同一缓冲区的多个版本在时间上的重叠。如果你发现自己需要重复这种模式,可以用一个简单的辅助函数来封装回放,比如前面代码片段中的playSound(buffer)。

安排精确的节奏

Web Audio API让开发者精确地安排未来的播放。为了证明这一点,我们来设置一个简单的节奏轨道。最简单和最广为人知的爵士鼓模式可能如图2-1所示,其中小鼓每隔8分音符演奏一次,低音鼓和军鼓则在4/4拍中交替演奏。

image

Figure 2-1. Sheet music for one of the most basic drum patterns

假设我们已经加载了kick、snare和hihat缓冲区,这样做的代码很简单。

for (var bar = 0; bar < 2; bar++) {
  var time = startTime + bar * 8 * eighthNoteTime;
  // 在第1、5拍播放低音(踢)鼓的声音
  playSound(kick, time)。
  playSound(kick, time + 4 * eighthNoteTime);

  // 在第3、7节播放小鼓的声音
  playSound(snare, time + 2 * eighthNoteTime);
  playSound(snare, time + 6 * eighthNoteTime);

  //每隔八分之一的音符播放一次Hihat。
  for (var i = 0; i < 8; ++i) {
    playSound(hihat, time + i * eighthNoteTime)。
  }
}

一旦你把声音安排在未来,就没有办法取消对未来播放事件的安排,所以如果你正在处理一个快速变化的应用程序,把声音安排在太远的未来是不可取的。处理这个问题的一个好方法是使用JavaScript定时器和事件队列创建你自己的调度器。这种方法在《两个时钟的故事》中有所描述。

改变音频参数

许多类型的音频节点都有可配置的参数。例如,GainNode有一个增益参数,控制所有通过该节点的声音的增益倍数。具体来说,1的增益不影响振幅,0.5的增益减半,2的增益加倍[见音量、增益和响度]。让我们来设置一个图,如下。

// 创建一个增益节点。
var gainNode = context.createGain();
// 将源连接到增益节点上。
source.connect(gainNode);
// 将增益节点连接到目的地。
gainNode.connect(context.destination)。
在API的上下文中,音频参数被表示为AudioParam实例。这些节点的值可以通过设置param实例的value属性直接改变。

// 降低音量。
gainNode.gain.value = 0.5。

这些值也可以在以后改变,通过在未来精确安排的参数变化。我们也可以使用setTimeout来做这个调度,但由于几个原因,这并不精确。

  • 基于毫秒的计时可能不够精确。

  • 主JS线程可能正忙于高优先级的任务,如页面布局、垃圾回收和来自其他API的回调,这将延迟定时器。

  • JS定时器受到标签状态的影响。例如,有背景的标签中的间隔定时器比标签在前台的时候发射得更慢。

我们可以调用setValueAtTime()函数,而不是直接设置数值,该函数需要一个数值和一个开始时间作为参数。例如,下面的片段在一秒钟内设置一个GainNode的增益值。

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

渐进式改变音频参数

在许多情况下,与其突然改变一个参数,你还不如选择一个更渐进的变化。例如,在建立一个音乐播放器的应用程序时,我们希望淡出当前的音轨,然后淡入新的音轨,以避免刺耳的过渡。虽然你可以通过前面描述的多次调用setValueAtTime来实现,但这是不方便的。

Web Audio API提供了一套方便的 RampToValue 方法来逐步改变任何参数的值。这些函数是线性RampToValueAtTime()和指数RampToValueAtTime()。这两者之间的区别在于过渡发生的方式。在某些情况下,指数过渡更有意义,因为我们对声音的许多方面都是以指数方式感知的。

让我们举个例子,在未来安排一个交叉渐变。给定一个播放列表,我们可以通过调度当前播放的曲目的增益降低和下一个曲目的增益增加来实现曲目之间的过渡,这两个过程都是在当前曲目播放结束前的一小段时间。

function createSource(buffer) {
  var source = context.createBufferSource();
  var gainNode = context.createGainNode();
  source.buffer = buffer。
  // 将源连接到增益。
  source.connect(gainNode);
  // 将增益连接到目的地。
  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;
      //fade in
      gainNode.gain.linearRampToValueAtTime(0,currTime)
      gainNode.gain.linearRampToValueAtTime(1, currTime + fadeTime)
      // fade out。
      gainNode.gain.linearRampToValueAtTime(1,currTime + duration-fadeTime)
      gainNode.gain.linearRampToValueAtTime(0,currTime + duration)

      // 现在播放轨道。
      source.noteOn(currTime)。

      //为下一次迭代增加时间。
      currTime += duration - fadeTime。
    }
  }
}

自定义计时曲线

如果线性或指数曲线都不能满足你的需要,你也可以通过使用setValueCurveAtTime函数的一个数值数组来指定你自己的数值曲线。通过这个函数,你可以通过提供一个定时值的数组来定义一个自定义的定时曲线。它是进行一堆setValueAtTime调用的一个捷径,应该在这种情况下使用。例如,如果我们想创造一个颤音效果,我们可以对GainNode的增益AudioParam应用一个振荡曲线,如图2-2。

image
Figure 2-2. A value curve oscillating over time

上图中的振荡曲线可以用以下代码实现。

var DURATION = 2;
var FREQUENCY = 1;
var SCALE = 0.4;

// 将时间分割成valueCount的离散步骤。
var valueCount = 4096;
// 创建一个正弦值曲线。
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);
  // 将最后一个值设为1,以便在最后将播放速率恢复到正常。
  if (i == valueCount - 1) {
    values[i] = 1;
  }
}
// 将其立即应用于增益节点,并使其持续2秒。
this.gainNode.gain.setValueCurveAtTime(values, context.currentTime, DURATION)。

在前面的片段中,我们手动计算了一条正弦曲线,并将其应用于增益参数,以创造一个颤音的声音效果。虽然这需要一些数学运算。

这给我们带来了Web Audio API的一个非常有趣的功能,让我们更容易地建立像颤音这样的效果。我们可以把通常连接到另一个AudioNode的任何音频流,改为连接到任何AudioParam。这个重要的想法是许多声音效果的基础。前面的代码实际上是这种效果的一个例子,叫做低频振荡器(LFO)应用于增益,它被用来建立诸如颤音、相位和颤音等效果。通过使用振荡器节点[见基于振荡器的直接声音合成],我们可以很容易地重建前面的例子,如下。

// 创建振荡器。
var osc = context.createOscillator();
osc.frequency.value = FREQUENCY;
var gain = context.createGain();
gain.gain.value = SCALE;
osc.connect(gain)。
gain.connect(this.gainNode.gain)。

//立即启动,2秒后停止。
osc.start(0);
osc.stop(context.currentTime + DURATION)。

后一种方法比创建一个自定义的数值曲线更有效,并且通过创建一个循环来重复效果,省去了我们手动计算正弦函数的麻烦。