语音识别前端系统化实战 - 第2节

音频数字化原理:从模拟到数字的桥梁

做语音识别,第一步就是理解声音怎么变成计算机能处理的数据。说白了,声音是连续的波,计算机只能处理离散的数字。这个转换过程,就是音频数字化。

我记得刚接触这个领域时,总觉得不就是录音嘛,有什么难的?直到有一次,我采集的语音数据在识别时总是出现奇怪的噪声,排查了半天才发现是采样率设置出了问题。从那以后,我再也不敢小看这三个核心参数了。

采样率:每秒拍多少张“照片”

采样率,就是每秒钟对声音信号采样的次数。单位是 Hz(赫兹)。

根据奈奎斯特采样定理:采样率必须大于信号最高频率的两倍,才能完整还原信号。

核心公式: 采样率 ≥ 2 × 信号最高频率

人耳能听到的频率范围是 20Hz ~ 20kHz。所以 CD 音质的采样率是 44.1kHz,刚好覆盖人耳范围。

语音识别场景下,我们关心的是人声。人声的主要能量集中在 300Hz ~ 3.4kHz。所以电话语音用 8kHz 就够了。但为了更好的识别效果,我一般建议用 16kHz。

采样率 适用场景 我的建议
8kHz 电话语音、低带宽通信 不推荐用于语音识别
16kHz 语音识别、语音助手 首选,平衡质量与性能
44.1kHz 音乐、高保真录音 语音识别没必要,浪费带宽
48kHz 视频配音、专业音频 除非有视频同步需求

我的经验: 做前端语音识别,16kHz 是黄金采样率。既能保证识别精度,又不会让音频数据太大。我曾经试过用 44.1kHz 采集,结果网络传输时延迟明显增加,识别效果并没有提升。

位深度:每个样本的“精度”

位深度决定了每个采样点能记录多少种音量等级。16 位就是 2^16 = 65536 个等级,8 位只有 256 个等级。

你想想看,如果位深度太低,音量变化就会变得“阶梯状”,听起来就是失真。语音识别对位深度没那么敏感,但太低会影响信噪比。

位深度 动态范围 推荐场景
8 位 48dB 极低带宽,不推荐
16 位 96dB 语音识别标准配置
24 位 144dB 专业录音,语音识别过剩
32 位浮点 理论无限 后期处理,前端不常用

避坑指南: 我曾经在项目里用了 8 位深度采集,结果背景噪声和语音信号混在一起,识别率直接掉了 20%。位深度太低,量化噪声会淹没弱音信号。语音识别至少用 16 位。

声道:单声道还是立体声?

声道数就是同时记录的音频通道数量。单声道(Mono)1 个通道,立体声(Stereo)2 个通道。

语音识别用单声道就够了。为什么?因为语音识别只关心声音的内容,不关心声场定位。立体声的数据量是单声道的两倍,处理起来更慢,对识别结果没有帮助。

我的习惯: 所有语音识别项目,一律用单声道 + 16kHz + 16 位。这个组合经过了无数次验证,稳定可靠。

浏览器获取麦克风权限:getUserMedia API

前端做语音识别,第一步就是拿到麦克风的数据。浏览器提供了 navigator.mediaDevices.getUserMedia 这个 API。

这个 API 其实很简单,但坑不少。我刚开始用的时候,经常遇到权限被拒绝、浏览器不兼容、设备选择错误等问题。

基础用法

async function getMicrophone() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      audio: {
        sampleRate: 16000,
        channelCount: 1,
        echoCancellation: true,
        noiseSuppression: true
      }
    });
    return stream;
  } catch (err) {
    console.error('麦克风获取失败:', err);
    throw err;
  }
}

这里有几个关键点:

  • sampleRate: 16000 — 强制使用 16kHz 采样率
  • channelCount: 1 — 单声道
  • echoCancellation: true — 开启回声消除,避免扬声器声音被麦克风再次采集
  • noiseSuppression: true — 开启降噪,减少环境噪声

注意: 浏览器不一定会完全遵守你设置的 sampleRate。有些浏览器会忽略这个参数,使用默认采样率。所以拿到音频流后,最好检查一下实际的采样率。

权限处理

用户第一次访问页面时,浏览器会弹出权限请求框。如果用户拒绝,后续调用会直接抛错。

我建议在页面加载时就检测权限状态,而不是等到用户点击“开始录音”时才去请求。这样可以提前引导用户开启权限。

