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