之前也有做过前端音频相关的操作,之前的做法是创建一个 audio 标签,需要播放提示音时调用 audio 实例的 play 方法,从而实现提示音的效果。

2021 年 6 月 17 日 — 万维网联盟(W3C)宣布 Web Audio API 现成为一项正式标准,支持在 Web 上创建和操作音乐及音频。

基于 Web Audio API 我们可以做很多有意思的事,例如在线乐器,在线游戏等,交互反馈等。

其浏览器的支持率已经非常高了,我们完全可以在主流浏览器应用这个 API。

image-20221212210904001

Audio API 的概念

在进行声音播放之前先来看下西 Audio 最基本的一些概念。

AudioContext 是音频上下文,可以在上下文中进行音频操作,如果你使用 Canvas,那么你对上下文应该是更容易理解的。AudioContext 可以理解为一个容器,之后所有发生的事情都需要在 AudioContext 范围内。

在上下文中通过AudioNode 音频节点来进行音频的操作,它是音频路由图(多个音频节点之间相关连接组成路由图)中最基本的组成单位,必须要工作在音频上下文中,并且一个音频节点只能绑定一个音频上下文。

音频节点有用自己的输入和输出,通过 connect 方法来连接不同的音频节点,所有的音频节点最终的出口是 AudioContext.destination,也就是扬声器等播放设备。

image-20221212215915606

destination 也是一种音频节点,此外常见的音频节点还有两类:SourceNodeProcessNode。sourceNode指能产生音频的节点,只有输出,没有输入;processNode是音频处理节点,可能会拥有一个或多个输入,一个输出或多个输出,例如下图所示:

image-20221212215842693

多个输入的情况如声道合并 ChannelMergerNode,那么就存在声道拆分 ChannelSplitterNode 可以有多个输出。

使用 Audio API 播放声音

音源

播放声音一定有一个音源,这个音源的主要来源有:

  • OscillatorNode:振荡器,创建某种指定类型的振荡器波,内置了四种波形"sine""square""sawtooth""triangle",分别是正弦波,方形波,锯齿波,三角波,此外还提供了自定义振荡器。可以从波形图中看到他们的命名由来

    image-20221213101908129

  • AudioBufferSourceNode:由内存音频数据组成的音源,数据存放在 AudioBuffer 中,主要是从网络请求中解析出来的二进制数据;

  • MediaElementAudioSourceNode:由 H5 元素生成的音源,video 或 audio 标签;

  • MediaStreamAudioSourceNode:通过 MediaStream 的形式获取的音源,如navigator.mediaDevices.getUserMedia、navigator.mediaDevices.getDisplayMedia。

可以通过下面的代码来播放余弦波振荡器发出的声音

const context = new AudioContext()
context.createOscillator()
// const osc = new OscillatorNode(context)
// const amp = new GainNode(context, { gain: 0.5 })
const osc = context.createOscillator()
const amp = context.createGain({ gain: 0.5 })
osc.start()

window.addEventListener('load', () => {
  const playBtn = document.getElementById('start-audio')
  playBtn.addEventListener('click', () => {
    osc.connect(amp)
    amp.connect(context.destination)
    context.resume()
  })

  const pauseBtn = document.getElementById('pause-audio')
  pauseBtn.addEventListener('click', () => osc.disconnect())
})

