4. 语音识别状态管理:识别开始、结束、错误、结果等事件监听、状态机设计模式、用户交互反馈

状态管理,说白了就是让整个语音识别流程变得可控。你想想看,用户点了一下麦克风按钮,背后发生了多少事?录音权限、网络请求、音频流处理、结果回调……任何一个环节出问题,前端都得知道「现在是什么状态」,然后给出正确的反馈。

我个人习惯把语音识别看作一个有限状态机。每个状态对应一组行为,状态之间通过事件驱动切换。这样做的好处是——代码逻辑清晰,不容易出现「状态混乱」的 bug。我在项目中遇到过好几次因为状态没管理好,导致用户连续点击麦克风时界面卡死的情况。嗯,后来全部用状态机重构了。

4.1 语音识别的核心事件

先看看浏览器原生 SpeechRecognition 暴露了哪些事件。这些事件就是状态机的「燃料」——没有它们,状态根本动不起来。

事件名 触发时机 常用场景
start 识别服务启动成功 显示「正在聆听」状态
end 识别服务关闭 重置 UI,清理资源
error 发生错误(网络、权限、无语音等) 展示错误提示,降级处理
result 返回识别结果 展示文本,触发后续逻辑
audiostart 开始捕获音频 启动波形动画
audioend 停止捕获音频 停止波形动画
nomatch 识别结果无法匹配 提示用户再说一次
我的小技巧: 不要只监听 result 和 error。把 audiostart 和 audioend 也加上,这样你能精确控制「录音中」的 UI 反馈,用户体验会好很多。

4.2 状态机设计模式

为什么非要用状态机?因为语音识别的状态切换是有「规则」的。比如:idle 状态只能通过用户点击进入 listening,不能直接跳到 processing。如果不用状态机,你可能会写出这样的代码:

// 糟糕的做法:到处 setState
function onStart() {
  setStatus('listening');
  // 万一此时已经处于 processing 状态呢?
}

function onResult() {
  setStatus('processing');
  // 如果 onStart 还没触发呢?
}

这种写法,状态一多就崩。我见过一个项目,因为没做状态校验,用户快速点击麦克风时,同时触发了三个 recognition.start(),浏览器直接报错。

用状态机就清晰多了。我一般这样定义:

const STATES = {
  IDLE: 'idle',
  LISTENING: 'listening',
  PROCESSING: 'processing',
  ERROR: 'error',
};

const TRANSITIONS = {
  [STATES.IDLE]: {
    START: STATES.LISTENING,
  },
  [STATES.LISTENING]: {
    RESULT: STATES.PROCESSING,
    ERROR: STATES.ERROR,
    END: STATES.IDLE,
  },
  [STATES.PROCESSING]: {
    END: STATES.IDLE,
    ERROR: STATES.ERROR,
  },
  [STATES.ERROR]: {
    RETRY: STATES.LISTENING,
    RESET: STATES.IDLE,
  },
};

然后写一个简单的状态机类:

class RecognitionStateMachine {
  constructor() {
    this.state = STATES.IDLE;
    this.listeners = {};
  }

  transition(event) {
    const nextState = TRANSITIONS[this.state]?.[event];
    if (!nextState) {
      console.warn(`非法转换:${this.state} -> ${event}`);
      return false;
    }
    this.state = nextState;
    this.emit('change', this.state);
    return true;
  }

  on(event, callback) {
    (this.listeners[event] ??= []).push(callback);
  }

  emit(event, data) {
    this.listeners[event]?.forEach(fn => fn(data));
  }
}
核心思路: 状态机强制规定了「从哪来,到哪去」。非法转换会被直接拦截,不会出现「状态漂移」。我在生产项目里用了两年,没出过一次状态相关的 bug。

4.3 状态与 UI 的映射

状态机只负责「状态流转」,UI 反馈需要单独映射。我习惯用一个对象把状态和 UI 行为绑定在一起:

const UI_MAP = {
  [STATES.IDLE]: {
    buttonText: '开始录音',
    buttonDisabled: false,
    showWaveform: false,
    showLoading: false,
    showError: false,
  },
  [STATES.LISTENING]: {
    buttonText: '录音中...',
    buttonDisabled: false,
    showWaveform: true,
    showLoading: false,
    showError: false,
  },
  [STATES.PROCESSING]: {
    buttonText: '识别中',
    buttonDisabled: true,
    showWaveform: false,
    showLoading: true,
    showError: false,
  },
  [STATES.ERROR]: {
    buttonText: '重试',
    buttonDisabled: false,
    showWaveform: false,
    showLoading: false,
    showError: true,
  },
};

