一个Chrome插件开发项目Web Audio API的心得

2020-12-27 15:14 Web前端学习笔记

图片



前段时间公司接了一个Chrome插件开发的小项目,主要就是将用户在浏览器端参与电话会议的音频进行降噪处理后输出,能够被zoom、skype等在线会议系统所采用的并传送给对方。
最初只是老老实实的调研Web Audio API的一系列内容,经过各种尝试之后也只是将获取到的microphone音频输出到speaker。但是这样实际上只是又增加了一路音频,在Windows平台上对方能够听到两路(原始音频和降噪后音频)叠加的声音,但是在ChromeBook和Mac OS等平台上就只能听到原始音频。
准备放弃之际,客户提供了一个日本公司的插件,说是能实现期望的效果,虽然是uglify的,但也勉强能看懂部分代码,一堆折腾之后是体验到了思维的“变态”之处。

1、劫持getUserMedia()
使用Web Audio API处理用户麦克风输入时都需要通过该方法获取用户授权,然后获得对应输入设备的音频流。
正常利用Web Audio API是没有办法修改用户麦克风输入流数据的,一般只是获取之后用于录音、播放等,除非是做zoom、skype本身的逻辑时可以对采集到的的音频流处理之后才播放给其他与会者。但是那个插件恰恰就是利用劫持getUserMedia()方法的方式,给zoom、skpye等返回了一个包装过的MediaStream,这样达到了篡改Web应用获取到的用户麦克风数据。
我参照类似思想把劫持逻辑实现后发现还是不好用,经过调试发现,因为插件是popup、content和background三块,我是在content中实现的,content只可以访问页面的DOM,而无法访问BOM。content更无法访问到用户页面的函数、数据等。

2、向Page注入脚本
在压缩后的上万行代码中一顿摸索后,发现了关键。他们实现劫持逻辑的代码是在一个独立js文件中,并不是在content内加载的。
1. 获取插件路径,封装插件内独立js的完整路径
2. 通过content脚本创建<script>标签,插入到DOM上
这样,被插入到Page内的那段脚本A就有了访问BOM的权限,并成功劫持了navigator.mediaDevices.getUserMedia()
而且在完成注入之后,插件立即将插入到页面的<script>标签删除,但是因为对象引用存在,所以对应脚本还是生效的,也就利用类似内存泄漏的方式抹掉了较为明显的注入痕迹。
另外需要注意,因为需要在页面初始化之前就替换掉navigator.mediaDevices.getUserMedia(),所以content的加载时间是配置为文档初始化时,也就是"run_at": "document_start"。此时无法获取到<head>或者<body>,需要通过document.documentElement进行处理。

3、通过<script>的attribute传递参数
因为Web Audio API主要靠AudioWorkletNode进行音频处理,而这个和起一个线程是一样的,需要一个独立的脚本文件,而且加上本身注入的脚本也需要一些初始化参数,所以是通过<script>的attribute传递的。
在脚本A刚一加载,就通过document.currentScript.getAttribute获取被配置到<script>标签上的属性值,变相的接收到了初始化参数。

4、WebAssembly加载
当完成了上述操作之后,加载核心处理算法时又遇到了问题。因为降噪算法客户是用C函数写的,并用emcc编译为WebAssembly库给JS使用,默认是一个.wasm文件,可以自行引用.wasm文件并调用浏览器方法进行编译,也可以直接引用emcc生成js文件自动加载及编译。
但是由于AudioWorkletNode的Processor是独立线程的js文件,浏览器不允许它再另行加载其他js,所以在AudioWorkletNode内加载WebAssembly库始终失败。
此时又转向那个日本插件寻求帮助,翻来翻去他们的算法也是在WebAssembly库内实现的,但是他们的WebAssembly库并非独立的文件,而是以base64的形式嵌入到了js中。翻了一下emscripten相关文档,果然有编译选项是把二进制内容也编译到js中的选项。
emcc libs.c -o libs.js -s EXPORTED_RUNTIME_METHODS="['ccall','cwrap']" -s SINGLE_FILE=1

就是SINGLE_FILE=1参数,这样就彻底把各种功能串了起来。

5、整理一下思路
(1)首先让content script在页面初始化前即加载:
"run_at": "document_start"
(2)然后通过document.documentElement向页面插入脚本:
const dom = document.head || document.getElementsByTagName("head")[0] || document.documentElement......dom.insertBefore(script, dom.firstChild)
(3)通过<script>的attribute传递初始化参数:
script.setAttribute('status', true)
(4)注入的脚本内劫持相关浏览器对象及方法:
const MD_GET_USER_MEDIA = navigator.mediaDevices.getUserMedia......navigator.mediaDevices.getUserMedia = this.getUserPatchMedia.bind(this, MD_GET_USER_MEDIA)
(5)劫持的getUserPatchMedia方法内对MediaStreamMediaStreamTrack进行移花接木,以便让第三方获取到处理过的音频流。
(6)利用AudioWorkletNode进行音频处理
this.audioCtx.audioWorklet.addModule(this.processorUrl).then(() => {        ......        this.filter = new AudioWorkletNode(this.audioCtx, 'enhancer-processor')      })
enhancer-processor内注册处理器:
registerProcessor('enhancer-processor', EnhancerProcessor)
(7)引用WebAssembly库时注意将二进制文件直接嵌入到enhancer-processor脚本内:
emcc libs.c -o libs.js -s EXPORTED_RUNTIME_METHODS="['ccall','cwrap']" -s SINGLE_FILE=1
能看到最终emcc生成的js文件中直接将wasm文件变成base64串嵌入到js中:
var wasmBinaryFile = 'data:application/octet-stream;base64,AGFzbQEAAAABpYCAgAAHYAF/AX9gAX8AYAAAYAABf2ADf39/AGADf39/AX9gA39+fwF+Aq+AgIAAAg
(8)在enhancer-processor内进行音频处理:
  process(inputs, outputs, parameters) {    ......    const inputChannels = inputs[0]    const outputChannels = outputs[0]    // 遍历所有声道进行处理    for (let i = 0; i < inputChannels.length; i++) {      let outputDataReduced = doNoiseMaskMicrophone(inputChannels[i])      outputChannels[i].set(outputDataReduced)    }    return true  }
其实如果只是简单的C函数调用(只有一些数字类型的传参和返回值),完全可以自己引用wasm然后调用,代码量很少。但是对于涉及到各种字符串、指针、内存等操作(这种音频流处理肯定涉及到内存申请、指针操作、出参等),建议还是直接用emcc生成的脚本,虽然代码量巨大,但是解决了js和c内存交换的问题。
经过上面的一顿“骚”操作,终于将目的实现了,做这个项目确实学会了很多插件开发的技巧,有些之前用过,但是综合性组合起来还是蛮有意思的。

图片

本文章转载自公众号:WebFrontNote

首页 - 前端 相关的更多文章: