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));
}
}
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));
}
requestAnimationFrame 而不是 setInterval,浏览器会在合适的时机渲染,省电又流畅。
4.5 完整的状态管理流程图
下面这张 SVG 图把整个状态流转和 UI 反馈的关系画清楚了。你可以把它贴在项目文档里,团队沟通效率会高很多。
4.6 实战中的避坑指南
最后分享几个我踩过的坑,希望能帮你省点时间。
- 不要依赖
end事件做清理。 有时候end不会触发(比如浏览器切后台)。我建议加一个visibilitychange监听,页面不可见时主动终止识别。 - 错误处理要区分「可恢复」和「不可恢复」。 比如网络超时可以重试,但「无麦克风权限」这种错误,重试也没用,直接提示用户去设置。
- 波形动画不要用真实音频数据。 浏览器安全策略限制,Web 页面拿不到麦克风的原始音频能量。我一般用随机数模拟,用户感知不到区别。
- 状态机的「初始状态」一定要明确。 我见过有人忘记初始化,导致页面加载后状态是
undefined,按钮直接崩了。建议在构造函数里强制设置初始状态。
状态管理这件事,说白了就是「把复杂流程拆成简单状态,再给每个状态配上对应的 UI」。用状态机兜底,用事件驱动流转,用 UI 映射表做反馈——这套组合拳打下来,语音识别的前端代码会变得非常稳。我在好几个项目里验证过,效果都不错。