遇见你,很幸运——初识 Web Audio

注:本文使用的Web Audio API遵循W3C在18年9月发布的候选推荐版本,本文代码在Chrome76中测试通过,请注意代码兼容性。如有错误,请不吝指正。

Web Audio Api的兼容性(数据来自can i use)

引言

每段缘起始都很简单,或是擦肩而过的那阵清风,或是四目相对的那缕温情,亦是共经患难的那份情谊……初春的一天,在我被工作中出现的问题折磨的不堪之际,经朋友引荐,她出现在我的视野中。我忘不了她解决问题时的飒爽英姿,那干练的身影在我脑海中迟迟不能散去。我有意要了解她,却总感觉只在冰山一角。终于经历了几个月的徘徊后,我下定决心不再等待,开一个系列来介绍我的理解。Why now? Why not?

本文是《认识 Web Audio》系列文章的第一篇,主要涉及音频上下文的一些简单概念的介绍和使用。整个系列分为以下文章:

  • 遇见你,很幸运——初识 Web Audio
  • 知其然,知其所以然——Web Audio原理探究
  • 我和音符有个约会——音频可视化篇
  • 为你弹奏肖邦的夜曲——音频创作篇
  • 是谁带来远古的呼唤——音频空间化篇

Web Audio背景

长期以来在网页上播放音视频都是一个痛点,虽然HTML5引入了audio、video标签来实现基本的音视频播放,但却不足以应付更复杂情况如游戏引擎、实时交互等场景下的音频处理。于是,Web Audio应运而生。但要说明的是,它之于audio标签并不是替代关系,而是类似img和canvas的一种补充的关系。Web Audio播放音频能实现安卓、ios的一致性,消除诸如播放有延迟、不能循环播放等问题,如howler.js(当然不仅于此);其次可以利用振荡器、滤波器等创造自己的乐器,进行交互式音乐的构建、实现歌曲的演奏,如Tone.js;还可以对音频播放进行可视化,如Pts.js……

Web Audio是应在web浏览器中处理音频的需求而产生的,它早已不是一个新鲜的概念,但并没有大规模使用却是一个不争的事实。除去不值一提的兼容性问题,我想原因大概有几个,首先是API居多,因为它要实现在浏览器产生、处理音频必须要有相应的接口,但和专业音频制作程序仍有一定差距,不是特别能吸引专业人士;然后就是要实现实时WebRTC、游戏级引擎的音频特效等所需要的专业知识(数字信号处理、通信原理等)所带来的学习成本也是让很多人望而却步。不过大势所趋,它还在处于不断发展中,相信未来前景还是不错的。

Web Audio提供的合成声音、添加特效、音频可视化功能,是在音频上下文完成的,它将不同的操作细化为对应的节点实现了模块化(Modular routing),各个节点又可以通过connect方法相连接,进行必要的处理后,最终输出到目标节点(音频上下文的destination属性,通常是声音输出设备如扬声器)。整个流程连接在一起形成一个音频路由图(audio routing graph)。

Web Audio Api简单工作流程为:

  1. 创建音频上下文(AudioContext或OfflineAudioContext)
  2. 在上下文中创建声音来源(如audio标签、xmlhttprequest请求得到的arraybuffer、oscillator振荡器产生的各种波形)
  3. 创建特效节点(如混响、各种滤波、平移、压缩)
  4. 选择音频的最终产出地(如系统扬声器)
  5. 将来源连到特效节点,将特效节点连到目的节点

    Web Audio Api使得我们可以更精确(时间上可以做到无延迟、空间上可以产生衰减效果模拟真实环境)的操作音频,这是audio标签所不具备的。举个例子来说,如果要实现音频的渐入渐出效果,原始方法需要使用定时器来不断更改volume来实现,而使用web audio只需将源节点连到增益节点(GainNode),设置gainNode.gain.linearRampToValueAtTime(value, endTime)就能实现在endTime-currentTime时间段内,声音值从原始到value值的变化,可以说操纵很轻松了。

Web Audio基础

在正式使用之前,首先了解一下Web Audio Api的部分接口。

