一文拿下Web端基于AudioWorkletNode录制音频

一、背景:

最近在写毕设, 一个Web即时通讯平台, 其中我想加个功能, 就是可以录制音频发送

故我先在网上找寻了一下, 看到了挺多大佬文章都是基于ScriptProcessorNode去实现功能, 比如说

我一开始直接使用了js-audio-recorder, 不过在使用的过程中发现已经给出了警告说ScriptProcessorNode已经被摒弃了。 目前需要用AudioWorkletNode去替换掉。 这一下子就提起我的兴趣了

故又找到了MDN去确认一下, 可以看到在14年该API就被弃用了,随时可能无法正常工作。 当然, 我试过了目前还是能用的, 但是按捺不住我想尝试的心啦

故此篇会探索一下如何使用AudioWorkletNode去实现功能。

当然,在此之前要先研究一下, 录制音频功能, 具体需要干嘛

研究之前, 总要先打好基础吧

二、基础铺垫

Web Audio

一段介绍让你快速走进Web AudioWeb Audio API 并不会取代<audio>音频元素,倒不如说它是<audio>的补充,就好比如<canvas><img>共存的关系。如果你想实现更多复杂的音频处理,以及播放,Web Audio API 提供了更多的优势以及控制

OK, 简单总结一下它的定位就是比<Audio>提供更多逻辑功能的API

Web Audio API 使用户可以AudioContext中进行音频操作,在Aduio Node上操作进行基础的音频,它们连接在一起构成Audio Routing Graph。音频节点通过它们的输入输出相互连接,形成一个链或者一个简单的网。这些节点的输出可以连接到其他节点的输入上,然后新节点可以对接收到的采样数据再进行其他的处理,再形成一个结果流。 一般来说,这个链或网起始于一个或多个音频源。音处理完成之后,可以连接到一个目的地AudioContext.destination,这个目的地负责把声音数据传输给扬声器或者耳机

Audio Node

正如上面所描述,Audio Routing Graph就是由一个个Audio Node连接形成的

一个 AudioNode 可以既有输入也有输出。输入与输出都有一定数量的通道。

只有一个输出而没有输入的AudioNode 叫做音频源

只有输入而没有输出的AudioNode叫做destination

AudioNode之间的连接通过connect方法实现。

接下去会先介绍两种AudioNode, 然后提供小demo,让你快速了解Web Audio API的工作流程, 接着对Web Audio API提供的 AudioNode进行总结

MediaElementAudioSourceNode

该节点对象可以由AudioContext.createMediaElementSource, 也可以通过new MediaElementAudioSourceNode创建。其他Audio Node同理

js 复制代码
// 第一种方法
const context = new AudioContext();
const source = context.createMediaElementSource(myMediaElement);
// 第二种方法,  element从options传入
const context = new AudioContext();
const source = new MediaElementAudioSourceNode(context, options)  

他没有输入,且只有一个输出。 故是一个音频源。

GainNode

一个 GainNode 始终只有一个输入和一个输出,两者拥有同样数量的声道。 它用于表示音量的变化

增益是一个无单位的值,会对所有输入声道的音频进行相应的增加(相乘)。如果进行了修改,则会立即应用新增益

这个提供了一个属性,GainNode.gain,是一个AudioParam, 表示应用的增益量。 可以通过AudioParam.value或者AudioParam的方法来改变增益效果

  • 这里使用AudioParam的方法相对来说会更理想, 他利用AudioContext.currentTime提供了一些API例如安排在一个确切的时间,更改 AudioParam 的值或者从某个时间到某个时间进行线性的变化。
  • 每个 AudioParam 都有一个初始化为空的事件列表,用于定义值在何时发生的具体变化。当该列表不为空时,将忽略使用 AudioParam.value 属性进行的更改。
改变音量的小demo

这里由以上两个Audio Node入手, 我们来写个小demo。嘿嘿这里使用了我挺喜欢的一首歌 《带我去很远的地方》

当我们什么都不更改的时候。 其代码如下

其实就是Web Audio API拿到<audio>的输入, 经过Audio Node处理之后, 回交给destination