这样,状态变化时直接取对应的 UI 配置,不用写一堆 if/else。代码量减少 40%,可读性翻倍。

4.4 用户交互反馈:加载动画与语音波形

状态管理最终要落到用户能感知的东西上。我重点讲两个反馈:加载动画和语音波形。

4.4.1 加载动画

加载动画用在 PROCESSING 状态。别搞太复杂,一个简单的旋转圆环就够了:

// 用 CSS 实现
@keyframes spin {
  to { transform: rotate(360deg); }
}

.loading-spinner {
  width: 32px;
  height: 32px;
  border: 3px solid #e0e0e0;
  border-top-color: #3498db;
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
}

注意一点:加载动画要在 PROCESSING 状态进入时立即显示,不要等 setTimeout。用户对延迟很敏感,200ms 的空白就会让人觉得「卡了」。

4.4.2 语音波形

波形动画是 LISTENING 状态的标配。我推荐用 Canvas 或 SVG 实时绘制,而不是用 GIF 图。原因很简单——波形要跟音频能量联动,GIF 做不到。

下面是一个简单的 SVG 波形实现:

<svg viewBox="0 0 200 60" class="waveform">
  <rect x="10" y="20" width="8" height="20" rx="4" class="bar bar-1"/>
  <rect x="30" y="10" width="8" height="40" rx="4" class="bar bar-2"/>
  <rect x="50" y="15" width="8" height="30" rx="4" class="bar bar-3"/>
  <rect x="70" y="5" width="8" height="50" rx="4" class="bar bar-4"/>
  <rect x="90" y="20" width="8" height="20" rx="4" class="bar bar-5"/>
  <rect x="110" y="10" width="8" height="40" rx="4" class="bar bar-6"/>
  <rect x="130" y="25" width="8" height="10" rx="4" class="bar bar-7"/>
  <rect x="150" y="15" width="8" height="30" rx="4" class="bar bar-8"/>
  <rect x="170" y="20" width="8" height="20" rx="4" class="bar bar-9"/>
</svg>
// 用 JS 控制每个柱子的高度变化
function animateWaveform(audioLevel) {
  const bars = document.querySelectorAll('.bar');
  bars.forEach((bar, index) => {
    const height = 10 + Math.random() * audioLevel * 40;
    bar.setAttribute('height', height);
    bar.setAttribute('y', 60 - height);
  });
  requestAnimationFrame(() => animateWaveform(audioLevel));
}
我曾经踩过的坑: 波形动画的刷新率不要超过 60fps,否则移动端会发热严重。用 requestAnimationFrame 而不是 setInterval,浏览器会在合适的时机渲染,省电又流畅。

4.5 完整的状态管理流程图

下面这张 SVG 图把整个状态流转和 UI 反馈的关系画清楚了。你可以把它贴在项目文档里,团队沟通效率会高很多。

语音识别状态机与UI反馈流程图 语音识别状态机与 UI 反馈 IDLE 按钮:开始录音 LISTENING 波形动画 PROCESSING 加载动画 ERROR 错误提示 用户点击 result 事件 error 事件 end 事件 重置 重试 状态机规则 1. 每个状态有明确的 进入和退出条件 2. 非法转换被拦截 3. UI 反馈与状态绑定 4. 错误状态可恢复 5. 状态变化触发 对应 UI 更新

4.6 实战中的避坑指南

最后分享几个我踩过的坑,希望能帮你省点时间。

  • 不要依赖 end 事件做清理。 有时候 end 不会触发(比如浏览器切后台)。我建议加一个 visibilitychange 监听,页面不可见时主动终止识别。
  • 错误处理要区分「可恢复」和「不可恢复」。 比如网络超时可以重试,但「无麦克风权限」这种错误,重试也没用,直接提示用户去设置。
  • 波形动画不要用真实音频数据。 浏览器安全策略限制,Web 页面拿不到麦克风的原始音频能量。我一般用随机数模拟,用户感知不到区别。
  • 状态机的「初始状态」一定要明确。 我见过有人忘记初始化,导致页面加载后状态是 undefined,按钮直接崩了。建议在构造函数里强制设置初始状态。
我的习惯: 每次状态变化都打一条日志,带上时间戳和事件来源。线上排查问题时,这些日志就是你的「黑匣子」,能快速定位是哪个环节出了问题。

状态管理这件事,说白了就是「把复杂流程拆成简单状态,再给每个状态配上对应的 UI」。用状态机兜底,用事件驱动流转,用 UI 映射表做反馈——这套组合拳打下来,语音识别的前端代码会变得非常稳。我在好几个项目里验证过,效果都不错。