纯属闲得蛋疼
首先我们需要准备好 WebAssembly 的工具链(大概可以这么叫吧),此处请参考
Compiling from C/C++ to WebAssembly | MDN 中的步骤
来完成。对于 Arch Linux 用户,可以从 AUR 中安装 emsdk
:
yaourt -S emsdk
之后则是和 MDN 中的步骤一样,编译并配置 LLVM 和 Emscripten SDK:
sudo emsdk install --build=Release sdk-incoming-64bit binaryen-master-64bit
sudo emsdk activate --global --build=Release sdk-incoming-64bit binaryen-master-64bit
完成后,将相关目录添加到环境变量,方法很多,例如在 .zshrc
最后加上:
export PATH=$PATH:/usr/lib/emsdk:/usr/lib/emsdk/clang/fastcomp/build_incoming_64/bin:/usr/lib/emsdk/node/8.9.1_64bit/bin:/usr/lib/emsdk/emscripten/incoming:/usr/lib/emsdk/binaryen/master_64bit_binaryen/bin
然后就是编译一下 libmad,方法也不难,首先前往 SourceForge 下载 libmad 的源码,然后进入所在目录并解压,执行 configure
, make
, make install
三连:
cd /path/to/libmad.tar.gz/
tar xzf libmad*.tar.gz
cd libmad*
mkdir ../build
/configure --prefix=$(pwd)/../build CC=emcc
make
make install
这样我们就将 libmad 编译安装到了 ../build
目录下了。之后就是编写 C 的程序来利用 libmad 进行解码,这里我使用的是 libmad 提供的 High-level API。在放代码之前,需要简单的讲一下 libmad 是如何工作的。
libmad 的高层 API 提供了 struct mad_decoder
类型,在初始化 struct mad_decoder
时我们需要向初始化函数传递若干函数指针作为参数。在解码过程中,libmad 获得待解码数据、输出解码后的数据、抛出错误等行为都是通过这些函数指针来完成的。libmad 在适当的时机会调用我们提供的回调,这样就能用最简单的方式实现流试的 MP3 解码了。
以输入函数 enum mad_flow (*input_func)(void *, struct mad_stream *)
为例,它是 mad_decoder_init
函数的第二个参数,当 libmad 需要获得待解码的 MP3 数据时,这个函数会被调用,此时我们应该通过 mad_stream_buffer
函数向 input_func
的第二个参数 struct mad_stream *
中写入数据的首地址和长度。libmad 会根据这个函数的返回值判断是否存在后续数据,即如果我返回 MAD_FLOW_STOP
,libmad 就不会再次调用 input_func
,而是等待缓存的数据解码完成后退出。
每一个回掉函数的首个参数都是一个 void *
的指针,其中的内容时我们初始化 decoder
时提供的一个地址,用作当前解码的上下文变量。
以下晒代码:
#include <mad.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define X86_64
#ifndef X86_64
#include <emscripten.h>
#endif
// 上文所述的上下文变量的结构体,用来保存解码的 PCM 数据和其他信息
struct buffer {
uint8_t* input;
size_t input_length;
mad_fixed_t** output;
size_t output_length;
size_t output_channels;
size_t output_samples;
};
// native 环境下 decode_mp3_to_pcm 函数的返回值
// 主函数通过读取这些信息将 PCM 写入存储
// wasm 环境下没用到这个结构体
struct output_t {
mad_fixed_t** p_output;
size_t channels;
size_t samples;
};
// 输入回调
static enum mad_flow input_callback (void *data, struct mad_stream *stream) {
struct buffer *buffer = (struct buffer *)data;
// 已经没有数据了,直接不管了,返回结束
if (buffer->input_length == 0) {
return MAD_FLOW_STOP;
}
// 直接把整个 MP3 丢进去……反正又不是不能用(
mad_stream_buffer(stream, buffer->input, buffer->input_length);
buffer->input_length = 0;
return MAD_FLOW_CONTINUE;
}
// 输出回调
static enum mad_flow output_callback (void *data, struct mad_header const *header, struct mad_pcm *pcm) {
struct buffer *buffer = (struct buffer *)data;
uint16_t samples;
int i;
samples = pcm->length;
if (!buffer->output) {
// 首次输出,分配好内存啥的
puts("output func fired");
buffer->output = (mad_fixed_t **)malloc(pcm->channels * sizeof(mad_fixed_t *));
buffer->output_channels = pcm->channels;
for (i = 0; i != pcm->channels; ++i) {
// 到后面再 realloc,这个不算未定义行为吧
buffer->output[i] = (mad_fixed_t *) malloc(0);
}
}
#ifdef X86_64
// 对于 native 环境这里用 realloc 就行了,虽然很粗暴,不过性能也不会差到哪儿去(丢人!)
for (i = 0; i != pcm->channels; ++i) {
buffer->output[i] = (mad_fixed_t *) realloc(buffer->output[i], (buffer->output_samples + pcm->length) * sizeof(mad_fixed_t));
}
// 逐个采样写入到输出数组
for (i = 0; i != pcm->length; ++i) {
int channel = 0;
for (channel = 0; channel != pcm->channels; ++channel) {
buffer->output[channel][buffer->output_samples + i] = pcm->samples[channel][i];
}
}
#else
// 至于 WASM ……是真的慢,不如直接把数据通过 EM_ASM_ 直接写入给 JavaScript 上下文,速度还是很快的……
EM_ASM_({
const begin = new Date().getTime();
const pcm_l_addr = $0;
const pcm_r_addr = $1;
const frames = $2;
const offset = $3;
if (!window.pcm) {
// 这里如果直接用字面值构造变量,emcc 会和我说 left 和 right 这两个变量为定义
// 就很神秘,不过显然走 JSON.parse 就没这个问题啦
window.pcm = JSON.parse("{ \"left\": [], \"right\": [] }");
window.begin = new Date().getTime();
window.jscost = 0;
}
let i = 0;
for (i = 0; i < frames; ++i) {
// 这里的 HEAP32 可以理解为一个 int32_t 的数组,里面就是 WASM 的堆内存
// 我们从 EM_ASM_ 宏的参数中传入了两个 PCM 采样数组的地址,计算偏移就能拿到数据了
// 我们直接把数据写入 JS 数组,反正众所周知 JS 的数组是稀疏数组,也不需要人为的扩展大小
window.pcm.left[offset + i] = Module.HEAP32[pcm_l_addr / 4 + i];
window.pcm.right[offset + i] = Module.HEAP32[pcm_r_addr / 4 + i];
}
window.jscost += (new Date().getTime()) - begin;
}, pcm->samples[0], pcm->samples[1], pcm->length, buffer->output_samples);
#endif
buffer->output_samples += pcm->length;
// 当然是继续啦,就算没有数据了这个函数也看不出来,反正没数据了 libmad 也不会再去执行这个回调了
return MAD_FLOW_CONTINUE;
}
// 异常回调
static enum mad_flow error_callback(void *data, struct mad_stream *stream, struct mad_frame *frame) {
puts("error func fired");
// doing nothing(
return MAD_FLOW_CONTINUE;
}
// 解码函数,在这里构造 libmad 的相关数据并执行解码,目的是可以直接被 wasm 拿去用
#ifndef X86_64
EMSCRIPTEN_KEEPALIVE void
#else
struct output_t
#endif
decode_mp3_to_pcm(uint8_t* input, size_t input_size) {
struct buffer buffer;
struct mad_decoder decoder;
struct output_t output;
int r;
memset(&buffer, 0, sizeof(buffer));
buffer.input = input;
buffer.input_length = input_size;
mad_decoder_init(&decoder, &buffer, input_callback, NULL, NULL, output_callback, error_callback, NULL);
mad_decoder_options(&decoder, 0);
r = mad_decoder_run(&decoder, MAD_DECODER_MODE_SYNC);
mad_decoder_finish(&decoder);
output.p_output = buffer.output;
output.samples = buffer.output_samples;
output.channels = buffer.output_channels;
#ifdef X86_64
return output;
#else
EM_ASM_({
console.log('WASM part finished, duration: ' + ((new Date().getTime()) - window.begin - window.jscost) + 'ms');
const channels = $0;
const samples = $1;
const pcm_l_addr = $2;
const pcm_r_addr = $3;
let ctx = new AudioContext(); // 创建音频上下文
let frames = samples;
let audioBuffer = ctx.createBuffer(channels, frames, 44100); // 创建音频缓冲区
let left = audioBuffer.getChannelData(0); // 获得两个声道的 PCM 数据数组
let right = audioBuffer.getChannelData(1);
let i = 0;
// 往两个数组里写入数据,值得注意的是这里每个采样的取值范围是 [-1. 1],
// 如果直接把 libmad 的输出 (mad_fixed_t aka signed long)写进去
// 回放的效果大概就是原歌曲音量放大 2 ^ 30 并强行限幅后的方波音乐了吧(
// 反正第一次我忘了这事儿,回放出来的东西把我吓死了(
for (i = 0; i < frames; ++i) {
left[i] = window.pcm.left[i] / (2 ** 30);
right[i] = window.pcm.right[i] / (2 ** 30);
}
// 将音频缓冲变为音频源的数据源,然后输出到音频上下文
let source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
// 开始播放咯
source.start();
}, output.channels, output.samples, output.p_output[0], output.p_output[1]);
#endif
}
#ifndef X86_64
EMSCRIPTEN_KEEPALIVE
#endif
// 当时拿来测试能不能成功执行一个函数的,懒得删了
void hello () {
printf("hello world\n");
}
#ifndef X86_64
EMSCRIPTEN_KEEPALIVE
#endif
// 测试 typed array 和 wasm 数组的转换
// 懒得删了
void test(uint8_t *array, size_t length) {
int i = 0;
for (i = 0; i != length; ++i) {
printf("%d ", array[i]);
}
printf("\n");
}
#ifndef X86_64
EMSCRIPTEN_KEEPALIVE
#endif
int main() {
#ifdef X86_64
// native 环境直接打开文件转换然后写入到外部的一个文件
// 就行了,主要是测试解码的代码能不能跑通的
FILE* fp = fopen("audio.mp3", "rb");
size_t file_size = 0;
uint8_t *input;
int i;
if (!fp) {
printf("Fuck you\n");
return 1;
}
fseek(fp, 0, SEEK_END);
file_size = ftell(fp);
input = (uint8_t *) malloc(file_size);
fseek(fp, 0, SEEK_SET);
fread(input, file_size, 1, fp);
fclose(fp); fp = NULL;
struct output_t output = decode_mp3_to_pcm(input, file_size);
fp = fopen("output.pcm", "wb+");
for (i = 0; i != output.samples; ++i) {
int channel = 0;
for (channel = 0; channel != output.channels; ++channel) {
fwrite(&output.p_output[channel][i], sizeof(output.p_output[channel][i]), 1, fp);
}
}
fclose(fp);
#else
// WASM 环境不需要执行 main,而是通过 JS 直接调用某一个 export 出来的函数
// 随便跑点东西搞点输出,意思一下就行了(
hello();
#endif
return 0;
}
比较重要的 JS 相关的代码都在注释里说明作用了。然后只要编译一下这个代码就行啦!这里我给了比较多的内存,主要是防止他崩掉(
mkdir server
emcc -I../build/include -L../build/lib main.c -lmad -s WASM=1 -o server/test.html -s TOTAL_MEMORY=268435456 -s EXTRA_EXPORTED_RUNTIME_METHODS='["ccall","cwrap","writeArrayToMemory"]' -O3
顺利编译后,随便找一个 MP3 的音频,放到 server
文件夹中,并启动一个 HTTP 服务器,root
就是这个 server
文件夹,比如
const express = require('express');
const server = express();
server.use('/', express.static('./server');
server.listen(8080);
不出意外的话,打开浏览器,访问 localhost:8080/test.html
就能看到一个看起来像终端的玩意儿里面输出了一行 Hello World 了。
现在我们再打开 JavaScript 控制台,调用 decode_mp3_to_pcm
函数来播放音频:
(async function () {
mp3 = await fetch('/audio.mp3'); // 换成你自己的音频 URI
data = new Uint8Array(await mp3.arrayBuffer());
buffer = Module._malloc(data.length);
Module.writeArrayToMemory(data, buffer);
Module.ccall('decode_mp3_to_pcm'
'null',
['number', 'number'],
[buffer, data.length])
})();
就成功啦,libmad 在我的笔记本上(i5 4200H,Google Chrome 64)需要花约 1.166 秒来进行解码,性能还算不错了吧……