1. 问题现场:语音识别结果被“吞”了
做 ChatFly 这个项目的过程中,我遇到过一个非常典型、也非常隐蔽的 bug:用户对着麦克风说了一段话,前端显示录音正常结束,请求也发出去了,但返回的识别结果里,最后几个字莫名其妙消失了。不是识别错,是直接没了。比如用户说“帮我查一下明天的天气”,返回的是“帮我查一下明天的天”。听起来像段子,但这个问题真实存在,而且排查过程相当折磨人。
这个现象在语音交互类产品里其实不算罕见,业内俗称“吃字”或“截断”。它通常不是 ASR 引擎本身的问题,而是整条音频链路上某个环节提前“收工”了。ChatFly 是一个跑在 macOS 上的对话助手项目,语音输入是它的核心入口之一,所以这个问题必须解决。本文适合正在做语音交互、实时音频采集、或者任何涉及流式识别的开发者阅读,尤其是那些已经跑通了 demo、但在真实使用中遇到“结果不完整”的人。
我会从音频采集、格式封装、请求发送、服务端识别、结果返回这几个环节逐一拆解,把“话被吃了”这件事的来龙去脉讲清楚,并给出可直接复现的排查方法和修复方案。核心关键词会自然穿插在各个环节里,包括 ASR、ChatFly、macOS、WAV、finish_reason 等。
2. 整体链路拆解:声音从麦克风到文字经历了什么
2.1 ChatFly 的语音输入架构
ChatFly 在 macOS 上的语音输入链路大致是这样的:前端通过系统音频接口采集麦克风数据,拿到的是 PCM 裸流;然后按照固定时长切片,封装成 WAV 格式;接着通过 WebSocket 或 HTTP 流式发送给后端;后端调用 ASR 服务进行识别;识别结果以流式方式返回,前端逐段渲染。整个链路里,任何一个环节对“结束”的理解不一致,都会导致最后一段音频被丢弃。
我最初的设计是:前端检测到用户停止说话后,发送一个“结束”信号,后端收到后调用 ASR 的 finish 接口,等待最终结果返回。听起来没问题,但实际运行时,前端停止录音和发送结束信号之间有一个时间差,而这个时间差里产生的音频数据没有被正确 flush 出去。
2.2 为什么选择 WAV 而不是直接传 PCM
很多人会问,既然 ASR 服务支持 PCM 裸流,为什么还要封装成 WAV?原因有几个。第一,WAV 自带采样率、位深、声道数等元信息,服务端不需要额外约定,减少沟通成本。第二,WAV 在 macOS 上调试方便,可以直接用 afplay 播放验证音频是否完整。第三,部分 ASR 服务对 WAV 的兼容性更好,尤其是涉及中英文混合识别时。
但 WAV 封装也带来了一个坑:WAV 头里的 data chunk size 字段。如果这个字段写的是 0 或者不准确,某些 ASR 服务会认为音频流还没结束,一直等待,直到超时;而另一些服务则会直接按实际收到的字节数处理,导致最后一段被截断。我遇到的就是后者。
2.3 finish_reason 在流式识别中的角色
流式 ASR 返回结果时,通常会带一个 finish_reason 字段,用来标识当前识别片段的状态。常见的值有 null、stop、length 等。null 表示还在识别中,stop 表示正常结束,length 表示达到长度限制被截断。如果前端只处理了 stop 的情况,而忽略了 length 或者 null 之后的最终结果,就会表现为“话被吃了”。
在 ChatFly 的早期版本里,我就是只判断了 finish_reason === 'stop' 才把结果拼接到最终文本里,结果当 ASR 因为音频流提前结束而返回 length 时,最后一段识别结果被直接丢弃了。这个问题在短语音上不明显,因为短语音通常一次就返回 stop;但在长语音或者网络抖动时,length 出现的概率大大增加。
3. 核心细节:音频采集与封装的几个关键参数
3.1 macOS 上的音频采集配置
在 macOS 上采集音频,我使用的是 AVAudioEngine。它的优势是延迟低、可控性强,适合实时场景。核心配置如下:
let audioEngine = AVAudioEngine() let inputNode = audioEngine.inputNode let recordingFormat = inputNode.outputFormat(forBus: 0) inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { buffer, time in // 处理音频数据 } audioEngine.prepare() try audioEngine.start()这里有几个参数需要特别注意。bufferSize 设置为 1024 是在延迟和性能之间取的平衡值。如果设得太小,比如 256,回调频率会很高,CPU 占用上升;设得太大,比如 4096,延迟会明显增加,用户感觉“说完话要等很久才有反应”。1024 在 44.1kHz 采样率下大约是 23ms 的数据量,实测下来比较稳。
recordingFormat 是从 inputNode 拿到的实际格式,通常是 44.1kHz 或 48kHz,32 位浮点。但 ASR 服务通常要求 16kHz、16 位、单声道。所以这里需要做重采样和格式转换。我一开始偷懒,直接把 44.1kHz 的浮点数据发给服务端,结果识别率惨不忍睹,而且偶尔出现“吃字”。后来老老实实做转换,问题少了一大半。
3.2 WAV 封装的正确姿势
WAV 文件结构其实很简单,但细节容易出错。一个标准的 WAV 文件包含 RIFF chunk、fmt chunk 和 data chunk。其中 data chunk 的 size 字段表示音频数据的字节数。在流式场景下,这个字段有两种处理方式:一种是先写一个占位值,等录音结束后回填;另一种是直接写 0xFFFFFFFF,表示未知长度,由服务端按实际流处理。
我最初用的是第一种方式,但回填逻辑有 bug:录音结束回调触发时,最后一批音频数据还在缓冲区里没写入,导致 data chunk size 比实际数据少了几百字节。ASR 服务按这个 size 读取,自然就把最后几个字丢了。后来改成第二种方式,并且在发送结束信号前强制 flush 缓冲区,问题才彻底解决。
import struct import wave def create_wav_header(sample_rate=16000, channels=1, bits_per_sample=16): byte_rate = sample_rate * channels * bits_per_sample // 8 block_align = channels * bits_per_sample // 8 data_size = 0xFFFFFFFF # 未知长度 header = struct.pack( '<4sI4s4sIHHIIHH4sI', b'RIFF', 0xFFFFFFFF, b'WAVE', b'fmt ', 16, 1, channels, sample_rate, byte_rate, block_align, bits_per_sample, b'data', data_size ) return header这段代码生成的是一个“流式 WAV 头”,data size 和 RIFF size 都写成最大值。服务端收到后,会按实际流长度处理,不会因为 size 不匹配而截断。实测这个改动对“吃字”问题的改善非常明显。
3.3 音频切片的粒度选择
流式识别需要把音频切成小片发送。切片粒度太粗,延迟高;太细,请求次数多,服务端压力大。我试过 100ms、200ms、500ms 三种粒度。100ms 的延迟最低,但 WebSocket 消息太频繁,在弱网环境下容易堆积;500ms 延迟明显,用户说完话要等半秒才有反应;200ms 是比较甜的点,既保证了实时性,又不会给网络太大压力。
但切片粒度还影响另一个问题:最后一片的处理。如果用户停止说话时,最后一片音频不足 200ms,有些实现会直接丢弃这片,导致“吃字”。我的做法是:停止录音时,不管最后一片多短,都强制发送,并且带上一个 is_final 标记。后端收到 is_final 后,立即调用 ASR 的 finish 接口,并等待最终结果。
4. 实操过程:从复现到修复的完整记录
4.1 如何稳定复现“吃字”问题
复现这个问题需要一点技巧,因为它在短语音上几乎不出现。我的方法是:录制一段 10 秒以上的语音,在结尾处故意放慢语速,把最后两个字拖长。然后在录音停止后立即观察返回结果。如果最后两个字丢失,说明问题复现成功。
另一个方法是模拟网络抖动。在发送最后一片音频时,人为增加 200ms 延迟,然后立即发送结束信号。这样后端可能在收到最后一片之前就收到了结束信号,导致最后一片被丢弃。这个场景在真实移动网络下很常见,所以修复时必须考虑。
我用 Charles 抓包看了整个 WebSocket 通信过程,发现结束信号确实比最后一片音频先到达后端。后端的处理逻辑是:收到结束信号后,等待 100ms,然后调用 ASR finish。但最后一片音频因为网络延迟,150ms 后才到,已经错过了窗口期。
4.2 修复方案一:调整结束信号的发送时机
最直接的修复是:前端在发送结束信号前,确保所有音频片都已经成功发送。WebSocket 的 send 方法是异步的,调用后并不代表数据已经发出。我用了以下方式确保顺序:
async function stopRecording() { // 停止采集,flush 缓冲区 await flushAudioBuffer(); // 发送最后一片,带 is_final 标记 await sendAudioChunk(lastChunk, true); // 等待所有发送完成 await waitForSendQueueEmpty(); // 发送结束信号 sendEndSignal(); }waitForSendQueueEmpty 的实现依赖于 WebSocket 的 bufferedAmount 属性。当 bufferedAmount 为 0 时,表示所有数据已经交给底层协议栈。虽然不能保证对方一定收到,但至少保证了发送顺序。
4.3 修复方案二:后端增加等待窗口
前端只能保证发送顺序,不能保证到达顺序。所以后端也需要配合。我的做法是:收到结束信号后,不立即调用 ASR finish,而是启动一个 300ms 的等待窗口。在这个窗口内,如果还有音频片到达,继续追加到识别流中;窗口结束后,再调用 finish。
这个等待窗口的长度需要权衡。太长,用户感觉响应慢;太短,起不到作用。300ms 是我实测下来比较合适的值,既能覆盖大部分网络抖动,又不会让用户明显感知到延迟。如果产品对延迟极其敏感,可以做成动态的:根据当前网络 RTT 自动调整窗口长度。
4.4 修复方案三:正确处理 finish_reason
即使音频完整送达,finish_reason 的处理也不能马虎。我的最终逻辑是这样的:
final_text = "" async for chunk in asr_stream: if chunk.finish_reason is None: # 中间结果,可以用于实时展示 partial_text = chunk.text elif chunk.finish_reason == "stop": final_text += chunk.text break elif chunk.finish_reason == "length": # 达到长度限制,但可能还有后续 final_text += chunk.text # 继续等待,直到收到 stop 或流结束 else: # 其他情况,保守处理 final_text += chunk.text关键点是:不要因为收到 length 就认为识别结束。length 只表示当前片段因为长度限制被切分,后面可能还有内容。只有 stop 或者流自然结束,才是真正的结束。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 可能原因 | 排查方法 | 修复方案 |
|---|---|---|---|
| 最后几个字丢失 | 音频片未 flush | 抓包看最后一片是否发送 | 停止录音时强制 flush |
| 识别结果为空 | WAV 头 data size 为 0 | 用 afplay 播放验证 | 使用流式 WAV 头 |
| 长语音被截断 | finish_reason 为 length 未处理 | 打印 finish_reason 日志 | 继续等待直到 stop |
| 弱网下丢字 | 结束信号先于音频到达 | 模拟延迟测试 | 后端增加等待窗口 |
| 识别率低 | 采样率不匹配 | 检查音频格式 | 重采样到 16kHz |
5.2 独家避坑技巧
第一个技巧:在 macOS 上调试音频,afplay 是最直接的工具。把采集到的 WAV 文件保存下来,用 afplay 播放,如果播放出来的声音本身就是缺尾的,那问题在采集端;如果播放完整但识别缺尾,问题在传输或服务端。这一步能省掉大量猜测时间。
第二个技巧:WebSocket 的 bufferedAmount 在 macOS 的某些版本上表现不一致。我遇到过 bufferedAmount 一直不为 0 的情况,后来发现是底层实现的问题。替代方案是用一个发送队列,每发送一片就记录一个序号,收到服务端的 ack 后再出队。虽然麻烦一点,但可靠性高很多。
第三个技巧:ASR 服务的超时时间要设置合理。我最初用的是默认的 10 秒,结果长语音识别到一半就超时了。后来改成 30 秒,并且在前端增加了心跳包,保持连接活跃。心跳包的间隔建议是超时时间的三分之一,比如 30 秒超时就用 10 秒心跳。
第四个技巧:如果用的是云服务商的 ASR,注意看他们的文档里关于“流式结束”的说明。不同厂商对 finish 信号的处理方式不一样。有的要求发送一个空的音频片表示结束,有的要求发送特定的 JSON 指令。我踩过这个坑,按 A 厂商的方式给 B 厂商发结束信号,结果 B 厂商一直等不到结束,最后超时返回了不完整的结果。
5.3 一个容易被忽略的细节:音频增益
在排查“吃字”问题的过程中,我还发现了一个附带问题:macOS 上不同麦克风的增益差异很大。内置麦克风的增益通常偏低,录出来的音频波形幅度很小,ASR 服务在识别时可能把低幅度的尾音当作噪声过滤掉,表现为“吃字”。解决方法是采集时做自动增益控制(AGC),或者手动设置一个合适的增益值。
我试过在 AVAudioEngine 的 tap 回调里做软件增益,简单乘以一个系数。系数太小没效果,太大又会导致削波。后来改用系统的 AGC,效果稳定很多。如果你也在 macOS 上做音频采集,建议优先用系统提供的 AGC,不要自己硬调。
6. 后续扩展与个人体会
这个问题解决之后,ChatFly 的语音输入稳定性提升了一个档次。用户反馈里“识别不全”的抱怨基本消失了。但我也意识到,流式音频处理里的坑远不止这一个。比如回声消除、噪声抑制、多人对话分离,每一个都是独立的大课题。
我个人在实际操作中的体会是:音频链路的问题,一定要用“分段验证”的思路去排查。不要一上来就怀疑 ASR 引擎,先把采集到的音频保存下来听一遍,确认音频本身是完整的;然后再看传输过程,确认数据没有丢失;最后才去检查服务端的处理逻辑。这个顺序能帮你快速定位问题所在的环节,避免在错误的方向上浪费时间。
另外,finish_reason 这个字段虽然小,但它在流式识别里的作用非常关键。建议在开发早期就把它的所有可能值都处理一遍,不要只处理 stop。很多“吃字”问题的根源,就是对 finish_reason 的理解不到位。把这个字段用好了,语音交互的体验会有质的提升。