[HTML] AudioContext 折腾笔记 02

之前我们已经看到了如何利用 HTML 的 WebAudio API 播放一段自定义的音频,其实难度并不大,主要步骤就是创建音频上下文,创建缓冲区和音频源,之后在缓存区内暴力写入波形数据,就可以播放了。

有了这些功能,理论上我们是可以在浏览器里使用 JavaScript 解码任意的音频文件并播放的,然而实际上 JavaScript 本身算是个半残废语言,而且既然能用 HTMLAudio 标签来播放音频,为什么要自己解码呢(

不过话说回来,不是所有代码都是写了拿去用的,有的时候折腾折腾一些比较奇怪的东西也非常有趣。想到世界上有一个叫 WAV(audio/x-wave) 的音频格式,其结构听说也是非常的简单粗暴,在解码上基本没有什么复杂度。于是为什么不尝试着自己用 JavaScript 把 wav 音频通过音频上下文接口播放出来呢?

音频文件的加载和分析

这一段其实没什么好说的,走 XMLHttpRequest 请求一个 ArrayBuffer 基本就可以了,值得注意的是需要解决跨域问题(如果媒体文件和当前 URL 不在同一域下)。不过既然 Google Chrome 已经支持 Promise 了,就试试走 Promise 来封装一个 HTTP 客户端吧(x

  function $get(url) {
    return new Promise((resolve, reject) => {
      let xhr = new XMLHttpRequest();
      xhr.open('GET', url);
      xhr.responseType = 'arraybuffer';
      xhr.addEventListener('progress', (e) => {
        status.innerText = `Loading media ${(100 * e.loaded / e.total).toFixed(3)}% ...`;
      });
      xhr.onreadystatechange = () => {
        switch(xhr.readyState) {
        case xhr.DONE:
          resolve(xhr.response)
          break;
        default:
          break;
        }
      };
      xhr.send();
    });
  }

代码里的 status 是一个 span,用来实时显示 xhr 的加载进度的,毕竟 wav 这么大……

之后的代码里只要使用 $get(url).then(callback) 这样的形式就可以加载 wav 文件了。

wav 文件格式

嗯搞到文件之后就得开始解码了,在解码之前,必须先获得一些元数据例如采样率啊文件大小声道数什么的。

wav 格式本身是很简单的,首先得介绍一下 wav 文件里的整体描述单位:Chunk,其结构如下

struct wavChunk {
  unsigned char chunkName[4];
  unsigned int  chunkSize;
  unsigned char chunkData[];  // sizeof is chunkSize
}

知道了 Chunk 之后,剩下的就很好解释了:wav 文件本身就是一个名为 RIFF 的 Chunk,随后包含若干 Chunk 来表示一些元数据,比如 FMT Chunk 中描述了音频格式、声道数、采样率、采样位数,而 data Chunk 中就是音频的 PCM 数据。刚才提到的三个 Chunk 的结构都是固定的,剩下一些比如 LIST 之类的 Chunk 我们可以暂时先不管。

于是,一些分析前的准备:

      let rawFile = file;
      file = new Uint8Array(rawFile);

      // Reference for RIFF chunk and FMT chunk
      let RIFF = {
        RIFF:           { begin:    0, length:    4, type: 'CHAR'},
        ChunkSize:      { begin:    4, length:    4, type: 'INTEGER'},
        WAVE:           { begin:    8, length:    4, type: 'CHAR'},
      }
      let FMT = {
        fmt:            { begin:   12, length:    4, type: 'CHAR'},
        Subchunk1Size:  { begin:   16, length:    2, type: 'INTEGER'},
        AudioFormat:    { begin:   20, length:    2, type: 'INTEGER'},
        NumOfChan:      { begin:   22, length:    2, type: 'INTEGER'},
        SamplesPerSec:  { begin:   24, length:    4, type: 'INTEGER'},
        bytesPerSec:    { begin:   28, length:    4, type: 'INTEGER'},
        blockAlign:     { begin:   32, length:    2, type: 'INTEGER'},
        bitsPerSample:  { begin:   34, length:    2, type: 'INTEGER'},
      }
      
      file.subArray = (begin, length) => {
        return file.slice(begin, begin + length);
      };

      let readChar = (begin, length) => Array
        .from(file
          .subArray(begin, length))
        .map(ch => String.fromCharCode(ch))
        .join('');

      let readInt = (begin, length) => Array
        .from(file
          .subArray(begin, length))
        .reverse()
        .reduce((a, b) => a * 256 + b);

      let readChunk = (ref, save) => {
        for (let item in ref) {
          switch(ref[item].type) {
            case 'CHAR':
              save[item] = readChar(ref[item].begin, ref[item].length);
              break;
            case 'INTEGER':
              save[item] = readInt(ref[item].begin, ref[item].length);
              break;
          }
        }
      }

简单的描述一下吧,由于 JavaScript 这个破烂语言数据类型残缺,对于 uint32_t 类的数字我们需要自己准备办法读取,还好这是一个无符号整数,只要看下小端的定义就能轻松的将其转换为具体的数据。上面代码中的 readInt 就能完成这项需求。

接下来继续,开始读取 RIFF Chunk:

      // Read the RIFF chunk
      readChunk(RIFF, this);

这句没什么好说的,按照之前定义的 RIFF 变量,按格式读取数据。然后依次读取接下来的 Chunk,直到读到 data Chunk。其中,如果遇到 FMT Chunk 就需要按格式读取,剩下的就暂时先不管了吧。

      for (let offset = 12;;) {
        let chunkName = readChar(offset, 4); offset += 4;
        let chunkSize = readInt(offset, 4); offset += 4;
        this[chunkName] = {};
        this[chunkName].size = chunkSize;
        if (chunkName === 'fmt ') {
          // Read the RIFF chunk
          readChunk(FMT, this[chunkName]);
        }
        else {
          if (chunkName == 'data') {
            let type = `Int${this['fmt '].bitsPerSample}Array`;
            this[chunkName] = new window[type](rawFile.slice(offset, chunkSize))
            break;
          }
          // Read as raw
          this[chunkName]._rawData = file.subArray(offset, chunkSize);
        }
        offset += chunkSize;
      }

将以上部分放到 Wav 类的构造函数中去,构造完毕后,我们能得到这样的一个对象:

Wav {
 ChunkSize: 20045138,
 LIST: { ... },
 RIFF: 'RIFF',
 WAVE: 'WAVE',
 data: Int16Array[...],
 "fmt ": Object,
}

关键数据就在 fmt 和 data 里了:fmt 定义了如何去播放,data 则是待播放的内容。

初始化 AudioContext 和其他相关变量

这里还是比较简单的,将 fmt 区块里的数据拿出来就可以了,内容可以参考上一篇笔记:

      let ctx = this.ctx = new AudioContext();
      let framesCount = this.framesCount = wav.ChunkSize / (wav['fmt '].bitsPerSample / 8);

      let audioBuffer = this.audioBuffer = ctx.createBuffer(
        wav['fmt '].NumOfChan, 
        framesCount / 2, 
        wav['fmt '].SamplesPerSec
      );

      this.source = ctx.createBufferSource();
      this.source.loop = false;
      this.source.connect(ctx.destination);
      this.source.buffer = this.audioBuffer;

这里面少了一步:PCM 数据的填入。我是想着让音频一边播放一边填入的,可惜这样下来只有 Chrome 系浏览器能正常播放了)

边播放边填入本身是可以用 setInterval 实现,顺便趁机长试一波 ES6 的 yield 关键字:

    *step() {
      let ctx = this.ctx;
      let channelBuffering = [];
      for (let i = 0; i < wav['fmt '].NumOfChan; ++i) {
        channelBuffering[i] = this.audioBuffer.getChannelData(i);
      }

      for (let currentFrame = 0; currentFrame < this.framesCount; ++currentFrame) {
        channelBuffering[currentFrame % 2][Math.ceil(currentFrame / 2)]
          = Number(wav.data[currentFrame]) / (1 << (wav['fmt '].bitsPerSample - 1));

        if (!this.started) {
          setTimeout(() => {this.source.start()}, 10);
          this.started = true;
        }

        yield currentFrame;
      }
    }

将以上函数都怼到 class Wav 中后,只要在准备一个主函数就可以完成 wav 的解码和播放了。具体代码的话就不贴了,没什么好讨论的了(逃

最后附上一个运行效果:playground/wav-decode完整源代码,其中还加了点可视化的魔法,wav 的体积真的是槽点(

参考链接

  1. WAVE PCM soundfile format
  2. WAV – Wikipedia