跳到主要内容

AudioWorkletNode

2024年02月06日
柏拉文
越努力,越幸运

一、认识


我们获取到的用户媒体流或者 Canvas 画布合成流之后, 初始化 AudioContext 音频上下文才。在音频上下文中通过 audioContext.createMediaStreamSource(stream) 创建音频处理的输入源, 也叫音源节点, 该接口可以从传入的媒体流(MediaStream)对象中抽取音频数据作为音频上下文(AudioContext)里的一个音源节点。 然后创建音频处理流, 我们优先采用 AudioWorklet/AudioWorkletNode 开启音频渲染线程来处理音频, 音频渲染线程将处理好的音频数据通过 postMessage 发送到主线程, 主线程通过 onmessage 来接收音频数据; 并在不支持的情况下回退到 ScriptProcessorNode 在主线程中处理音频, ScriptProcessorNode 通过 onaudioprocess 事件来处理音频数据。最后, 音源节点连接音频处理流、连接音频输出节点(扬声器)。这样,音源发出的音频信号在输出到扬声器前,会先经过自定义的音频处理器处理。在音频处理部分, 我们分别实现了 16kHz 音频降采样音频响度 RMS 均方根音频强度 dB 分贝值音频音量百分比16-bit PCM 音频格式转换语音活动检测(VAD

音频采样工作流如下:

一、获取用户媒体流: 上面已经获取的用户媒体流或者 Canvas 画布合成流都可以。并在 getUserMedia audio 参数中增加 noiseSuppression: true 降噪、echoCancellation: true 回声消除和 autoGainControl: true 自动增益。

二、创建音频上下文: 实例化一个新的 AudioContext 上下文, 用于处理和控制音频操作, 提供了一个用于音频处理和合成的工作环境。

三、创建音源节点(音频处理的输入源): 通过 audioContext.createMediaStreamSource(stream) 创建一个接口, 该接口可以从传入的媒体流(MediaStream)对象中抽取音频数据作为音频上下文(AudioContext)里的一个音源节点。

四、创建音频处理流: 我们优先采用 AudioWorklet/AudioWorkletNode 开启音频渲染线程来处理音频, 音频渲染线程将处理好的音频数据通过 postMessage 发送到主线程, 主线程通过 onmessage 来接收音频数据; 并在不支持的情况下回退到 ScriptProcessorNode 在主线程中处理音频, ScriptProcessorNode 通过 onaudioprocess 事件来处理音频数据。为什么优先使用 AudioWorklet: AudioWorklet 运行在 Audio Rendering Thread(音频渲染线程)中,与主线程解耦,能够避免 主线程阻塞 导致的音频丢失或卡顿问题。而 ScriptProcessorNode 运行在 主线程,会受到 UI 渲染、网络请求等任务的影响,导致音频处理延迟增大,甚至出现 音频丢帧,特别是在高 CPU 负载情况下,影响实时性和用户体验。

五、音源节点连接音频处理节点并连接音频输出点(扬声器): 这样,音源发出的音频信号在输出到扬声器前,会先经过自定义的音频处理器处理

六、AudioWorklet/AudioWorkletNode 或者 ScriptProcessorNode 音频处理: 16kHz 音频降采样音频响度 RMS 均方根音频强度 dB 分贝值音频音量百分比16-bit PCM 音频格式转换语音活动检测(VAD

二、实现


创建 audio-processor.js 文件: 音频处理器, 只需要接收一个 用户媒体流 UserMedia 或者 Canvas captureStream 等流, 然后在 audio-processor 中就可以处理音频。

function isSupportAudioWorklet(audioContext) {
return (
audioContext.audioWorklet &&
typeof audioContext.audioWorklet.addModule === "function" &&
typeof AudioWorkletNode !== "undefined"
);
}

function isSupportCreateScriptProcessor(audioContext) {
return typeof audioContext.createScriptProcessor === "function";
}

function calculateRMS(input) {
const frames = new Int16Array(input);

// 将Int16Array转换为Int32Array
const audioData = new Int32Array(frames.length);
for (let i = 0; i < frames.length; i++) {
audioData[i] = frames[i];
}

// 计算每个样本的平方值
let sumOfSquares = 0;
for (let i = 0; i < audioData.length; i++) {
sumOfSquares += audioData[i] ** 2;
}

// 计算平均值
const meanOfSquares = sumOfSquares / audioData.length;

// 计算RMS
const rms = Math.sqrt(meanOfSquares);

// 定义参考压力和转换为相应的参考值
const refPressure = 0.00002;
const refValue = refPressure * 32767;

// 计算分贝值
let dB = 0;
if (rms !== 0) {
dB = 20 * Math.log10(rms / refValue);
dB = Math.round(dB);
}

return dB;
}

/**
* @description: rms 转换为分贝计算公式
* @param {*} gain
*/
function rmsToDb(gain) {
return 20 * Math.log10(gain);
}

export function to16kHz(audioBuffer, sampleRate = 44100) {
const data = new Float32Array(audioBuffer);
const fitCount = Math.round(data.length * (16000 / sampleRate));
const newData = new Float32Array(fitCount);
const springFactor = (data.length - 1) / (fitCount - 1);

function cubicInterpolate(p0, p1, p2, p3, x) {
return (
p1 +
0.5 *
x *
(p2 -
p0 +
x * (2 * p0 - 5 * p1 + 4 * p2 - p3 + x * (3 * (p1 - p2) + p3 - p0)))
);
}

for (let i = 0; i < fitCount; i++) {
const tmp = i * springFactor;
const index = Math.floor(tmp);
const x = tmp - index;

// 取出四个相邻的数据点,处理边界情况
const p0 = data[Math.max(0, index - 1)];
const p1 = data[index];
const p2 = data[Math.min(index + 1, data.length - 1)];
const p3 = data[Math.min(index + 2, data.length - 1)];

newData[i] = cubicInterpolate(p0, p1, p2, p3, x);
}

return newData;
}

function to16BitPCM(input) {
const dataLength = input.length * (16 / 8);
const dataBuffer = new ArrayBuffer(dataLength);
const dataView = new DataView(dataBuffer);
let offset = 0;
for (let i = 0; i < input.length; i++, offset += 2) {
const s = Math.max(-1, Math.min(1, input[i]));
dataView.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
return dataView;
}

function getVolumeShownPercent(dbValue) {
const minDb = -80;

if (dbValue < minDb) {
return 0;
}
if (dbValue > 1) {
return 1;
}

const volumePercent = (Math.abs(minDb) - Math.abs(dbValue)) / Math.abs(minDb);

return volumePercent;
}

function convertFloat32ToInt16(float32Array) {
const int16Array = new Int16Array(float32Array.length);
for (let i = 0; i < float32Array.length; i++) {
// 将浮点数从范围[-1, 1] 映射到整数范围[-32768, 32767]
int16Array[i] = Math.floor(float32Array[i] * 32767);
}
return int16Array;
}

class VAD {
constructor(sampleRate, threshold = 0.01) {
this.threshold = threshold;
this.sampleRate = sampleRate;
}

isSpeech(samples) {
const energy =
samples.reduce((sum, val) => sum + val * val, 0) / samples.length;
return energy > this.threshold;
}
}

const myAudioWorkletProcessorCode = `
class MyAudioWorkletProcessor extends AudioWorkletProcessor {
constructor(options) {
super(options);
this.vad = new ${VAD}(sampleRate);
}

get intervalInFrames() {
return 40 / 1000 * sampleRate;
}

process(inputs) {
const input = inputs[0];
if (!input || input.length === 0) {
return;
}

const samples = input[0];
const vad = this.vad.isSpeech(samples);
const output = ${to16kHz}(samples, sampleRate);

const sum = samples.reduce((acc, curr) => acc + curr * curr, 0);
const rms = Math.sqrt(sum / samples.length);
const db = ${rmsToDb}(rms);
const volume = ${getVolumeShownPercent}(db);


const audioBuffer = ${to16BitPCM}(output);
const aiDb = ${calculateRMS}(${convertFloat32ToInt16}(samples));

this.port.postMessage({ rms, db, vad, aiDb, volume, audioBuffer });

return true;
}
}

registerProcessor("my-audio-worklet-processor", MyAudioWorkletProcessor);
`;

export class AudioProcessor {
constructor(options) {
const {
stream,
processCallback,
enableVAD = false,
scriptProcessorOptions,
} = options;

this.stream = stream;
this.enableVAD = enableVAD;
this.processCallback = processCallback;
this.audioContext = new AudioContext();
this.scriptProcessorOptions = scriptProcessorOptions || {
bufferSize: 1024,
numberOfInputChannels: 1,
numberOfOutputChannels: 1,
};
this.streamSource = this.audioContext.createMediaStreamSource(this.stream);

this.init();
}

init() {
if (isSupportAudioWorklet(this.audioContext)) {
this.audioWorkletNodeDealAudioData();
} else {
this.scriptNodeDealAudioData();
}

this.audioContext?.suspend();
}

stop() {
this.audioContext?.close();
this.streamSource?.disconnect();
this.scriptProcessor?.disconnect();
}

start() {
this.audioContext?.resume();
}

pause() {
this.audioContext?.suspend();
}

resume() {
this.audioContext?.resume();
}

scriptNodeDealAudioData() {
if (!isSupportCreateScriptProcessor(this.audioContext)) {
return;
}

try {
this.scriptProcessor = this.audioContext.createScriptProcessor(
this.scriptProcessorOptions.bufferSize,
this.scriptProcessorOptions.numberOfInputChannels,
this.scriptProcessorOptions.numberOfOutputChannels
);
this.streamSource.connect(this.scriptProcessor);
this.scriptProcessor.connect(this.audioContext.destination);

this.scriptProcessor.onaudioprocess = (event) => {
const samples = event.inputBuffer.getChannelData(0);
const output = to16kHz(samples, this.audioContext.sampleRate);

const sum = samples.reduce((acc, curr) => acc + curr * curr, 0);
const rms = Math.sqrt(sum / samples.length);
const db = rmsToDb(rms);
const volume = getVolumeShownPercent(db);
const audioBuffer = to16BitPCM(output);
const aiDb = calculateRMS(convertFloat32ToInt16(samples));

const data = {
db,
rms,
aiDb,
volume,
buffer: audioBuffer,
};

this.processCallback?.(data);
};
} catch (e) {
console.log("AudioProcessor scriptNodeDealAudioData 错误原因:", e);
}
}

async audioWorkletNodeDealAudioData() {
if (!isSupportAudioWorklet(this.audioContext)) {
return;
}

try {
const myAudioWorkletProcessorBlogURL = window.URL.createObjectURL(
new Blob([myAudioWorkletProcessorCode], { type: "text/javascript" })
);

await this.audioContext.audioWorklet.addModule(
myAudioWorkletProcessorBlogURL
);

const myAudioWorkletNode = new AudioWorkletNode(
this.audioContext,
"my-audio-worklet-processor",
{
numberOfInputs: 1,
numberOfOutputs: 1,
channelCount: 1,
}
);

this.streamSource
.connect(myAudioWorkletNode)
.connect(this.audioContext.destination);

myAudioWorkletNode.onprocessorerror = () => {
this.scriptNodeDealAudioData();
return false;
};

myAudioWorkletNode.port.onmessageerror = () => {
this.scriptNodeDealAudioData();
return false;
};

myAudioWorkletNode.port.onmessage = (event) => {
const { db, rms, vad, volume, audioBuffer } = event.data;

console.log("vad", vad);

const data = {
db,
rms,
vad,
volume,
buffer: audioBuffer,
};

this.processCallback?.(data);
};
} catch (e) {
console.log("AudioProcessor audioWorkletNodeDealAudioData 错误原因:", e);
}
}
}