一、背景:
最近在写毕设, 一个Web即时通讯平台, 其中我想加个功能, 就是可以录制音频发送
故我先在网上找寻了一下, 看到了挺多大佬文章都是基于ScriptProcessorNode
去实现功能, 比如说
我一开始直接使用了js-audio-recorder
, 不过在使用的过程中发现已经给出了警告说ScriptProcessorNode
已经被摒弃了。 目前需要用AudioWorkletNode
去替换掉。 这一下子就提起我的兴趣了
故又找到了MDN去确认一下, 可以看到在14年该API就被弃用了,随时可能无法正常工作。 当然, 我试过了目前还是能用的, 但是按捺不住我想尝试的心啦
故此篇会探索一下如何使用AudioWorkletNode
去实现功能。
当然,在此之前要先研究一下, 录制音频功能, 具体需要干嘛
研究之前, 总要先打好基础吧
二、基础铺垫
Web Audio
一段介绍让你快速走进Web Audio
: Web 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
对象只需要指定audio
为true
即可
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
可以看到onaudioprocess
40毫秒就会执行一次。
我们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
去处理了