打造可扩展的纯前端语音采集系统:从录音到未来 ASR 的完整架构设计
本文是「纯前端语音识别」系列的第一篇。我们将从零搭建一个结构清晰、完全在浏览器内运行、可无缝升级为离线 ASR 或对接后端服务的语音处理基础框架,并深入解析
MediaRecorder音频配置的关键参数及其对 ASR 识别效果的影响。
🎯 为什么需要自己实现语音采集?
很多开发者面对“语音转文字”需求时,第一反应是使用浏览器内置的 Web Speech API:
js
编辑
const recognition = new webkitSpeechRecognition();
recognition.start();
但这种方案存在致命缺陷:
❌ Web Speech API 的三大问题
- 依赖厂商云端服务
Chrome 调用 Google 语音服务,Safari 调用 Apple 服务 —— 你的音频数据会上传到第三方服务器,无法保证隐私,网络要求相对较高。 - 不支持离线
一旦断网,功能直接失效。 - 中文支持弱且不可控
识别准确率低,无法替换模型,不能针对业务场景优化(如医疗、金融术语)。
💡 如果你做的是内部工具或对隐私敏感的产品(如会议记录、医疗问诊),Web Speech API 并不是好选择。
✅ 目标:构建一个可扩展的语音处理底座
希望实现:
- ✅ 纯前端录音:音频数据不出浏览器(可选)
- ✅ 支持离线 ASR:集成 FunASR / Vosk 等国产/开源模型
- ✅ 也支持后端转写:将音频发送给高精度服务(如 Whisper API)
- ✅ 实时流式识别:边说边出文字
- ✅ 代码结构清晰:便于后续迭代
因此,我们先从最基础的 音频流采集 + Web Worker 缓存 开始,并重点配置适合 ASR 的音频参数。
🔧 技术栈说明
| 技术 | 作用 |
|---|---|
navigator.mediaDevices.getUserMedia | 获取麦克风权限 |
MediaRecorder | 浏览器原生录音 API,支持分片录制 |
Web Worker | 在后台线程处理音频数据,避免阻塞 UI |
Blob + URL.createObjectURL | 生成可播放的音频 URL |
type: 'module' Worker | 支持 ES 模块导入(为后续加载 WASM 模型做准备) |
🎚️ MediaRecorder 音频配置详解:哪些参数影响 ASR 效果?
MediaRecorder 的构造函数支持传入 mimeType 和 audioBitsPerSecond,但更关键的是 getUserMedia 中的音频约束(constraints) 。这些参数直接影响录音质量与 ASR 模型的输入兼容性。
常用音频约束参数及用途
| 参数 | 类型 | 默认值 | 推荐值(ASR 场景) | 说明 |
|---|---|---|---|---|
channelCount | number | 2(立体声) | 1 | 大多数 ASR 模型仅支持单声道(mono),双声道会浪费带宽且可能降低识别率 |
sampleRate | number | 设备默认(常为 44100/48000) | 16000 | 主流 ASR 模型(Whisper、FunASR、Vosk)的标准输入采样率;过高反而无益 |
sampleSize | number | 16 | 16 | 位深度,16bit 足够,更高对语音识别无明显提升 |
echoCancellation | boolean | true | true | 回声消除,提升语音清晰度 |
noiseSuppression | boolean | true | true | 降噪,减少环境干扰 |
autoGainControl | boolean | true | 可选 | 自动增益控制,防止音量忽大忽小 |
✅ 最佳实践:
js 编辑 const constraints = { audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true, autoGainControl: true } };
⚠️ 注意事项
- 并非所有设备都支持指定
sampleRate,浏览器会自动选择最接近的可用值。 - 若目标 ASR 模型要求 WAV 格式(如 FunASR),而
MediaRecorder默认输出webm或ogg,需后续转换(可用audiobuffer-to-wav库)。 - 录音格式可通过
MediaRecorder.isTypeSupported('audio/wav')检测,但 Chrome 不支持直接录 WAV,需通过AudioContext转换。
🧠 为什么必须使用 Web Worker?
你可能会问: “音频块直接存在主线程数组里不行吗?为什么要发给 Worker?”
答案是:为了架构解耦和未来扩展性。具体原因如下:
1. 为离线 ASR 预留接口
后续我们要在 Worker 中加载 WASM 模型(如 FunASR、Vosk) ,这些模型必须在 Worker 内运行(主线程会卡死)。现在就用 Worker,未来只需替换 worker.js 内容,主线程代码几乎不用改。
2. 避免内存泄漏风险
录音过程中持续 push Blob 到数组。若在主线程操作,容易因闭包、事件监听未清理导致内存无法释放。Worker 是独立上下文,生命周期更可控。
3. 防止 UI 卡顿
虽然当前只是缓存,但未来识别过程可能消耗大量 CPU(如 80MB 的 SenseVoice 模型推理)。Worker 可确保页面流畅。
4. 符合“数据处理在后台”的最佳实践
音频采集 → 缓存 → 识别 → 返回结果,这是一个典型的数据流水线,天然适合放在 Worker 中。
✅ 即使现在只做缓存,也建议用 Worker —— 这是为未来留的接口。
📦 核心代码详解
主线程:index.html
我们封装了一个 AudioData 类,职责清晰:
js
编辑
class AudioData {
constructor() {
this.mediaStream = null; // 麦克风流
this.mediaRecorder = null; // 录音控制器
this.worker = null; // Web Worker 实例
}
// 初始化 Worker(模块模式)
workerInit() {
this.worker = new Worker(
new URL('./worker.js', import.meta.url),
{ type: 'module' } // 关键:启用 ES 模块
);
}
// 初始化麦克风 + MediaRecorder
async init() {
// 1. 获取麦克风权限(16kHz 单声道,适合 ASR)
this.mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
sampleRate: 16000, // 多数 ASR 模型要求 16kHz
echoCancellation: true,
noiseSuppression: true // 启用降噪
}
});
// 2. 创建 MediaRecorder,每 1 秒触发一次 dataavailable
this.mediaRecorder = new MediaRecorder(this.mediaStream);
// 3. 将每个音频块(chunk)发给 Worker
this.mediaRecorder.addEventListener('dataavailable', (e) => {
this.worker.postMessage({ type: 'audioChunk', blob: e.data });
});
// 4. 停止录音时,从 Worker 获取所有 chunks
this.mediaRecorder.addEventListener('stop', async () => {
this.worker.postMessage({ type: 'finish' });
// 等待 Worker 返回完整音频块列表
const chunksFromWorker = await new Promise(resolve => {
this.worker.onmessage = (e) => resolve(e.data);
});
// 合并为 Blob 并播放
const blob = new Blob(chunksFromWorker, { type: 'audio/webm' });
audioDom.src = URL.createObjectURL(blob);
});
}
start(time = 1000) {
this.mediaRecorder.start(time); // 每 time 毫秒切一个 chunk
}
stop() {
this.mediaRecorder.stop();
}
}
⚠️ 注意:
MediaRecorder默认输出audio/webm格式。若后端要求 WAV,需额外转换(后续文章会讲)。
Worker 线程:worker.js
当前只做缓存,但结构已为 ASR 准备好:
js
编辑
let audioChunks = [];
self.onmessage = async (e) => {
const { type, blob } = e.data;
if (type === "audioChunk") {
audioChunks.push(blob); // 缓存音频块
}
if (type === "finish") {
self.postMessage(audioChunks); // 返回所有块
// 👉 未来这里将替换为:调用 ASR 模型识别
audioChunks = [];
}
};
✅ 这个 Worker 就是我们未来的“ASR 引擎舱” 。
🔮 后续扩展方向(重点!)
方向一:纯前端离线语音转文字(无后端)
引入以下国产/开源方案:
✅ 国产首选:FunASR(阿里达摩院)
-
模型:
sensevoice-small(80MB) -
特点:中文 SOTA、支持情感/语种识别、社区提供 WASM 版本
-
使用方式(在
worker.js中):js 编辑 import { createRecognizer } from 'https://.../funasr-wasm.js'; const asr = await createRecognizer({ model: 'sensevoice-small' }); const result = await asr.recognize(fullAudioBlob); self.postMessage({ text: result.text });
✅ 轻量级选择:Vosk
- 模型:
vosk-model-small-zh-cn(50MB) - 特点:跨平台、社区提供
vosk-browserCDN - 适合资源受限场景
💡 两者都支持 完全离线,首次加载模型后,后续无需网络。
方向二:发送音频到后端,实现实时转写
如果你有后端服务(如部署了 Whisper 或 FunASR Server),可这样集成:
步骤 1:将音频块合并为完整音频
js
编辑
// 在主线程 stop 事件中
const fullBlob = new Blob(chunksFromWorker, { type: 'audio/wav' });
步骤 2:通过 WebSocket 发送(支持流式)
js
编辑
const socket = new WebSocket('wss://your-asr-server');
chunksFromWorker.forEach(chunk => {
socket.send(chunk); // 边录边传
});
步骤 3:后端实时返回中间结果
- 后端使用 流式 ASR(如 FunASR 的 streaming mode)
- 通过 WebSocket 推送
partial result→final result - 前端动态更新 UI:“正在说话... → 你好 → 你好世界”
⚠️ 注意:需统一音频格式(推荐 16kHz WAV),
MediaRecorder默认的webm需转换(可用audiobuffer-to-wav库)。
📝 总结:为什么这是一个“基础但重要”的起点?
本文实现的功能看似简单 —— 只是录音并回放,但它是一个精心设计的可扩展底座:
| 特性 | 价值 |
|---|---|
| Worker 架构 | 未来无缝集成 ASR 模型 |
| 分片录音 | 支持流式识别(边录边识别) |
| 16kHz 采样率 + 单声道 | 兼容主流 ASR 模型输入要求 |
| 模块化设计 | 主线程与数据处理解耦 |
| 音频参数可配置 | 为不同 ASR 场景预留调整空间 |
这不是最终产品,而是一个起点。下一篇文章,我们将在此基础上:
- 集成 FunASR 的 SenseVoice 模型
- 实现 完全离线的中文语音识别
- 对比 FunASR vs Vosk vs Whisper 的效果
💻 完整源代码(开箱即用)
index.html
html
预览
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>语音转文字</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
}
button {
margin: 10px 0;
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
}
#output {
margin-top: 20px;
padding: 10px;
border: 1px solid #ccc;
min-height: 50px;
background: #f9f9f9;
}
</style>
</head>
<body>
<button id="btn">开始录音</button>
<button class="btn">停止录音</button>
<div id="output">请说话...</div>
<div>
<audio src="" controls></audio>
</div>
<script type="module">
const btn = document.querySelector('#btn');
const stop = document.querySelector('.btn');
const audioDom = document.querySelector('audio');
class AudioData {
constructor() {
this.mediaStream = null;
this.mediaRecorder = null;
this.worker = null;
}
workerInit() {
this.worker = new Worker(
new URL('./worker.js', import.meta.url),
{ type: 'module' }
);
}
async init() {
try {
this.workerInit();
// 👇 关键:配置适合 ASR 的音频参数
this.mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1, // 单声道
sampleRate: 16000, // 16kHz 采样率
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
}
});
this.mediaRecorder = new MediaRecorder(this.mediaStream);
this.mediaRecorder.addEventListener('dataavailable', (e) => {
this.worker.postMessage({ type: 'audioChunk', blob: e.data });
});
this.mediaRecorder.addEventListener('stop', async () => {
this.worker.postMessage({ type: 'finish' });
this.worker.onmessage = (e) => {
const chunksFromWorker = e.data;
const blob = new Blob(chunksFromWorker, { type: 'audio/webm' });
const url = URL.createObjectURL(blob);
audioDom.src = url;
};
});
} catch (e) {
console.error('权限获取失败', e);
}
}
start(time = 1000) {
this.mediaRecorder.start(time);
}
stop() {
this.mediaRecorder.stop();
}
}
const myAudioData = new AudioData();
btn.addEventListener('click', async () => {
btn.disabled = true;
btn.textContent = '录音中……';
await myAudioData.init();
myAudioData.start(1000);
});
stop.addEventListener('click', () => {
btn.textContent = '开始录音';
btn.disabled = false;
myAudioData.stop();
});
</script>
</body>
</html>
worker.js
js
编辑
let audioChunks = [];
self.onmessage = async (e) => {
const { type, blob } = e.data;
if (type === "audioChunk") {
audioChunks.push(blob);
}
if (type === "finish") {
self.postMessage(audioChunks);
audioChunks = [];
}
};
👋 如果你觉得有帮助,欢迎点赞、收藏、关注!
下一期我们将实现 纯前端中文语音识别(FunASR + Web Worker) ,对比国产模型与 Whisper 的效果,并提供完整可运行的 ZIP 包。敬请期待!