# Flutter 实时语音识别工程实践:从音频采集到流式 ASR 的完整链路

语音识别看起来只是"打开麦克风,把声音变成文字",但真正落地到移动端后,它会迅速演变成一个跨越权限、音频、网络、协议、状态机和 UI 生命周期的复杂系统。

本文结合一个 Flutter 聊天场景中的 ASR 实现,拆解一套基于科大讯飞在线实时转写服务的完整方案,重点讨论以下工程问题:

  • 如何避免 WebSocket 建连期间丢失用户说话的开头
  • 如何按照真实时间速率发送 PCM 音频
  • 如何合并不断变化的中间识别结果
  • 如何处理暂停、继续、取消和发送
  • 如何隔离过期异步回调,避免不同识别会话串话
  • 当前方案还存在哪些性能和可靠性边界

一、整体架构:ASR 不是一个 SDK,而是一条数据流水线

当前方案本质上是一套"本地实时采集 + 云端流式识别"系统:

text 复制代码
用户长按说话
    ↓
登录与麦克风权限检查
    ↓
业务服务器获取临时鉴权参数
    ↓
启动 PCM 音频采集
    ↓
并行建立 ASR WebSocket
    ↓
握手前音频进入本地缓冲
    ↓
收到 started 后回放缓冲
    ↓
按 40ms 节奏持续发送音频
    ↓
解析中间结果与确定性结果
    ↓
实时更新输入面板
    ↓
暂停 / 继续 / 取消 / 发送

代码按职责分成了五层:

层级 核心职责 主要实现
交互层 长按、松手、取消、发送、波形动画 VoiceInputWidget
业务协调层 权限、鉴权、暂停继续、文本累积 VoiceRecognitionService
SDK 门面层 ASR 生命周期与识别 Stream AsrSdk
音频服务层 录音、格式转换、首包缓冲、音量计算 AsrService
协议策略层 WebSocket、分包、心跳、结果解析 XfyunAsrStrategy

这种分层的价值在于:录音器、WebSocket 协议和聊天业务没有直接耦合。将来替换 ASR 厂商时,主要改动可以被限制在策略层及其鉴权逻辑中。

二、安全鉴权:客户端不应持有 ASR 密钥

在线 ASR 通常需要 API Key、Secret 或签名。把长期密钥直接写在 Flutter 客户端中并不安全:安装包可以被反编译,密钥也可能被恶意复用。

当前方案采用了两段式鉴权:

text 复制代码
Flutter 客户端
    │
    │ POST /utils/rtasrllm/req-query-String
    ▼
业务服务器
    │
    │ 使用服务端密钥生成临时鉴权信息
    ▼
baseString + signature
    │
    ▼
客户端拼接 ASR WebSocket 地址

客户端提交的主要参数包括:

json 复制代码
{
  "audioEncode": "pcm_s16le",
  "sampleRate": 16000,
  "roleType": 0,
  "engPunc": "0",
  "engVadMdn": 2
}

服务端返回:

json 复制代码
{
  "baseString": "...",
  "signature": "..."
}

客户端最终构造 WebSocket 连接:

text 复制代码
wss://.../communicate/v1?{baseString}&signature={urlEncodedSignature}

需要注意,临时鉴权信息同样属于敏感数据。生产日志不应直接输出 baseStringsignature,更安全的做法是只记录请求 ID、错误码和鉴权耗时。

三、音频格式:为什么选择 16kHz、单声道、PCM16

当前录音配置为:

dart 复制代码
const config = RecordConfig(
  encoder: AudioEncoder.pcm16bits,
  sampleRate: 16000,
  numChannels: 1,
);

也就是:

  • 采样率:16000Hz
  • 声道数:1
  • 位深:16bit
  • 编码:PCM signed 16-bit little-endian

每秒原始音频量为:

text 复制代码
16000 samples/s × 2 bytes/sample × 1 channel
= 32000 bytes/s
≈ 31.25 KiB/s

