一、核心思想
本方案实现了一个高性能的流式音频录制系统,用于实时捕获麦克风输入并转换为 PCM 格式数据。主要核心思想包括:
-
使用 AudioWorklet 实现线程隔离:将音频数据格式转换放在独立的 Worklet 线程中执行,避免阻塞主线程,确保 UI 流畅。
-
流式输出设计:以事件驱动的方式实时输出音频数据,适合流式传输场景(如实时语音识别、语音聊天等)。
-
独立 AudioContext:使用独立的音频上下文,避免与系统音频播放器产生冲突。
-
Int16 格式输出:将 Web Audio API 的 Float32 格式转换为 Int16 格式,便于网络传输和服务器处理。
二、技术架构
2.1 架构概览
麦克风输入 → MediaStream → AudioWorklet 处理器 → Int16 ArrayBuffer → 事件监听
↓
Float32 → Int16 转换(独立线程)
2.2 关键技术点
| 技术点 | 说明 |
|---|---|
| AudioWorklet | 在独立线程中处理音频,避免阻塞主线程 |
| AudioContext | 管理音频处理图,可自定义采样率 |
| MediaStream | 获取麦克风音频流 |
| Float32 → Int16 | 音频数据格式转换,范围从 [-1, 1] 到 [-32768, 32767] |
| EventTarget | 使用事件监听模式,方便数据接收 |
2.3 数据流转
- 麦克风采集:通过
getUserMedia获取麦克风流 - Float32 输入:Web Audio API 以 Float32Array 格式提供音频数据(-1.0 到 1.0)
- Worklet 转换:在 Worklet 线程中将 Float32 转换为 Int16 ArrayBuffer
- 主线程接收:通过
port.postMessage将转换后的数据发送到主线程 - 事件分发:通过
message事件对外分发音频数据
三、技术细节
3.1 音频格式
- 输入格式:Float32Array,范围 [-1.0, 1.0]
- 输出格式:Int16 ArrayBuffer,范围 [-32768, 32767]
- 声道配置:单声道
- 缓冲区大小:128 帧(Web Audio API 标准)
- 采样率:可配置(默认 24000 Hz)
3.2 格式转换算法
Float32 到 Int16 的转换公式:
// 限制范围在 [-1.0, 1.0]
const s = Math.max(-1, Math.min(1, float32Value));
// 转换为 Int16 范围
int16Value = s < 0 ? s * 0x8000 : s * 0x7fff;
3.3 AudioWorklet 配置
| 参数 | 说明 |
|---|---|
| 处理器名称 | pcm-recorder-processor |
| 输入 | Float32Array 音频数据 |
| 输出 | Int16 ArrayBuffer |
| 执行位置 | 独立 Worklet 线程 |
3.4 麦克风配置
{
audio: {
echoCancellation: true, // 回声消除
noiseSuppression: true, // 噪声抑制
autoGainControl: true, // 自动增益控制
}
}
四、代码实现
4.1 AudioWorklet 处理器 (recorder-worklet.js)
/**
* PCM 音频录制处理器
* @remarks
* 运行在独立的 Audio Worklet 线程中,负责实时捕获麦克风音频数据并转换为 Int16 格式
* - 采样率:由 AudioContext 决定(通常为 48000 Hz)
* - 输入格式:Float32Array(-1.0 到 1.0)
* - 输出格式:ArrayBuffer (Int16,-32768 到 32767)
* - 缓冲区大小:128 帧(Web Audio API 标准)
* - 在 Worklet 线程中完成格式转换,避免阻塞主线程
*/
class RecorderProcessor extends AudioWorkletProcessor {
/**
* 将 Float32Array 转换为 Int16 ArrayBuffer
* @param {Float32Array} float32Data - Float32Array 格式的 PCM 数据,范围为 -1.0 到 1.0
* @returns {ArrayBuffer} Int16 格式的 ArrayBuffer
*/
pcmFloat32ToInt16(float32Data) {
const length = float32Data.length;
const int16Array = new Int16Array(length);
for (let i = 0; i < length; i++) {
// 限制范围在 [-1.0, 1.0]
const s = Math.max(-1, Math.min(1, float32Data[i]));
// 转换为 Int16 范围
int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
return int16Array.buffer;
}
/**
* 音频处理回调
* @param {Float32Array[][]} inputs - 输入音频数据([通道][样本])
* @returns {boolean} 返回 true 保持处理器活跃
*/
process([input]) {
// 获取第一个输入源(麦克风)
// 确保有音频数据
if (!input || !input.length) return true;
// 获取第一个声道的数据(单声道录制)
const [channelData] = input;
if (!channelData || !channelData.length) return true;
// 转换为 Int16 ArrayBuffer 并发送到主线程
const arrayBuffer = this.pcmFloat32ToInt16(channelData);
this.port.postMessage(arrayBuffer);
// 返回 true 保持处理器运行
return true;
}
}
// 注册处理器
registerProcessor("pcm-recorder-processor", RecorderProcessor);
4.2 主录制器类 (pcm-stream-recorder.ts)
/**
* PCM 流式音频录制器
* @remarks
* 用于录制麦克风输入的 PCM 音频数据,支持事件监听
* 使用 Web Audio API 的 AudioWorklet 实现高性能录制
* - 音频格式:ArrayBuffer (Int16,范围 -32768 到 32767)
* - 声道:单声道
* - 采样率:可配置(默认为 24000 Hz)
* - 使用独立的 AudioContext,避免与播放器冲突
* - 在 AudioWorklet 线程中完成格式转换,不阻塞主线程
* - 输出的 ArrayBuffer 可直接用于网络传输
*
* @example
* ```typescript
* const recorder = new PCMStreamRecorder(24000); // 指定采样率
* recorder.addEventListener('message', (event: MessageEvent<ArrayBuffer>) => {
* console.log(event.data); // ArrayBuffer (Int16 格式)
* // 直接发送到服务器
* sendToServer(event.data);
* });
* await recorder.start();
* recorder.stop();
* ```
*/
export class PCMStreamRecorder extends EventTarget {
/**
* 麦克风媒体流
*/
private mediaStream: MediaStream | null = null;
/**
* 音频源节点
*/
private sourceNode: MediaStreamAudioSourceNode | null = null;
/**
* AudioWorklet 节点
*/
private workletNode: AudioWorkletNode | null = null;
/**
* 私有的 AudioContext
*/
private audioContext: AudioContext;
/**
* 是否正在录制
* @remarks
* 通过检查关键资源(sourceNode、workletNode、mediaStream)是否都存在来判断
*/
get isRecording(): boolean {
return !!(this.sourceNode && this.workletNode && this.mediaStream);
}
/**
* 采样率
* @remarks
* 录制的音频数据采样率,在构造时确定后不再变更
*/
readonly sampleRate: number;
/**
* Worklet 模块是否已加载
*/
private workletLoaded = false;
/**
* 构造函数
* @param sampleRate - 采样率(默认 24000 Hz)
*/
constructor(sampleRate = 24000) {
super();
this.sampleRate = sampleRate;
this.audioContext = new AudioContext({ sampleRate });
}
/**
* 开始录制音频
* @throws {Error} 如果麦克风权限被拒绝或不支持
* @remarks
* - 首次调用会请求麦克风权限
* - 自动加载 AudioWorklet 模块(仅首次加载)
* - 通过监听 'message' 事件接收音频数据(ArrayBuffer,Int16 格式)
* - 录制的采样率由构造函数指定
* - 格式转换在 AudioWorklet 线程中完成,主线程直接接收 ArrayBuffer
*
* @example
* ```typescript
* const recorder = new PCMStreamRecorder(24000);
* recorder.addEventListener('message', (event: MessageEvent<ArrayBuffer>) => {
* console.log(event.data); // ArrayBuffer (Int16 格式)
* // 直接发送到服务器
* sendToServer(event.data);
* });
* await recorder.start();
* ```
*/
async start(): Promise<void> {
if (this.isRecording) return;
// 加载 AudioWorklet 模块(仅首次加载)
if (!this.workletLoaded) {
await this.audioContext.audioWorklet.addModule(
new URL("recorder-worklet.js", import.meta.url),
);
this.workletLoaded = true;
}
const handleMessage = (event: MessageEvent<ArrayBuffer>) => {
if (!this.isRecording) return;
// 直接转发 ArrayBuffer(已在 Worklet 中完成转换)
this.dispatchEvent(new MessageEvent("message", { data: event.data }));
};
try {
// 请求麦克风权限
this.mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true, // 回声消除
noiseSuppression: true, // 噪声抑制
autoGainControl: true, // 自动增益控制
},
});
// 创建音频源节点
this.sourceNode = this.audioContext.createMediaStreamSource(this.mediaStream);
// 创建 AudioWorklet 节点
this.workletNode = new AudioWorkletNode(this.audioContext, "pcm-recorder-processor");
// 监听来自 Worklet 的音频数据(已转换为 ArrayBuffer)
this.workletNode.port.addEventListener("message", handleMessage);
// 启动 port 消息监听
this.workletNode.port.start();
// 连接音频处理图
this.sourceNode.connect(this.workletNode);
// 注意:此时 isRecording 会自动变为 true(通过 getter 判断资源状态)
} catch (error) {
console.error("Recorder error:", error);
this.stop();
throw error;
}
}
/**
* 停止录制音频
* @remarks
* 停止录制并释放音频节点和麦克风流资源,但保留 AudioContext
* 可通过再次调用 start() 重新开始录制
*/
stop(): void {
// 断开音频节点
this.sourceNode?.disconnect();
this.sourceNode = null;
this.workletNode?.disconnect();
this.workletNode = null;
// 停止麦克风流
this.mediaStream?.getTracks().forEach((track) => track.stop());
this.mediaStream = null;
}
/**
* 关闭录制器并释放所有资源
* @remarks
* 关闭 AudioContext 和所有相关资源,调用后需要重新创建实例才能再次使用
*/
cleanup(): void {
this.stop();
this.audioContext.close();
}
}
五、使用示例
5.1 基本使用
// 创建录音器实例(采样率 24000 Hz)
const recorder = new PCMStreamRecorder(24000);
// 监听音频数据
recorder.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
const audioData = event.data; // Int16 格式的 ArrayBuffer
console.log("收到音频数据:", audioData.byteLength, "字节");
// 可以直接发送到服务器
// await sendAudioToServer(audioData);
});
// 开始录制
try {
await recorder.start();
console.log("录音开始");
} catch (error) {
console.error("录音启动失败:", error);
}
// 停止录制
// recorder.stop();
5.2 流式发送到服务器
class AudioStreamer {
private recorder: PCMStreamRecorder;
private ws: WebSocket;
constructor(sampleRate = 24000, wsUrl: string) {
this.recorder = new PCMStreamRecorder(sampleRate);
this.ws = new WebSocket(wsUrl);
this.recorder.addEventListener("message", async (event: MessageEvent<ArrayBuffer>) => {
if (this.ws.readyState === WebSocket.OPEN) {
// 发送二进制数据到服务器
this.ws.send(event.data);
}
});
}
async startRecording() {
await this.recorder.start();
}
stopRecording() {
this.recorder.stop();
}
close() {
this.recorder.cleanup();
this.ws.close();
}
}
5.3 带数据缓冲的实现
class BufferedRecorder {
private recorder: PCMStreamRecorder;
private buffer: ArrayBuffer[] = [];
constructor(sampleRate = 24000) {
this.recorder = new PCMStreamRecorder(sampleRate);
this.recorder.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
this.buffer.push(event.data);
// 当缓冲区达到一定大小时处理
if (this.buffer.length >= 10) {
this.processBuffer();
}
});
}
private processBuffer() {
const totalSize = this.buffer.reduce((sum, buf) => sum + buf.byteLength, 0);
const merged = new Int16Array(totalSize);
let offset = 0;
for (const buf of this.buffer) {
const data = new Int16Array(buf);
merged.set(data, offset);
offset += data.length;
}
console.log("处理音频块:", totalSize / 2, "个采样点");
this.buffer = [];
}
async start() {
await this.recorder.start();
}
stop() {
// 处理剩余缓冲
if (this.buffer.length > 0) {
this.processBuffer();
}
this.recorder.stop();
}
}
六、注意事项
6.1 权限处理
- 首次调用
start()会请求麦克风权限 - 用户拒绝权限会抛出错误
- 建议在用户点击事件中调用
start(),避免浏览器自动阻止
6.2 资源管理
- 使用
stop()停止录制但保留 AudioContext,可重新开始录制 - 使用
cleanup()完全释放所有资源,需要重新创建实例 - 页面卸载时建议调用
cleanup()
6.3 浏览器兼容性
| 功能 | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| AudioWorklet | 66+ | 76+ | 14.1+ | 79+ |
| getUserMedia | 21+ | 17+ | 11+ | 12+ |
6.4 性能考虑
- Worklet 在独立线程中执行格式转换,不会阻塞主线程
- 每次处理 128 帧音频(约 5.3ms @ 24000Hz),实时性良好
- 避免在 message 事件处理中执行耗时操作
6.5 调试建议
// 监听录制状态
const checkRecordingStatus = () => {
console.log("录制状态:", recorder.isRecording);
console.log("采样率:", recorder.sampleRate);
};
setInterval(checkRecordingStatus, 1000);
七、常见问题
Q1: 为什么选择 Int16 格式而不是其他格式?
Int16 格式(16 位 PCM)是音频处理的行业标准,具有以下优势:
- 兼容性好,大多数音频库都支持
- 文件体积适中(相比 Float32 小一半)
- 精度足够满足语音应用需求
- 便于网络传输
Q2: 采样率应该如何选择?
- 8000 Hz:电话质量,适合简单语音识别
- 16000 Hz:语音应用常用,质量和性能平衡
- 24000 Hz:高质量语音,推荐用于实时语音
- 44100/48000 Hz:CD 质量,适合音乐应用
Q3: 如何处理音频数据丢失?
在 message 事件处理中,确保及时处理数据,避免缓冲区溢出:
recorder.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
// 立即处理数据,不要阻塞
processAudioData(event.data);
});
Q4: 支持多声道吗?
当前实现为单声道录制。如需多声道,需修改 Worklet 处理器的 process 方法处理多个声道,并根据需求合并或分别输出。