一、背景:

最近在写毕设, 一个Web即时通讯渠道, 其中我想加个功用, 便是能够录制音频发送

故我先在网上找寻了一下, 看到了挺多大佬文章都是根据ScriptProcessorNode去完成功用, 比方说

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

一文拿下Web端根据AudioWorkletNode录制音频

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

一文拿下Web端根据AudioWorkletNode录制音频

故此篇会探究一下怎样运用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,这个目的地担任把声响数据传输给扬声器或许耳机

一文拿下Web端根据AudioWorkletNode录制音频

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如下

一文拿下Web端根据AudioWorkletNode录制音频

接下去咱们测验经过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对象只需求指定audiotrue即可

navigator.mediaDevices.getUserMedia({
    audio: true
}).then(stream => {
    console.log(stream)
}).catch((err) => {})

此刻页面就会弹出需求框显现你是否允许网页运用您的麦克风, 假如制止的话自然走到代码中catch的逻辑, 假如允许的话咱们就能够顺畅拿到音频轨迹

一文拿下Web端根据AudioWorkletNode录制音频

三、录制音频思路

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>

此刻的流程图如图所示

一文拿下Web端根据AudioWorkletNode录制音频
至于recorder.onaudioprocess里的处理便是经过e.inputBuffer.getChannelData()拿到左右声道数据(如图所示), 然后穿插兼并左右声道的数据, 最终将数据放入创立的wav文件即可, 这儿不赘述了
一文拿下Web端根据AudioWorkletNode录制音频

AudioWorkletNode完成

在介绍他之前, 咱们无妨想一下ScriptProcessorNode被抛弃的原因,音频的录制能够说是一个高频触发且核算成本昂扬的进程, 打印一下InputBuffer能够看到onaudioprocess40毫秒就会履行一次。

一文拿下Web端根据AudioWorkletNode录制音频

咱们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 去向理了(我的图打错字了。。。, 点击停止路由->点击停止录音)

一文拿下Web端根据AudioWorkletNode录制音频

兼容性

上述两者的兼容性感觉差不多的,横竖都不考虑IE。 但ScriptProcessorNode究竟现已抛弃啦, 随时都有不能运用的危险

一文拿下Web端根据AudioWorkletNode录制音频
一文拿下Web端根据AudioWorkletNode录制音频