对语音而言,16kHz 能够覆盖主要频率信息,同时比 44.1kHz 或 48kHz 显著减少网络和计算开销。

录音插件返回 PCM 字节后,应用先将每两个字节按小端序还原为 int16,再归一化到 [-1, 1]

text 复制代码
floatSample = int16Sample / 32768.0

发送前又执行反向转换:

text 复制代码
int16Sample = clamp(floatSample, -1, 1) × 32767

转为 Float32 的主要价值是方便计算音量、VAD、归一化或滤波。不过当前发送链路没有实际应用预处理,因此"PCM16 → Float32 → PCM16"产生了一次额外遍历和内存分配。

更高效的实现可以同时维护两条路径:

text 复制代码
原始 PCM16 ───────────────→ WebSocket 发送
     │
     └→ 低成本采样/转换 → 音量与波形计算

这样能减少高频录音回调中的对象创建和 GC 压力。

四、首字保护:录音和 WebSocket 必须并行启动

实时语音最常见的体验问题之一是:用户说了"帮我查询天气",最终结果却变成"我查询天气"。

原因通常不是识别模型,而是音频采集和网络连接的启动顺序。如果实现为:

text 复制代码
先连接 WebSocket
→ 等待服务端 started
→ 再启动录音

那么用户在连接期间说出的内容根本没有进入录音器。

当前方案采用了更合理的顺序:

text 复制代码
启动录音器
    ├── 音频进入本地缓冲队列
    └── 异步连接 WebSocket
              ↓
       收到 started 消息
              ↓
       回放全部缓冲音频
              ↓
       后续音频直接发送

核心判断可以抽象为:

dart 复制代码
if (!strategy.isWsReady) {
  buffer.add(audio);
} else {
  strategy.acceptAudio(audio);
}

缓冲队列最多保留约 32 个录音回调包。队列满时删除最旧数据,形成一个有界环形缓冲:

dart 复制代码
if (buffer.length >= maxPackets) {
  buffer.removeFirst();
}
buffer.add(packet);

这个设计同时解决了两个问题:

  1. WebSocket 握手不会阻塞本地录音。
  2. 缓冲区有上限,不会因为网络长时间不可用而无限占用内存。

不过,"约两秒"是基于"每次回调约 1024 个采样点"的经验假设。不同平台、插件版本和系统调度可能返回不同大小的数据块。更稳健的容量控制不应按包数,而应按总采样数或总字节数计算:

text 复制代码
bufferedDuration = bufferedSampleCount / sampleRate

还需要明确一个边界:当前首字缓冲只覆盖"录音器启动后到 WebSocket 收到 started 之前"的时间段。权限检查和服务端鉴权发生在录音启动之前,因此它不能消除权限或鉴权延迟造成的首字窗口。

五、40ms 分包:让发送速度匹配真实音频时间

WebSocket 建立后,音频不能简单地"有多少发多少"。如果把缓冲中的两秒音频瞬间全部写入 Socket,服务端可能出现:

  • 内部队列突增
  • 实时 VAD 行为异常
  • 限流或连接中断
  • 识别时间轴与真实时间不一致

当前方案将音频切成每包 1280 字节,并每 40ms 发送一包。

为什么是 1280 字节?

text 复制代码
16000 samples/s
× 2 bytes/sample
× 0.04s
= 1280 bytes

因此一个分包恰好代表 40ms 音频:

dart 复制代码
Timer.periodic(
  const Duration(milliseconds: 40),
  (_) => sendOnePacket(),
);

这使网络发送速率与音频生产速率基本一致。

当连续 15 秒没有发送音频时,客户端会发送一个全零静音包作为心跳,避免空闲连接被中间网络设备或服务端关闭。

不过,当前待发送队列没有容量限制。如果事件循环阻塞、网络写入异常或生产速率持续高于消费速率,队列仍可能不断增长。生产方案应增加:

  • 队列水位监控
  • 最大积压时长
  • 超限后的丢弃或终止策略
  • 发送延迟和积压包数埋点

