一、背景:
最近在写毕设, 一个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
同理
// 第一种办法
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
<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
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 |
由 WebRTCMediaStream (如网络摄像头或麦克风)生成的音频源 (嘿, 看到麦克风, 你就知道这便是咱们想要的东西啦!, 不过不急, 先稳扎稳打来) |
- 处理音效的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
即可
navigator.mediaDevices.getUserMedia({
audio: true
}).then(stream => {
console.log(stream)
}).catch((err) => {})
此刻页面就会弹出需求框显现你是否允许网页运用您的麦克风, 假如制止的话自然走到代码中catch
的逻辑, 假如允许的话咱们就能够顺畅拿到音频轨迹
三、录制音频思路
ScriptProcessorNode完成
这儿先来一个运用ScriptProcessorNode
的思路, 由于这不是咱们的目标写法, 故这儿只会主体流程, 详细的流程能够看注释, 我想很明晰了
<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()
指定称号和该处理类
主结构如下
// 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
模块称号以及能够传递自定义参数曩昔
// 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
一共是完成了四个功用
<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
类供给了以上四个功用, 注释都有解释了这儿就不赘述了
// 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
传回来即可
// 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
去向理了(我的图打错字了。。。, 点击停止路由->点击停止录音)
兼容性
上述两者的兼容性感觉差不多的,横竖都不考虑IE。 但ScriptProcessorNode
究竟现已抛弃啦, 随时都有不能运用的危险