第十四章 WebAssembly 在 ACC 音频编码中的应用

2,321 阅读8分钟

作者:王伟

1. 前言

目前 PC Web 侧实现直播推流通常基于 WebRTC 技术,视频编码为 H264/VP8 格式,音频编码为 Opus 格式,而通常的下行直播分发协议如 Flv、Hls 等挟带的视频数据编码格式为 H264,音频数据编码格式为 AAC,这中间存在流媒体服务器需要将 Opus 音频转码为 AAC 音频的工作,增加了流媒体服务器转码成本和转码稳定性问题。如果能够在直播推流时直接推送 AAC 音频数据,就可以省去流媒体服务器音频转码部分的开销。为了解决推流 AAC 的问题,我们选择在 Web 侧自己实现推流能力。

推流主要分三部分工作,音视频采集、编码、上行传输。采集借助浏览器暴露的摄像头、麦克风采集 API,视频编码借助 WebCodec API,传输借助 WebSocket、WebTransport API。只有音频编码的工作由于目前 WebCodec 暂不支持对 AAC 音频格式的编码,需要寻找音频编码的替代方案。

音视频编解码工作是 CPU 密集型任务,业界相关方案实现大多基于 C/C++ 语言编写。这其中,FFmpeg 作为多媒体处理领域强大的软件实现,提供了简洁的 API 使用方式,能够满足对 AAC 音频编码的需求。而基于 C/C++ 的源代码编译到 WebAssembly 也有成熟的工具链。所以,在本方案中,我们借助 FFmpeg 和 WebAssembly 技术, 实现一段 AAC 音频编码的程序并且编译为 WebAssembly 字节码文件,应用在浏览器环境来完成音频的编码功能。

2. FFmpeg 基本模块介绍

FFmpeg 是一个功能强大的开源多媒体处理软件,像 Chromium 内核、众多移动端播放器、众多流媒体提供商的流媒体服务器都会基于或者扩展 FFmpeg 来实现编解码、转码、录制等能力。

FFmpeg 模块划分比较清晰,主要分为封装格式处理相关、编解码相关、单帧图像缩放,像素格式转换相关、音频重采样相关、音视频滤镜处理相关;分为如下几个模块:

  • libavformat

音视频封装格式及IO处理相关,主要完成音视频流读写、解封装、转封装功能。比如 ts文件转mp4,只是封装格式层面的处理。

  • libavcodec

音视频编解码处理相关,主要完成解码、编码工作。比如 H264解码成图像数据,图像数据编码成H265格式。

  • libswscale

视频帧图像缩放、像素格式转换相关。比如 yuv420表示的图像转换成 yuv422表示格式

  • libswresample

音频重采样、格式转换相关。比如 48000采样率 -> 44100采样率,f32le -> s16p

  • libavfilter

滤镜相关,音视频单帧数据滤镜处理。ffmpeg 内置马赛克、水印、叠加等大量滤镜效果

FFmpeg 工作过程是个流水线,以 Mp4 格式 H264 编码的视频转码为 Mp4 格式 H265 编码的视频为例;Mp4 源视频经过 libavformat 进行格式解析,得到每一帧的编码后的视频数据;视频数据经过 libavcodec 进行解码得到原始图像数据,这里可以通过 libavfilter 进行添加水印等滤镜处理,处理后的图像数据通过 libavcodec 进行 H265 格式编码,编码后的数据再经过 libavformat 进行格式封装,最终输出转码后的视频,如下图 1 所示。

14-1.png

图 1. FFmpeg 工作流示意图

在我们的使用场景中,需要实现对采集的音频原始数据( PCM 格式)进行重采样和 AAC 格式编码的功能,主要借助 libavcodec(实现音频编码),libswresample(实现重采样)两个模块,会用到模块内定义的一些结构体和 API 函数;我们将在下面的小节中进行详细介绍。

3. AAC 编码基本流程