js 复制代码
<body>
    <div>
        <audio controls src="./黄霄雲 - 带我去很远地方.mp3" ></audio>
    </div>
    <script>
        const audioElement = document.querySelector('audio');
        audioElement.addEventListener('play', () => {
            // 创建Audio Context
            const audioContext = new AudioContext();
            // 创建音频源, 之前提到的创建Audio Node第二种方法
            const source = new MediaElementAudioSourceNode(audioContext, {
                mediaElement: audioElement // 传入Audio节点
            })
            // 创建一个Gain节点, 这是提到的创建Audio Node的第一种方法
            const gainNode = audioContext.createGain();
            // 将Audio Node连接起来形成 Audio Routing Graph
            source.connect(gainNode);
            // 连接到其输出
            gainNode.connect(audioContext.destination);
        })
    </script>
</body>

此时的Audio Node Graph如下

接下去我们尝试通过GainNode去改变他的增益。 这里是做了一秒的节流, 根据鼠标在页面上Y轴上的坐标变化, 去调节音乐的音量大小, 正常音量值为1

js 复制代码
document.onmousemove = (e) => {
    if (timer) {
        return;
    }
    timer = setTimeout(() => {
        const CurY = e.pageY;
        console.log('当前音量为', CurY / HEIGHT);
        gainNode.gain.value = CurY / HEIGHT; // 就是通过修改value直接去修改了值
        timer = null;
    }, [1000])  
}

效果如下, GIF这里看不到鼠标的移动和音乐的变化, 所以我也把小demoindex.html - sandbox - CodeSandbox 放上来了, 可以玩一下hhh

小总结

总体来说Web Audio是提供了很多Audio Node的, 又可以将其分为几大类型

  • 音频源Audio Node

此类反正就是提供音源, 它的特征就是只有输出, 没有输入

Audio Node 作用
OscillatorNode 表示一个振荡器,它产生一个周期的波形信号(如正弦波), 会生成一个指定频率的波形信号(即一个固定的音调)
AudioBufferSourceNode 包含了一些写在内存中的音频数据,在处理有严格的时间精确度要求的回放的情形下它尤其有用, 通常用来控制小音频片段
MediaElementAudioSourceNode 由 HTML5 <audio><video>元素生成的音频源,前面介绍过了这里就不赘述了
MediaStreamAudioSourceNode 由 WebRTC MediaStream(如网络摄像头或麦克风)生成的音频源 (嘿, 看到麦克风, 你就知道这就是我们想要的东西啦!, 不过不急, 先稳扎稳打来)
  • 处理音效的Audio Node

这一类就是用来处理音频嘛, 自然要有输入, 也要处理完的输出

Audio Node 作用
GainNode 先上老熟人,这就是处理音频的增益效果嘛(对我而言感知上就是音量)
BiquadFilterNode 表示一个简单低阶滤波器(双二阶滤波器)
ConvolverNode 对给定的 AudioBuffer 执行线性卷积,通常用于实现混响效果
DelayNode 对输入进行延时输出的处理
DynamicsCompressorNode 提供了一个压缩效果器,用以降低信号中最响部分的音量,来协助避免在多个声音同时播放并叠加在一起的时候产生的削波失真
.... .....
  • 输出音频的Audio Node

这一类就是用来对音频进行输出的, 那么因为他本身就是输出嘛, 那他的特性就是只有输入, 没有输出

Audio Node 作用
AudioDestinationNode 定义了最后音频要输出到哪里, 我们可以通过audioContext.desitnation来查看, 通常都是到扬声器
MediaStreamAudioDestinationNode 定义了使用WebRTC 的MediaStream应该连接的目的地
  • 数据分析类Audio Node
Audio Node 作用
AnalyserNode 提供可分析的数据, 可以用于数据分析和可视化
  • JS操纵音频 Audio Node

前面说到的Node都是有固定的作用, 那么如果我只是想要拿到音源数据, 自定义操作, 再把他输出, 这个时候就需要用到ScriptProcessorNode