你也可以通过下面的按钮来进行尝试四种振荡器(📢!!!调小音量

音频处理器

上面的代码中其实就用到了音频处理器——GainNode,用于控制音频的音量。我们可以通过 AudioContext 的原型链来看到各种音频处理节点。

image-20221213112757009

在这些create方法中,每个音频节点都有自己的构造函数,在实例化的时候需要绑定 context,所以 AudioContext 提供了简便的创建方式,可以省略显式的 context 绑定。

下面我们来逐一认识各个处理器:

效果器 构造函数 输入 输出 通道数 用途
双二阶滤波器 BiquadFilterNode 1 1 2 控制音调、均衡器
线性卷积 ConvolverNode 1 1 1,2,4 音频混响
延迟 DelayNode 1 1 2 延迟输出
动态压缩 DynamicsCompressorNode 1 1 2 降低信号中最响部分的音量
音量 GainNode 1 1 2 音量增益
立体声 StereoPannerNode 1 1 2 控制左右声道的偏移
非线性畸变器 WaveShaperNode 1 1 2 添加暖调处理
周期性波形 PeriodWave - - - 自定义振荡器
分析器 AnalyserNode 1 1/0 1 音频分析或可视化
通道分离器 ChannelSplitterNode 1 变量,默认 6 - 将音频源的通道分离
通道合并器 ChannelMergerNode 变量,默认 6 1 2 多个输入流合并到一个流

音频控制

音频的控制主要有播放、停止、暂停/继续,在播放之前需要初始化音频源,音频源的创建我们上面说过有四种方式,并且我们已经演示了通过振荡器发生的过程,我们继续看一下另外的创建音频源的方式。

第一种,通过 arraybuffer 创建音频源。

首先创建一个 BufferSource,接着我们通过 fetch 请求获取到了音频文件的二进制数据,并将其解析为 arraybuffer,然后将 buffer 数据使用 AudioContext.decodeAudioData 来进行解析(老版本是回调的形式,新版本支持了 Promise),然后将数据喂给 BufferSource,最后将 BufferSource 输出到播放设备即可。

let sourceBuffer 
function loadResource() {
  sourceBuffer = context.createBufferSource()
  fetch('./bgm.aac')
    .then(res => res.arrayBuffer())
    .then(buffer => context.decodeAudioData(buffer))
    .then(audioBuffer => {
      sourceBuffer.buffer = audioBuffer
      sourceBuffer.connect(context.destination)
      sourceBuffer.start(0)
    })
}

function stopMusic() {
  context.suspend()
}

function startMusic() {
  if(!sourceBuffer)
    loadResource()
  context.resume()
}

值得注意的是,使用音频上下文的暂停是整体的暂停,如果你的音频路由图中是有一个音频源那么可以使用这种方式来进行音频控制,如果是多音频源需要单独暂停某个音频源的话还是需要使用 AudioNode.disconnect。

你可以体验一下另一种形式的播放,选择一个本地文件来播放

第二种,通过音频/视频元素创建。

在已有的音频/视频元素基础上,通过 AudioContext.createMediaElementSource 来创建音频源。

const audioElement = document.getElementById('audio')
let elemContext
let gainNode
audioElement.addEventListener('play', () => {
  if (!elemContext) {
    elemContext = new AudioContext()
    gainNode = elemContext.createGain()
    const source = elemContext.createMediaElementSource(audioElement)
    source.connect(gainNode)
    gainNode.connect(elemContext.destination)  
  }
})


function voiceAdd() {
  if (gainNode.gain.value < 1)
    gainNode.gain.value += 0.1
}

function voiceSub() {
  if (gainNode.gain.value > 0)
    gainNode.gain.value -= 0.1
}

window.addEventListener('keydown', (e) => {
  if (e.key === 'ArrowDown')
    voiceSub()
  if (e.key === 'ArrowUp')
    voiceAdd()
})

媒体元素在使用createMediaElementSource处理之后,控制权将会由 AudioContext 接管。但是仍然可以使用原有的控制面板来控制播放/暂停。

第三种是通过媒体流来创建音源。

通过浏览器的媒体方法获取媒体流,将媒体流作为音频源进行创建,然后就可以进行音频节点的处理,这样就可以对音频进行效果增益

let mediaStream, streamContext;
function playAudioByStream() {
  streamContext = new AudioContext()
  const video = document.getElementById('video')
  navigator.mediaDevices.getUserMedia({
    video: true,
    audio: true
  }).then(stream => {
    video.srcObject = stream
    video.onloadedmetadata = (e) => {
      video.play()
      video.muted = true
    }
    const source = streamContext.createMediaStreamSource(stream)
    const delay = streamContext.createDelay(3)
    source.connect(delay)
    delay.connect(streamContext.destination)
  })
}

除了使用媒体流进行处理之外,还可以使用createMediaStreamTrackSource 将某个音频轨道作为音频源进行处理。

音频可视化

常见的音频可视化有音乐频谱,音频波形匹配(音色验证),我们以音乐频谱为例来看一下音频可视化的操作流程。

和音频可视化相关的 AudioNode 是 AnalyserNode,可以用来显示波形和频谱。分析器节点会在指定的频率域捕获音频数据,这取决于设置的fftSize(默认2048)。

频谱

使用getByteFrequencyData获取频率的数据,这个方法接受一个数组参数,会将分析的结果放入这个数据,但是这个数组并不是日常使用的普通数组。因为产生的数据类型是 8 位无符号整形,所以我们需要构造一个Uint8Array 数组。

如果使用getFloatFrequencyData则需要使用 32 位精度浮点数 Float32Array。

function drawFrequency() {
  analyser.fftSize = 512;
  const bufferLengthFrequency = analyser.frequencyBinCount;
  const dataArrayFrequency = new Uint8Array(bufferLengthFrequency);
  analyser.getByteFrequencyData(dataArrayAlt);
}

然后我们就可以从dataArrayFrequency中获取频谱数据了,我们可以结合 canvas 来进行可视化的绘制。

  const drawFrequency = function () {
    drawVisual = requestAnimationFrame(drawFrequency);

    analyser.getByteFrequencyData(dataArrayFrequency);

    canvasCtx.fillStyle = "rgb(0, 0, 0)";
    canvasCtx.fillRect(0, 0, WIDTH, HEIGHT);

    const barWidth = (WIDTH / bufferLengthFrequency) * 2.5;
    let barHeight;
    let x = 0;

    for (let i = 0; i < bufferLengthFrequency; i++) {
      barHeight = dataArrayFrequency[i];

      canvasCtx.fillStyle = "rgb(" + (barHeight + 100) + ",50,50)";
      canvasCtx.fillRect(
        x,
        HEIGHT - barHeight / 2,
        barWidth,
        barHeight / 2
      );

      x += barWidth + 1;
    }
  };

通过不断调用绘制方法可以一直使用实时的频率数据进行渲染,以达到频谱跃动的效果

image-20221214220101438

波形

使用getByteTimeDomainData获取波形数据,同样的需要使用 Uint8Array 数组,如果使用getFloatTimeDomainData则需要使用 Float32Array。

和频率相同的操作,但是波形图的绘制方式需要修改

  const drawTimeDomain = function () {
    drawVisual = requestAnimationFrame(drawTimeDomain);
    analyser.getByteTimeDomainData(dataArray);
    
    canvasCtx.fillStyle = "rgb(200, 200, 200)";
    canvasCtx.fillRect(0, 0, WIDTH, HEIGHT);

    canvasCtx.lineWidth = 2;
    canvasCtx.strokeStyle = "rgb(0, 0, 0)";

    canvasCtx.beginPath();

    const sliceWidth = (WIDTH * 1.0) / bufferLength;
    let x = 0;

    for (let i = 0; i < bufferLength; i++) {
      let v = dataArray[i] / 128.0;
      let y = (v * HEIGHT) / 2;

      if (i === 0) {
        canvasCtx.moveTo(x, y);
      } else {
        canvasCtx.lineTo(x, y);
      }

      x += sliceWidth;
    }

    canvasCtx.lineTo(canvas.width, canvas.height / 2);
    canvasCtx.stroke();
  };

image-20221214220236160


前端小白