之前我们已经看到了如何利用 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 的体积真的是槽点(