他用于通过 JavaScript 代码生成,处理,分析音频。它与两个缓冲区相连接,一个缓冲区里包含当前的输入数据,另一个缓冲区里包含着输出数据。每当新的音频数据被放入输入缓冲区,就会产生一个AudioProcessingEvent 事件,当这个事件处理结束时,输出缓冲区里应该写好了新数据。也就是说, 我们通过AudioProcessingEvent就可以去处理音频

至于AudioWorkletNode放在后面讲

讲完了Audio Node, 回归正题

你需要录制音频, 这个时候得要你给开权限吧,给你音频源吧, 这个时候就需要用到MediaDevices.getUserMedia

MediaDevices.getUserMedia

MediaDevices是由Navigator.mediaDevices的一个对象, 用于提供对相机和麦克风等媒体输入设备以及屏幕共享的连接访问。

在MDN介绍了,其提供的功能只能在安全上下文中使用了, 我看了一下定义, 就是使用了https协议, wss协议或者本地传递资源(例如http://localhost, http://127.0.0.1

MediaDevices.getUserMedia: 提示用户给予使用媒体输入的许可,媒体输入会产生一个MediaStream,里面包含了请求的媒体类型的轨道。这个流可以包括音频轨道,视频轨道也可能是其他轨道类型。

在此需求下我们只需要用到音频轨道, 故传入的constraints对象只需要指定audiotrue即可

js 复制代码
navigator.mediaDevices.getUserMedia({
    audio: true
}).then(stream => {
    console.log(stream)
}).catch((err) => {})

此时页面就会弹出需要框显示你是否允许网页使用您的麦克风, 如果禁止的话自然走到代码中catch的逻辑, 如果允许的话我们就能够顺利拿到音频轨道

三、录制音频思路

ScriptProcessorNode实现

这里先来一个使用ScriptProcessorNode的思路, 因为这不是我们的目标写法, 故这里只会主体流程, 具体的流程可以看注释, 我想很清晰了

js 复制代码
<body>
    <div class="start">开始录音</div>
    <script>
        document.querySelector('.start').addEventListener('click', async () => {
            // 创建好Audio Context上下文
            const context = new AudioContext();
            // 通过ScriptProcessorNode去拿到音频数据
            const recorder = context.createScriptProcessor();
            // 当音频数据进入Input buffer的时候就会触发该函数
            recorder.onaudioprocess = (e) => {
                // 这里拿到数据就可以去处理成我们想要的数据格式了
                console.log(e);
            }
            // 拿到音频轨道
            const stream = await navigator.mediaDevices.getUserMedia({
                audio: true,
            });
            // 通过音源Audio Node输入
            const audioInput = context.createMediaStreamSource(stream);
            // Audio Input之后拿到我们的ScriptProcessorNode, 故此处通过connect进行连接
            audioInput.connect(recorder);
            // 这里其实要不要给扬声器都无所谓,反正如果ScriptProcessorNode不把数据放到outputBuffer的话也没声
            recorder.connect(context.destination);
        })
    </script>
</body>

此时的流程图如图所示

至于recorder.onaudioprocess里的处理就是通过e.inputBuffer.getChannelData()拿到左右声道数据(如图所示), 然后交叉合并左右声道的数据, 最后将数据放入创建的wav文件即可, 这里不赘述了

AudioWorkletNode实现

在介绍他之前, 我们不妨想一下ScriptProcessorNode被废弃的原因,音频的录制可以说是一个高频触发且计算成本高昂的过程, 打印一下InputBuffer可以看到onaudioprocess40毫秒就会执行一次。

我们JS是单线程的,主线程还要处理各种UI和DOM相关的任务, 那这种情况下可能就会导致要么UI卡顿,要么音频故障。 那么我们自然也希望将音频处理相关的计算从主线程中移出去, 像web worker这样, 另外找线程去负责它

Audio Worklet就是很好地将用户提供地代码放在音频处理线程中进行处理, 故而避免了上述的情况发生

这里要注意: Audio Worklet和MediaDevices一样, 都是只能在安全上下文中使用

使用AudioWorkletNode需要分为两步走

第一步是注册一个AudioWorkletProcessor, 处于AudioWorkletGlobalScope上下文中, 并且最后运行于Web Audio rending thread

第二步是生成一个建立在AudioWorkletProcessor基础上的AudioWorkletNode运行在主线程上

AudioWorkletProcessor

对于AudioWorkletProcessor而言, 我们的操作是

  • AudioWorkletProcessor 接口派生一个子类, 然后必须定义一个process方法用来操纵音频。
  • 该子类中必须实现process()方法, 用于处理传入的音源数据并且写回(默认是没有写回的,所以就算你连接了destination也不会有声音到达), 其返回值决定了是否让节点保持活跃状态。
    • 返回true的话则强制保持节点处于活跃状态
    • 返回false的话则允许在安全的情况下(没有新的音频数据传进来且没有正在处理的数据)终止节点
  • 调用registerProcessor()指定名称和该处理类

主结构如下

js 复制代码
// processor.js
class RecorderProcessor extends AudioWorkletProcessor  {
    constructor(options) {
        super(options);
    }
    process(inputs, outputs, parameters) {
        console.log(inputs); // 这里就拿到了输入的数据了
        return true
    }
}
registerProcessor('recorder-processor', RecorderProcessor)
AudioWorkletNode

对于AudioWorkletNode而言我们的操作是

  • 通过addModule对编写processor的模块加入
  • 实例化AudioWorkletNode, 此时需要指定processor模块名称以及可以传递自定义参数过去
js 复制代码
// index.js
this.context = new AudioContext();
await this.context.audioWorklet.addModule('./processor.js');
this.recorder = new AudioWorkletNode(
    this.context,
    "recorder-processor",
    {  // 自定义参数
        processorOptions:...
    }
    
)
实现录制音频

OK回归正题, 那么如何去实现录制音频呢, 其实和ScriptProcessorNode差不多的, 对于主流程的话就是换了一个Audio Node参与进来而已 , 其他该怎么处理就还是怎么处理。 这里还是提供了一个小demo

一共是实现了四个功能

js 复制代码
<body>
    <button class="start">开始录音</button>
    <button class="end">终止录音</button>
    <button class="play">播放录音</button>
    <button class="get">拿到mav数据</button>
    <script src="./index.js" type="text/javascript"></script>
</body>

实现了一个Recorder类提供了以上四个功能, 注释都有解释了这里就不赘述了

js 复制代码
// index.js
class Recorder {
    async _initRecorder() {
        this.isNeedRecorder = true;
        // 创建上下文
        this.context = new AudioContext();
        // 加入processor模块
        await this.context.audioWorklet.addModule('./processor.js');
        // 实例化AudioWorkletNode
        this.recorder = new AudioWorkletNode(
            this.context,
            "recorder-processor",
            {
                processorOptions: {
                    // 这里将isNeedRecoreder传过去了
                    isNeedRecorder: this.isNeedRecorder
                }
            }
        )
        // 然后开始订阅消息, 这里主要是为了停止的时候能够拿到数据
        this.recorder.port.onmessage = (e) => {
            if (e.data.type === 'result') {
                this.resultData = e.data.data;
            }
        }
        
    }
    // 开始录音
    async startRecorder() {
        // 先初始化
        await this._initRecorder();
        // 获得权限拿到音频轨道
        this.stream = await navigator.mediaDevices.getUserMedia({
            audio: true,
        });
        // 实例化MediaStreamSourceNode拿到作为音源Audio Node
        this.audioInput = this.context.createMediaStreamSource(this.stream);
        // 然后连接起来
        this.audioInput.connect(this.recorder);
        this.recorder.connect(this.context.destination);
    }
    // 停止录音
    async stopRecorder() {
        this.isNeedRecorder = false;
        // 为了让processor不再活跃了,否则会一直调用process方法
        this.recorder.port.postMessage('stop'); 
        // 不再录入音频
        this.stream.getAudioTracks()[0].stop();
        // 断开连接
        this.audioInput.disconnect();
        this.recorder.disconnect();   
    }
    // 拿到数据
    getData() {
        // 还没停止的话要先停止录音
        if (this.isNeedRecorder) {
            this.stopRecorder();
        }
        // 拿到Wav数据
        const data = createWavFile(this.resultData);
        // 生成blob数据
        this.blobData = new Blob([data], { type: 'audio/wav' });
        return this.blobData;
    }
    // 播放
    play() {
        // 没有blob数据的话需要先获取
        if (!this.blobData) {
            this.getData();
        } 
        // 然后丢给audio播放就好了
        const blobUrl = URL.createObjectURL(this.blobData);
        const audio = new Audio();
        audio.src = blobUrl;
        audio.play();
    }

}

对于processor文件呢主要就是拿到数据,然后再停止录音的时候处理数据, 然后将数据通过port.postMessage传回来即可

js 复制代码
// processor.js
class RecorderProcessor extends AudioWorkletProcessor  {
    constructor(options) {
        super(options);
        // 拿到AudioWorkletNode传过来的参数
        this.isNeedProcess = options.processorOptions.isNeedRecorder;
        this.LBuffer = [];
        this.RBuffer = [];
        // 在停止的时候将数据传回去
        this.port.onmessage = (e) => {
            if (e.data === 'stop') {
                this.isNeedProcess = false;
                const leftData = this.flatArray(this.LBuffer);
                const rightData = this.flatArray(this.RBuffer);
                this.port.postMessage({
                    type: 'result',
                    data: this.interleaveLeftAndRight(leftData, rightData),
                })
            }   
        }
    }
    // 二维转一维
    flatArray(list) {
        // 拿到总长度
        const length = list.length * list[0].length;
        const data = new Float32Array(length);
        let offset = 0;
        for(let i = 0; i < list.length; i++) {
            data.set(list[i], offset);
            offset += list[i].length;
        }
        return data
    }
    // 交叉合并左右数据
    interleaveLeftAndRight(left, right) {
        const length = left.length + right.length;
        const data = new Float32Array(length);
        for (let i = 0; i < left.length; i++) {
            const k = i * 2;
            data[k] = left[i];
            data[k + 1] = right[i]; 
        }
        return data;
    }
    process(inputs) {
        const inputList = inputs[0];
        if (inputList && inputList[0] && inputList[1]) {
            // 这里不能直接push进去数据, 要么浅拷贝要么转化了再存进去!!!
            // 不然你录出来的声音就是吱吱吱吱吱吱吱!
            // 害我找大半天bug以为是后面的数据处理有问题
            this.LBuffer.push(new Float32Array(inputList[0]));
            this.RBuffer.push(new Float32Array(inputList[1]));
        }
        return this.isNeedProcess
    }
}

registerProcessor('recorder-processor', RecorderProcessor)

完整一点的代码:index.js - sandbox - CodeSandbox

其中处理wav的代码copy了上述提到的大佬的文章, 因为我确实不会

最后再录个performance看一下, 可以看到确实是交给AudioWorklet thread 去处理了

相关推荐
蜗牛快跑2136 分钟前
面向对象编程 vs 函数式编程
前端·函数式编程·面向对象编程
Dread_lxy7 分钟前
vue 依赖注入(Provide、Inject )和混入(mixins)
前端·javascript·vue.js
涔溪1 小时前
Ecmascript(ES)标准
前端·elasticsearch·ecmascript
榴莲千丞1 小时前
第8章利用CSS制作导航菜单
前端·css
奔跑草-1 小时前
【前端】深入浅出 - TypeScript 的详细讲解
前端·javascript·react.js·typescript
羡与1 小时前
echarts-gl 3D柱状图配置
前端·javascript·echarts
guokanglun1 小时前
CSS样式实现3D效果
前端·css·3d
咔咔库奇1 小时前
ES6进阶知识一
前端·ecmascript·es6
前端郭德纲1 小时前
浏览器是加载ES6模块的?
javascript·算法
JerryXZR2 小时前
JavaScript核心编程 - 原型链 作用域 与 执行上下文
开发语言·javascript·原型模式