六、识别结果不是字符串,而是一条不断修订的时间线

流式 ASR 不会只返回一次最终文本。服务端会持续发送不同阶段的结果:

  • type=1:中间结果,后续可能被修订
  • type=0:确定性结果,可以稳定累积
  • ls=true:当前语音段的最后结果

假设用户说"帮我查询明天的天气",服务端可能依次返回:

text 复制代码
帮
帮我查
帮我查询明天
帮我查询明天的天气

如果每次都直接追加,结果会变成:

text 复制代码
帮帮我查帮我查询明天帮我查询明天的天气

因此当前方案将文本拆成两个概念:

text 复制代码
committedText:已经确定的历史文本
temporaryText:当前正在变化的中间文本

合并规则如下:

dart 复制代码
if (ls || type == '0') {
  committedText += text;
  display(committedText);
} else {
  display(committedText + text);
}

关键点是:中间结果只参与显示,不写入确定性文本;只有确定性结果才真正提交。

响应解析还做了两级兼容:

  1. 优先使用强类型模型解析 cn.st.rt.ws.cw.w
  2. 解析失败时退回 Map 结构的兼容解析。

这对第三方接口非常重要,因为真实服务偶尔会出现数字与字符串混用。例如说话人编号 rl 可能返回整数,也可能返回字符串。模型层通过 int.tryParse 兼容两种格式,避免单个非关键字段破坏整段识别。

七、三层状态机:不要用一个 isRecording 管理所有状态

语音输入至少涉及三套不同语义的状态。

底层识别器状态:

text 复制代码
idle
→ loading
→ ready
→ listening
→ stopping
→ completed

异常和取消还有独立分支:

text 复制代码
listening → canceling → canceled
任意阶段 → error

SDK 生命周期状态:

text 复制代码
notInitialized
→ initializing
→ ready
→ started

语音面板状态:

text 复制代码
pausedEmpty  无文本、未录音
recording    正在录音
paused       已暂停、存在文本

这三者不能合并为一个布尔值:

  • SDK 已启动,不代表正在录音。
  • 正在显示录音动画,不代表 WebSocket 已经完成握手。
  • 面板暂停,不代表底层停止动作已经完成。
  • 识别结束,不代表文字一定应该发送。

清晰的状态分层可以避免 UI、设备和网络生命周期相互污染。

八、并发治理:真正棘手的是"已经过期的成功"

移动端 ASR 中最危险的竞态不是请求失败,而是旧请求在不合适的时间成功。例如:

text 复制代码
用户按下说话
→ 开始请求鉴权
→ 用户立刻松手或关闭面板
→ 鉴权请求返回成功
→ 旧流程继续启动录音

如果只检查 mountedisRecording,很难完整解决这类问题。

当前方案使用单调递增的 request ID:

dart 复制代码
final requestId = ++currentRequestId;

每经过一个异步边界,都验证:

dart 复制代码
if (requestId != currentRequestId) {
  return;
}

当用户取消、关闭面板、切换键盘或发起新一轮识别时,只需递增当前 ID,所有旧回调立即在逻辑上失效。

SDK 层又维护了独立的 recognition session ID,用来隔离底层录音回调。于是形成两道防线:

text 复制代码
业务 request ID
    └── 防止旧权限、鉴权和 UI 请求继续执行

SDK session ID
    └── 防止旧识别结果写入新 Stream

此外,暂停操作是异步的。旧录音停止前,如果立即开始下一轮录音,可能发生录音器资源竞争。因此业务层保存正在执行的 stop Future,开始新识别前必须等待:

dart 复制代码
await waitForPendingStop();

它相当于在相邻两次识别之间建立了一个异步屏障:

text 复制代码
Session N stop 完成
        ↓
Session N+1 才允许 start

九、暂停和继续:每轮识别独立,业务层负责拼接