音频上下文接口
  • BaseAudioContext是实际使用的音频上下文AudioContext(用于实时渲染)和OfflineAudioContext(用于离线渲染)的基类,不能被直接实例化。音频节点都在其内创建并相互连接在一起,允许信号最终连接到AudioDestinationNode节点进行播放,形成一个音频路由图。其包含的部分属性如下:
    state属性是一个枚举属性,取值为 {‘suspended’, ‘running’, ‘closed’},表示音频上下文的当前状态。前两个可通过实例的resume()suspend()方法切换,调用close()之后音频上下文将会释放系统资源,不能再次使用。
    currentTime属性表示上下文的运行时间,当上下文处于running状态时该值会以均匀的速度单调递增,由渲染线程控制,不一定和处理的音频时间同步。
    destination属性是一个AudioDestinationNode节点,通常是实际的音频输出设备。
    sampleRate只读属性,表示采样率,在处理过程中保持不变,因此实时处理中不支持采样率转换。在不设置的情况下默认为音频输出设备的采样率。进行处理时使用的采样率如果和音频输出时的采样率不一致会进行重采样。

  • AudioContext实时音频上下文,来直接为用户产生信号。AudioContext初始化时state默认为suspended,因为自动播放策略限制,必须经过用户操作后才允许处于运行状态,这可通过resume()恢复音频上下文,或者调用AudioBufferSourceNode的start()时来恢复上下文。主动暂停上下文使用suspend(),关闭使用close()。但要注意的是虽然二者都可以释放包括线程,进程和音频流等系统资源。但暂停后可通过resume()恢复运行,关闭AudioContext则意味着释放所有资源,此后将无法使用或再次恢复它。构造函数中可传入contextOptions的对象,如果选项中包含sampleRate属性,则采样率设置为该属性,否则使用默认输出设备的采样率。

  • OfflineAudioContext离线音频上下文,虽然有destination属性,但实际并不会渲染到音频输出硬件中,但会尽可能快的渲染(一般来说要比实时渲染更快),通过startRendering()返回AudioBuffer,适用于那些可以在后台进行音频处理的场景。使用OfflineAudioContext(numberOfChannels, length, sampleRate)构造函数进行初始化,参数必填,不过也可以将一个包含这三个属性的对象(sampleRate/length必须属性)作为参数传递。因为构造时就已经确定了长度,所以在渲染length/smapleRate时间之后其状态就会变为closed,不能继续使用了。和AudioContext不同的是,离线音频上下文只有suspend(suspendTime)方法,没有close()方法,在音频数据渲染完之后主动关闭。终止时必须传入终止时间,以在指定时刻终止,该方法一般来说只有在同步操作音频数据时才有用。

音频节点、数据和音频控制接口
  • AudioNode:音频节点,是所有展示在音频路由图中节点模块(如音频源节点、目的节点、过滤器节点、增益节点等)的基类,不能直接实例化,提供connect方法实现节点间的连接。

  • AudioBuffer:音频缓冲区,由createBuffer(numberOfChannels, length, sampleRate)或构造器创建而来,参数都是必填,生成指定长度的音频buffer,该buffer默认初始化为0。该buffer包含duration(=length/sampleRate)、length、numberOfChannels、sampleRate属性。可通过getChannelData(channel)方法获取某一通道下的音频数据,返回一个Float32Array类型的数据。它只是保存数据源,不是真正的音频节点,只有赋值给相应的AudioNode才有作用。像AudioBufferSourceNode的buffer就是AudioBuffer类型。

  • AudioParam:音频参数接口,控制AudioNode的某个方面,比如音量。该接口除了value属性,其他属性都是只读的。可以直接为参数的value赋值,也可以使用AudioParam的方法实现预先设定。setValueAtTime(value, startTime)可以做到的当AudioContext的currentTime走到给定的startTime时间后进行赋值为value。linearRampToValueAtTime(value, endTime)可以做到从当前时间到endTime从当前值线性变化到value值。常用场景是实现声音播放的渐隐渐现,来避免使用定时器实现音量大小的设置。setTargetAtTime(target, startTime, timeConstant)可用于实现声音信号的衰变。

音频源节点
  • AudioBufferSourceNode:音频数据源节点,由createBufferSourceNode()方法或构造器创建得到。它接受一个AudioBuffer作为buffer属性来提供数据源,数据源必须来自内存。

  • MediaElementAudioSourceNode:媒体元素节点,由指定的HTMLMediaElement(通常为audio或video标签)创建得到,调用该方法后,音频播放的控制权移交给当前音频上下文的路由图。即只有当前AudioContext状态为running时才能播放,并且要连接到AudioContext的destination节点才能播放出声音。它允许我们操作audio、video声音轨道数据。

  • MediaStreamAudioSourceNode:媒体流音频节点,获取实时媒体流的音频源。使用音频上下文的createMediaStreamSource(mediaStream)方法或new MediaStreanAudioSourceNode(context, {mediaStream})创建而来

音频目的节点
  • MediaStreamAudioDestinationNode:音频流输出目的节点,数据存储在stream属性中,做临时存储用。

  • AudioDestinationNode:音频目的节点,每个AudioContext只有唯一的一个该节点,由BaseAudioContext的destination属性提供,一般为对应的音频输出硬件,如扬声器。

Web Audio的使用

接下来从加载音频的角度讲述如何使用Web Audio。加载音频方式主要有四种

  • 使用HTMLMediaElement元素(如audio/video)作为数据源,适合应用于音频数据量很大的场景

    下面是一个接管页面audio标签音频播放的示例,并使用gainNode节点来控制音频的音量