音频规格主要关注采样率、channel 声道数、码率等几个指标;其中,采样率表示对音频输入采集量化后每秒采样的数量,常见的采样率有48000、44100、22050;channel 声道数表示单声道,左右两声道等。在我们的方案中,为简单起见,会限制编码输入的 PCM 音频源数据为 48000 采样率 + 单声道,编码输出可以指定常规的采样率、声道数和特定的码率,AAC 音频格式选择 LC-AAC,即对连续的 1024 个采样编码为一帧音频。确定好输入之后,在 AAC 编码程序这部分实现重采样、声道数调整、指定码率编码。

重采样主要涉及 SwrContext[4]、AVAudioFifo[5] 两个结构体,编码涉及 AVPacket[6]、AVFrame[7]、AVCodecContext[8] 结构体。

  • SwrContext: 对指定规格的 PCM 输入转换成指定采样率、声道数的 PCM 输出

  • AVAudioFifo: FFmpeg 提供的 buffer队列,用于暂存音频数据。这里用于攒一帧(1024采样)数据后进行编码

  • AVPacket: 用于存放编码后的音频数据,数据存放在AVPacket->data字段中

  • AVFrame: 用于存放编码前的 PCM 数据,用于编码

  • AVCodecContext: 用于实现编码,主要使用avcodec_send_frame、avcodec_receive_packet 两个 API

14-2.png

图 2. WebAssembly 模块文件结构

如上图 2 所示,在对输入采样数据进行重采样时,以重采样成 44100 为例;1024 个输入采样重采样后得到 940 个采样,不够一帧 (1024 采样) 编码的数据,需要进行缓存,FFmpeg 针对这种场景提供了 AVAudioFifo 结构体以及相关 API (av_audio_fifo_alloc[9]、av_audio_fifo_write[10]、av_audio_fifo_size[11]、av_audio_fifo_read[12]),队列中攒够 1024 采样后使用 AVCondecContext 及相关 API 进行编码,编码后的 AAC 数据存放在 AVPacket 实例上,对数据 copy 后通过回调形式传回javascript层面消费。

4. 使用 Emscripten 编译

FFmpeg 基于 C 语言编写,目前主流的用于 C/C++ 原项目编译 WebAssembly 的工具链是 Emscripten。 Emscripten 以 clang 作为编译前端,对源代码生成 LLVM IR;Emscritpen 提供的 WebAssembly 编译后端对中间产物进行转换生成 WebAssembly 字节码;同时 Emscripten 提供 libc、libc++ 的标准实现,让代码能正常运行在浏览器环境(运行时提供)。Emscritpen 的编译产物有 WebAssembly 字节码、JavaScript 胶水代码;胶水代码用于加载和运行 WebAssembly 模块,同时提供一些语法糖 API,用于简化 JavaScript 和 WebAssembly 之间的交互;更多工具链相关内容可阅读课程的 "常用 WebAssembly 开发语言和工具链"章节内容。

这里需要进行 WebAssembly 编译的有两部分,第一,FFmpeg libxx 静态库,第二,基于 FFmpeg API 实现的 AAC 编码程序。

4.1 静态库编译

从 github 获取 FFmpeg 源代码,根目录下有一个 configure 脚本;因为 FFmpeg 适配多种 CPU 架构和支持丰富的视频格式和编解码格式,通过 configure 脚本可以按需启用能力,去除不必要的编译,减少包体积。完整编译配置可通过 ./configure -h 查看主要参数说明:

  • --prefix 指定编译输出目录

  • --cc 指定编译器为 emcc

  • 禁用汇编相关源代码

  • 去除 ffmpeg、ffplay 等命令行工具编译输出

  • --disable-everything 禁用所有封装格式、编解码能力、滤镜能力等

  • --enable-encoder=aac 启用唯一的 aac 编码能力

  • --enable-protocol=data 这里程序输入、输出以 buffer 非文件形式提供