"继续说话"不是恢复同一个 WebSocket,而是:

  1. 停止当前识别。
  2. 保存已经得到的文本。
  3. 重新获取鉴权。
  4. 创建新的识别会话。
  5. 将新结果追加到旧文本之后。

最终显示文本可以表达为:

text 复制代码
最终显示文本 = 历史累积文本 + 当前会话文本

新会话的 ASR Stream 只负责本轮内容,业务层持有累积文本:

dart 复制代码
final newText = accumulatedText.isNotEmpty
    ? accumulatedText + currentSessionText
    : currentSessionText;

这种设计比复用底层识别器内部字符串更清晰,因为"继续说话"属于交互语义,不属于 ASR 引擎语义。

用户还可以手动编辑识别文本。编辑后必须同步更新累积文本,否则下一次继续识别时,旧的未编辑内容会重新出现。

十、停止、取消和发送是三种不同的终止语义

三种操作对数据的处理不同:

操作 停止录音 等待尾包 保留文本 发送消息
暂停
取消 否或忽略最终结果
发送

正常停止时,客户端先停止本地录音,然后等待待发送队列排空,最长约三秒:

text 复制代码
75 × 40ms = 3000ms

随后发送结束消息:

json 复制代码
{
  "end": true,
  "sessionId": "..."
}

当前实现再固定等待 500ms,然后关闭连接并输出最终结果。

这是一个可工作的工程折中,但固定等待存在边界:网络抖动或服务端负载较高时,最终识别帧可能在 500ms 之后到达。更可靠的方式是:

  • 等待服务端明确的 completed/end action
  • 同时设置最大结束超时
  • 超时后返回最后一个已提交结果

也就是"协议事件优先,超时兜底",而不是单纯依赖固定延时。

发送前还通过独立策略过滤无效文本:

text 复制代码
取消的请求         → 不发送
request ID 已过期  → 不发送
trim 后为空         → 不发送
其他情况            → 生成发送意图

这样能阻止旧回调、空白识别结果或取消后的文本进入聊天消息链路。

十一、波形不是装饰,它也是采集链路的观测窗口

每批 Float32 音频会计算均方根:

text 复制代码
RMS = sqrt(sum(sample²) / N)

再将经验范围 [0.002, 0.15] 映射到 [0, 1],驱动七段式波形动画。

这不仅改善用户体验,也可以用来诊断问题:

text 复制代码
波形始终为 0
→ 权限、录音器、输入设备或 PCM 数据异常

波形正常但没有文字
→ 鉴权、WebSocket、协议或服务端识别异常

有文字但波形不动
→ 音量订阅或 UI 重绘链路异常

当前实现还设置了六秒无识别结果提示:如果持续录音但文字仍为空,识别气泡会变红。需要注意,这只是 UI 超时提示,并不会自动停止录音。

十二、手势生命周期:为什么松手事件还需要全局监听

语音输入通常依赖"长按开始、松手暂停"。但 Flutter Widget 可能在按压期间重建,手指也可能移出命中区域,导致 onLongPressEndonLongPressCancel 丢失。

结果就是:用户已经松手,录音却仍在继续。

当前方案在长按开始时额外注册全局 Pointer Route,监听物理层面的:

  • PointerUpEvent
  • PointerCancelEvent

收到事件后统一触发暂停,并通过 _isVoicePressing 保证只执行一次。这个处理把 ASR 停止动作绑定到真实手指释放,而不是绑定到某个可能被重建的 Widget。

相关 Widget 测试覆盖了:

  • 正常松手能够暂停
  • 长按结束回调丢失时,全局 PointerUp 仍能暂停
  • PointerCancel 时仍能完成清理
  • 重复事件不会重复暂停

十三、当前方案最值得继续优化的地方

1. UI 的 recording 状态早于真实录音

业务层在请求服务端鉴权之前就把面板切换为 recording。因此弱网下可能出现:

text 复制代码
UI 已显示正在录音
→ 鉴权仍在网络中
→ 真实录音器尚未启动

