|
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 所示。图 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图 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 可注册的函数最多数量emccpcm2aac.c-Os-lavcodec-lavutil-lswresample\-L../fflibs/lib-I../fflibs/include\-Wno-implicit-function-declaration\-sTOTAL_MEMORY=33554432\-sMODULARIZE=1\-sEXPORT_NAME=m\-sEXPORTED_FUNCTIONS='["_init_callback","_encode_one_frame","_init_encoder","_free_encoder","_flush","_malloc"]'\-sEXPORTED_RUNTIME_METHODS='["addFunction"]'\-sRESERVED_FUNCTION_POINTERS=20\-opcm2aac.js编译后得到 pcm2aac.js、pcm2aac.wasm5. WebAssembly 前端使用视角JavaScript 侧对 WebAssembly 的使用主要分以下几步:加载 WebAssembly 胶水代码并执行这里在执行作用域下得到导出的 m 函数,如果 WebAssembly 使用在 worker 环境中,可以通过importScript(胶水代码 JavaScript 路径) 直接执行得到导出的函数,如下代码所示。consttext=awaitfetch(`胶水代码js路径`).then((res)=>res.text())newFunction(`self.exports={};${text}`)()constWasmModule=self.exports.m导出函数调用胶水代码内部完成 WebAssembly 文件的加载和实例化;由于胶水代码和 WebAssembly 一般托管在 cdn上,文件路径可配置,这里执行 WasmModule 函数时可以指定 instantiateWasm 钩子函数,实现自定义文件加载或者借助 locateFile 实现,如下代码所示。WasmModule({instantiateWasm:function(info,receiveInstance){fetch(`wasm文件路径`).then((res)=>res.arrayBuffer()).then((buffer)=>{returnWebAssembly.instantiate(buffer,info)}).then(function(result){returnreceiveInstance(result.instance)}).then(()=>{logger.log('aacmoduleinited')}).catch((e)=>{})},}).then(module=>{})WebAssembly 注册回调函数WebAssembly 实例化后可以通过 module 对象访问编译选项中指定的 _init_callback、_init_encoder 等函数,这里比较重要的点是注册 WebAssembly 程序中需要执行的回调函数,如下代码所示。functionaacOutput(ptr,size){//通过指定的编码后的aac数据在内存中的开始位置和长度,将编码数据从wasm实例内存中copy出来constbuf=this._module.HEAPU8.subarray(ptr,ptr+size)}//注册回调函数,得到函数指针constfnPtr=module.addFunction(aacOutput,'vii')//指定返回值类型和参数类型//wasm程序中执行回调函数初始化module._init_callback(fnPtr)this._module=modulewasm程序中init_callback实现typedefvoid(*OutputCallback)(uint8_t*buff,intsize);OutputCallbackcallback=NULL;voidinit_callback(longfn){callback=(void(*)(unsignedchar*,int))fn;}ACC 编码WebAssembly 实例初始化完成之后,在 JavaScript 代码中使用完成编码流程,如下代码所示。functionencode(pcmBuffer:Uint8Array){//allocbufferthatwasmcanprocessconstptr=this._module._malloc(pcmBuffer.length)//copypcmdatatoallocatedmemorythis._module.HEAPU8.subarray(ptr,ptr+b.length).set(pcmBuffer)constret=this._module._encode_one_frame(ptr)if(ret
|
|