emconfigure ./configure --prefix=$(pwd)/libsoutputsdir \
    --cc="emcc" --cxx="em++" --ar="emar" --ranlib="emranlib" --cpu=generic --target-os=none \
    --enable-small \
    --extra-cflags=-Os \
    --enable-cross-compile \
    --disable-inline-asm \
    --disable-x86asm \
    --disable-ffmpeg \
    --disable-ffplay \
    --disable-ffprobe \
    --disable-programs \
    --disable-doc \
    --disable-htmlpages \
    --disable-manpages \
    --disable-podpages \
    --disable-txtpages \
    --disable-swscale \
    --disable-devices \
    --disable-avdevice \
    --disable-avformat \
    --disable-avfilter \
    --disable-logging \
    --disable-videotoolbox \
    --disable-postproc \
    --disable-pthreads \
    --disable-os2threads \
    --disable-w32threads \
    --disable-network \
    --disable-debug \
    --disable-everything \
    --enable-protocol=data \
    --enable-encoder=aac \

执行上面脚本命令,生产对应的 Makefile;make 编译得到libavcodec、libswresample、libavutil 几个静态库。

4.2 AAC编码程序编译

C 版本的编码程序调试成功后,编译对应的 WebAssembly 版本,使用 emcc 编译前端配合常用的编译选项如下,完整的编译选项见 emcc [13][14]。

  • -s TOTAL_MEMORY: 指定为wasm程序初始及总分配的内存,单位字节,必须是 64K 的倍数。

  • -s MODULARIZE= 1,-s EXPORT_NAME: 配合使用,表示生成的 JavaScript 胶水代码导出一个函数,使用上调用函数执行得到 WebAssembly 模块实例;同时此函数接受一个对象,可以提供一些钩子函数用于自定义 WebAssembly 的加载机制。

  • -s EXPORTED_FUNCTIONS: WebAssembly 导出的用于在 JavaScript 侧调用的函数。

  • -s EXPORTED_RUNTIME_METHODS: emcc 提供的语法糖函数,通过 addFunction 注册的函数可以在 WebAssembly 程序中调用,用于 WebAssembly 指定结果回调给 JavaScript 使用。

  • -s RESERVED_FUNCTION_POINTERS: 指定通过 addFunction 可注册的函数最多数量

emcc pcm2aac.c -Os -lavcodec -lavutil -lswresample \
    -L../fflibs/lib -I../fflibs/include \
    -Wno-implicit-function-declaration \
    -s TOTAL_MEMORY=33554432 \
    -s MODULARIZE=1 \
    -s EXPORT_NAME=m \
    -s EXPORTED_FUNCTIONS='["_init_callback", "_encode_one_frame", "_init_encoder", "_free_encoder", "_flush", "_malloc"]' \
    -s EXPORTED_RUNTIME_METHODS='["addFunction"]' \
    -s RESERVED_FUNCTION_POINTERS=20 \
    -o pcm2aac.js

编译后得到 pcm2aac.js、pcm2aac.wasm

5. WebAssembly 前端使用视角

JavaScript 侧对 WebAssembly 的使用主要分以下几步:

  • 加载 WebAssembly 胶水代码并执行

这里在执行作用域下得到导出的 m 函数,如果 WebAssembly 使用在 worker 环境中,可以通过importScript(胶水代码 JavaScript 路径) 直接执行得到导出的函数,如下代码所示。

const text = await fetch(`胶水代码js路径`).then((res) => res.text())

new Function(`self.exports={};${text}`)()

const WasmModule = self.exports.m
  • 导出函数调用

胶水代码内部完成 WebAssembly 文件的加载和实例化;由于胶水代码和 WebAssembly 一般托管在 cdn上,文件路径可配置,这里执行 WasmModule 函数时可以指定 instantiateWasm 钩子函数,实现自定义文件加载或者借助 locateFile 实现,如下代码所示。