async function checkMicrophonePermission() {
  const result = await navigator.permissions.query({ name: 'microphone' });
  if (result.state === 'denied') {
    // 引导用户去浏览器设置中开启权限
    showPermissionGuide();
  }
  result.onchange = () => {
    if (result.state === 'granted') {
      // 权限已开启,可以初始化录音
      initRecording();
    }
  };
}

我的经验: 在移动端 Safari 上,getUserMedia 必须在用户手势(如点击按钮)的上下文中调用,否则会失败。PC 端 Chrome 也有类似限制。所以一定要把录音按钮放在用户能点击的位置。

音频流处理基础

拿到 MediaStream 之后,怎么把它变成我们能处理的音频数据?这里要用到 AudioContext 和 ScriptProcessorNode(或者更现代的 AudioWorklet)。

AudioContext 与音频管道

AudioContext 是 Web Audio API 的核心。它创建了一个音频处理图,你可以把麦克风输入、音频文件、合成声音等作为“源”,经过各种“节点”处理,最终输出到扬声器或进行分析。

音频处理管道流程图 麦克风输入 AudioContext ScriptProcessor / AudioWorklet PCM 数据 关键说明: • getUserMedia 返回 MediaStream,通过 AudioContext.createMediaStreamSource 接入 • ScriptProcessorNode 已废弃,新项目建议使用 AudioWorklet(兼容性需注意) • 最终输出的是 Float32Array 格式的 PCM 数据,范围 -1 到 1

从 MediaStream 到 PCM 数据

下面是一个完整的音频采集示例,把麦克风数据转换成 Float32Array:

class AudioCapture {
  constructor() {
    this.audioContext = null;
    this.stream = null;
    this.processor = null;
    this.onAudioData = null;
  }

  async start() {
    // 1. 获取麦克风流
    this.stream = await navigator.mediaDevices.getUserMedia({
      audio: {
        sampleRate: 16000,
        channelCount: 1,
        echoCancellation: true,
        noiseSuppression: true
      }
    });

    // 2. 创建 AudioContext
    this.audioContext = new AudioContext({
      sampleRate: 16000
    });

    // 3. 创建源节点
    const source = this.audioContext.createMediaStreamSource(this.stream);

    // 4. 创建处理节点
    const bufferSize = 4096;
    this.processor = this.audioContext.createScriptProcessor(bufferSize, 1, 1);

    // 5. 处理音频数据
    this.processor.onaudioprocess = (event) => {
      const inputData = event.inputBuffer.getChannelData(0);
      // inputData 是 Float32Array,范围 -1 到 1
      if (this.onAudioData) {
        this.onAudioData(inputData);
      }
    };

    // 6. 连接节点
    source.connect(this.processor);
    this.processor.connect(this.audioContext.destination);
  }

  stop() {
    if (this.processor) {
      this.processor.disconnect();
    }
    if (this.stream) {
      this.stream.getTracks().forEach(track => track.stop());
    }
    if (this.audioContext) {
      this.audioContext.close();
    }
  }
}

核心要点: onaudioprocess 回调中拿到的 inputData 就是我们要的音频数据。它是一个 Float32Array,每个元素代表一个采样点的振幅,范围在 -1 到 1 之间。

bufferSize 的选择

bufferSize 决定了每次回调返回的采样点数量。常见值有 256、512、1024、2048、4096。

bufferSize 延迟 CPU 占用 推荐场景
256 低 高 实时性要求极高
1024 中 中 语音识别,平衡之选
4096 高 低 非实时处理

我的建议: 语音识别场景,bufferSize 用 1024 或 2048。太小了 CPU 扛不住,太大了延迟明显。我曾经试过 256,结果移动端直接卡死。

关于 AudioWorklet

ScriptProcessorNode 虽然好用,但它是在主线程运行的,可能会阻塞 UI。Chrome 已经推荐使用 AudioWorklet,它在独立线程运行,性能更好。

不过 AudioWorklet 需要单独加载一个 JS 文件,配置稍微复杂一些。对于初学者,先用 ScriptProcessorNode 上手没问题。等项目稳定了,再考虑迁移到 AudioWorklet。

兼容性提醒: AudioWorklet 在 Safari 上支持不完整。如果你的项目需要兼容 iOS,建议先用 ScriptProcessorNode 做 fallback。

小结

这一章我们聊了音频数字化的三个核心参数:采样率、位深度、声道。也讲了怎么用 getUserMedia 获取麦克风权限,以及如何用 AudioContext 把音频流转换成可处理的 PCM 数据。

这些是语音识别前端开发的基石。后面的章节,我们会基于这些数据做语音活动检测、特征提取,最终送到识别引擎里去。