Decode and playback MP3 in web browser using Web Assembly

Notice

This is an old article and only available in Chinese. If you need a translation, please leave a comment and I will do my best to provide it as soon as possible.

纯属闲得蛋疼

实际运行效果

首先我们需要准备好 WebAssembly 的工具链(大概可以这么叫吧),此处请参考 Compiling from C/C++ to WebAssembly | MDN 中的步骤 来完成。对于 Arch Linux 用户,可以从 AUR 中安装 emsdk

1
yaourt -S emsdk

之后则是和 MDN 中的步骤一样,编译并配置 LLVM 和 Emscripten SDK:

1
2
sudo emsdk install --build=Release sdk-incoming-64bit binaryen-master-64bit
sudo emsdk activate --global --build=Release sdk-incoming-64bit binaryen-master-64bit

完成后,将相关目录添加到环境变量,方法很多,例如在 .zshrc 最后加上:

1
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 三连:

1
2
3
4
5
6
7
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 时提供的一个地址,用作当前解码的上下文变量。

以下晒代码:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
#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 相关的代码都在注释里说明作用了。然后只要编译一下这个代码就行啦!这里我给了比较多的内存,主要是防止他崩掉(

1
2
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 文件夹,比如

1
2
3
4
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 函数来播放音频:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
(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 秒来进行解码,性能还算不错了吧……

comments powered by Disqus
Except where otherwise noted, content on this blog is licensed under CC-BY 2.0.
Built with Hugo
Theme Stack designed by Jimmy