<audio src="./1.mp3" controls></audio>
<button>播放</button>
<input type="range" value="1" min="0" max="3" step=".1" />
<script>
  const ac = new AudioContext()
  const range = document.querySelector('input[type="range"]')
  const btn = document.querySelector('button')
  const audio = document.querySelector('audio')
  const ms = ac.createMediaElementSource(audio)
  // 等价于以下语句
  // const bf = new MediaElementAudioSourceNode(ac, { mediaElement: audio })
  const gainNode = ac.createGain()
  gainNode.gain.value = 1
  ms.connect(gainNode).connect(ac.destination)
  btn.onclick = function() {
    // AudioContext若处于suspended状态,需要恢复后才能正常播放
    if (ac.state === 'suspended') ac.resume()
    audio.play()
  }
  range.onchange = e => {
    gainNode.gain.value = e.target.value
  }
</script>
  • ajax异步获取数据:因为只有当全部加载到音频数据后才能使用,所以一般应用在少量音频数据场景中

    下面是一个先使用离线音频上下文异步获取到音频数据再传给音频上下文进行播放的例子

const ac = new AudioContext()
// 这里只是为展示OfflineAudioContext的使用,实际上完全可以直接用AudioContext异步获取到数据进行播放
const oc = new OfflineAudioContext(2, 44000 * 100, 44000)
async function fetchAudio(src) {
  const file = await fetch(src)
  const fileBuffer = await file.arrayBuffer()
  const buffer = await oc.decodeAudioData(fileBuffer)
  const bufferSource = oc.createBufferSource()
  bufferSource.buffer = buffer
  bufferSource.connect(oc.destination)
  bufferSource.start(0)
  oc.startRendering()
   .then(buffer => {
     // 这里对a、b进行赋值是为了给第三个例子的buffer创造来源
     window.a = buffer.getChannelData(0)
     window.b = buffer.getChannelData(1)
     const bs = new AudioBufferSourceNode(ac, { buffer })
     bs.connect(ac.destination)
     bs.start(0)
   })
   .catch(e => {
     console.log(e)
   })
}
fetchAudio('1.mp3')
  • 自定义声音:使用OscillatorNode产生类似正弦、锯齿形波形或者创建buffer并使用数据填充

    下面展示了一个使用自定义数据填充AudioBuffer的例子,在创建buffer时需要传入信道数、采样率、采样长度,使用AudioBuffer的copyToChannel()方法可以实现将一个Float32Arrays类型数据复制到指定信道。当然数据也可以自己产生,mdn官网上有一个使用随机数生成噪声的例子

const ac = new AudioContext()
const buffer2 = ac.createBuffer(2, 10 * 41000, 41000)
// 这里使用了上面第二个例子代码中的数据来源
buffer2.copyToChannel(a, 0, 0)
buffer2.copyToChannel(b, 1, 0)
const bf = ac.createBufferSource()
bf.buffer = buffer2
bf.connect(ac.destination)
bf.start()
  • 通过Media Stream API获取相机和麦克风的数据:使用MediaSteamAudioSourceNode来实现,适用于WebRTC或想要录制音频的场景

    下面是一个使用getUserMedia和MediaRecorder进行录音的例子。getUserMedia获取用户录音的权限,因为在录音过程中不需要输出,所以将音频流保存至MediaStreamAudioDestinationNode节点,并且在音频流传递过程中还使用了低通滤波器实现低音增强,录完音保存至audio标签,使用原生控件可进行播放和下载。同样,此例子只做展示用,其实直接将mediaStream作为参数传递给MediaRecorder构造器也是能实现录音。不过要注意的是MediaRecorder目前还仅在高版本PC浏览器中受支持,不太适合应用于生产环境。

<audio controls></audio>
<button>开始录音</button>
<script>
  const ac = new AudioContext()
  const biquadFilter = ac.createBiquadFilter()
  const msa = new MediaStreamAudioDestinationNode(ac)
  const mediaRecorder = new MediaRecorder(msa.stream)
  let chunks = [],
    start = false
  const btn = document.querySelector('button')
  const audio = document.querySelector('audio')
  // 麦克风->低音滤波->音频流目的节点 在这个过程中进行录制->audio标签播放
  navigator.mediaDevices
    .getUserMedia({
      audio: true
    })
    .then(mediaStream => {
      const ms = ac.createMediaStreamSource(mediaStream)
      biquadFilter.type = 'lowshelf'
      biquadFilter.frequency.value = 40000
      biquadFilter.gain.value = 1
      ms.connect(biquadFilter).connect(msa)
    })
    .catch(e => console.log(e))
  // 点击按钮开始录音或结束录音
  btn.onclick = () => {
    if (!start) {
      mediaRecorder.start()
      btn.innerText = '结束录音'
    } else {
      mediaRecorder.stop()
      btn.innerText = '开始录音'
    }
    start = !start
  }
  // 把得到的录音流保存至chunk
  mediaRecorder.ondataavailable = e => {
    chunks.push(e.data)
  }
  mediaRecorder.onstop = e => {
    const blob = new Blob(chunks, { type: 'audio/ogg; codecs=opus' })
    audio.src = URL.createObjectURL(blob)
  }
</script>

参考资料

  1. Web Audio API规范
  2. MDN Web_Audio_API专题
发布了21 篇原创文章 · 获赞 27 · 访问量 8万+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章