ScriptProcessorNode
一、认识
我们获取到的用户媒体流或者 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);
}
}
}