WasmModule({
            instantiateWasm: function (info, receiveInstance) {
                fetch(`wasm文件路径`)
                    .then((res) => res.arrayBuffer())
                    .then((buffer) => {
                        return WebAssembly.instantiate(buffer, info)
                    })
                    .then(function (result) {
                        return receiveInstance(result.instance)
                    })
                    .then(() => {
                        logger.log('aac module inited')
                    })
                    .catch((e) => {
                    })
            },
}).then(module => {
    
})
  • WebAssembly 注册回调函数

WebAssembly 实例化后可以通过 module 对象访问编译选项中指定的 _init_callback_init_encoder 等函数,这里比较重要的点是注册 WebAssembly 程序中需要执行的回调函数,如下代码所示。

function aacOutput (ptr, size) {
    // 通过指定的编码后的aac数据在内存中的开始位置和长度,将编码数据从wasm实例内存中copy出来
    const buf = this._module.HEAPU8.subarray(ptr, ptr + size)
}

// 注册回调函数,得到函数指针
const fnPtr = module.addFunction(aacOutput, 'vii') // 指定返回值类型和参数类型

// wasm程序中执行回调函数初始化
module._init_callback(fnPtr)
this._module = module


wasm程序中init_callback实现

typedef void (*OutputCallback)(uint8_t *buff, int size);
OutputCallback callback = NULL;
void init_callback(long fn)
{
    callback = (void (*)(unsigned char *, int))fn;
}
  • ACC 编码

WebAssembly 实例初始化完成之后,在 JavaScript 代码中使用完成编码流程,如下代码所示。

function encode(pcmBuffer: Uint8Array){
    // alloc buffer that wasm can process
    const ptr = this._module._malloc(pcmBuffer.length)

    // copy pcm data to allocated memory
    this._module.HEAPU8.subarray(ptr, ptr + b.length).set(pcmBuffer)
    
    const ret = this._module._encode_one_frame(ptr)
    if (ret < 0) {
        // 编码出错,异常处理
    }
}

6. 总结

WebAssembly 提供了一种标准的字节码规范;得益于浏览器虚拟机和编译工具链对 WebAssembly 标准的支持,前端使用场景上进一步拓宽了浏览器环境下对其他语言三方库的使用。在我们的场景中,WebAssembly 扮演的更多是翻译和桥梁的角色,借助于 WebAssembly 技术生态,将一些其他语言实现的优秀的解决方案搬到浏览器舞台;避免过度的二次开发的同时对程序的性能也有很大提升。这个过程中,如何编译 WebAssembly,如何与 JavaScript 交互等是相对固定的流程,重要的还是需要我们对其他语言以及使用其他语言实现的解决方案的理解与使用。总之,基于 WebAssembly,让前端应用开发有个更多可能。

7. 参考文献

[1]. FFmpeg Documentation: ffmpeg.org/doxygen/tru…
[2]. Emscripten: emscripten.org/docs/tools_…
[3]. FFmpeg : github.com/FFmpeg/FFmp…
[4]. SwrContext: ffmpeg.org/doxygen/tru…
[5]. AVAudioFifo: ffmpeg.org/doxygen/tru…
[6]. AVPacket: ffmpeg.org/doxygen/tru…
[7]. AVFrame: ffmpeg.org/doxygen/tru…
[8]. AVCodecContext: ffmpeg.org/doxygen/tru…
[9]. av_audio_fifo_alloc: ffmpeg.org/doxygen/tru…
[10]. av_audio_fifo_write: ffmpeg.org/doxygen/tru…
[11]. av_audio_fifo_size: ffmpeg.org/doxygen/tru…
[12]. av_audio_fifo_read: ffmpeg.org/doxygen/tru…
[13]. emcc: emscripten.org/docs/tools_…
[14]. options: github.com/emscripten-…


尾部关注.gif

扫码关注公众号 👆 追更不迷路