现有首字缓冲只覆盖"录音器启动后到 WebSocket started 之前"的窗口,不能覆盖权限和鉴权阶段。

可以考虑:

  • 提前预取短效鉴权
  • 增加 preparing 面板状态
  • 真实录音启动后才显示 recording
  • 先录音,再并行获取鉴权,但严格限制本地缓冲时长

2. 减少音频格式往返转换

当前每个音频块执行:

text 复制代码
Uint8List → Float32List → List<double> → List<int> → Uint8List

可以让 WebSocket 直接使用原始 PCM16,只在音量计算时读取样本,减少分配与复制。

3. 为待发送队列增加背压

除了握手缓冲,WebSocket 发送队列也需要:

  • 最大字节数
  • 最大音频时长
  • 高水位告警
  • 网络过慢时的中止策略

4. 用服务端完成事件替代固定 500ms

固定等待难以适配所有网络条件,应等待明确的结束响应,同时保留超时兜底。

5. 建立可观测指标

建议至少记录以下非敏感指标:

text 复制代码
permission_duration
auth_duration
recorder_start_duration
websocket_connect_duration
server_started_duration
first_audio_send_duration
first_partial_result_duration
stop_flush_duration
pending_audio_duration

有了这些数据,才能判断"识别慢"究竟发生在权限、鉴权、录音启动、握手还是模型推理阶段。

6. 修正隐私描述和配置遗留

当前方案实际会把音频流发送到云端 ASR 服务。隐私说明应明确表达音频会用于在线识别,避免让用户误解为完全本地处理。

同时,iOS 配置仍包含"本地存储语音识别模型"的说明,而当前在线方案并没有启用本地 Sherpa ONNX 模型。这类历史配置应和真实架构保持一致。

结语

一个稳定的实时 ASR 系统,核心不只是"把 PCM 发给服务端",而是同时控制三条时间线:

text 复制代码
用户时间线:按下、松手、取消、编辑、发送
设备时间线:权限、录音器、音频回调、资源释放
网络时间线:鉴权、握手、分包、结果修订、会话结束

真正可靠的实现需要做到:

  • 音频采集与网络握手解耦
  • 使用有界缓冲保护开头语音
  • 按真实音频时间节奏发送数据
  • 区分中间结果和确定性结果
  • 用显式状态机管理生命周期
  • 用 request/session ID 隔离旧异步回调
  • 用停止屏障防止相邻录音会话抢占资源
  • 用协议完成事件保证尾部识别结果
  • 对延迟、积压和失败阶段建立可观测性

ASR 模型决定了识别准确率,而这些工程设计决定了它在真实用户手里是否像一个可靠的功能。

以上皆由codex总结

相关推荐
iCOD3R几秒前
Skill - kill-ai-slop 解决“AI 味”样式
前端·css·ai编程
皮音17 分钟前
Skill 在项目中的实践 —— 从 Agent 困境到 Skill 工程化
前端
大龄秃头程序员30 分钟前
RN 0.86 新架构实战:TurboModule + Fabric 组件 + iOS 原生容器,完整代码开源
前端·react native
这是个栗子32 分钟前
前端开发中的常用工具函数(八)
开发语言·前端·javascript
Hilaku43 分钟前
Vue 和 React 真正的差距,不在语法,而在团队犯错成本
前端·javascript·程序员
研☆香1 小时前
为什么变量声明在赋值前也能使用?—— 深入理解 JavaScript 变量提升与作用域
开发语言·前端·javascript
Liora_Yvonne1 小时前
目录结构到底怎么设计?别再把 components 和 utils 当垃圾桶了
前端
柯克七七2 小时前
我把 bug 修得太干净了,测试组以为这个模块本来就没问题
前端·javascript·typescript
Null1552 小时前
浏览器唤起桌面端应用(终极篇)
前端·javascript
Quz2 小时前
QML Label 完整用法与 Text 组件选型
前端·qt