打造可扩展的纯前端语音采集系统:从录音到未来 ASR 的完整架构设计

97 阅读8分钟

打造可扩展的纯前端语音采集系统:从录音到未来 ASR 的完整架构设计

本文是「纯前端语音识别」系列的第一篇。我们将从零搭建一个结构清晰、完全在浏览器内运行、可无缝升级为离线 ASR 或对接后端服务的语音处理基础框架,并深入解析 MediaRecorder 音频配置的关键参数及其对 ASR 识别效果的影响。


🎯 为什么需要自己实现语音采集?

很多开发者面对“语音转文字”需求时,第一反应是使用浏览器内置的 Web Speech API

js
编辑
const recognition = new webkitSpeechRecognition();
recognition.start();

但这种方案存在致命缺陷

❌ Web Speech API 的三大问题

  1. 依赖厂商云端服务
    Chrome 调用 Google 语音服务,Safari 调用 Apple 服务 —— 你的音频数据会上传到第三方服务器,无法保证隐私,网络要求相对较高。
  2. 不支持离线
    一旦断网,功能直接失效。
  3. 中文支持弱且不可控
    识别准确率低,无法替换模型,不能针对业务场景优化(如医疗、金融术语)。

💡 如果你做的是内部工具或对隐私敏感的产品(如会议记录、医疗问诊),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 的构造函数支持传入 mimeTypeaudioBitsPerSecond,但更关键的是 getUserMedia 中的音频约束(constraints) 。这些参数直接影响录音质量与 ASR 模型的输入兼容性。

常用音频约束参数及用途

参数类型默认值推荐值(ASR 场景)说明
channelCountnumber2(立体声)1大多数 ASR 模型仅支持单声道(mono),双声道会浪费带宽且可能降低识别率
sampleRatenumber设备默认(常为 44100/48000)16000主流 ASR 模型(Whisper、FunASR、Vosk)的标准输入采样率;过高反而无益
sampleSizenumber1616位深度,16bit 足够,更高对语音识别无明显提升
echoCancellationbooleantruetrue回声消除,提升语音清晰度
noiseSuppressionbooleantruetrue降噪,减少环境干扰
autoGainControlbooleantrue可选自动增益控制,防止音量忽大忽小

最佳实践

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-browser CDN
  • 适合资源受限场景

💡 两者都支持 完全离线,首次加载模型后,后续无需网络。


方向二:发送音频到后端,实现实时转写

如果你有后端服务(如部署了 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 包。敬请期待!