资讯动态

流式ASR语音识别“吃字”问题排查与时间戳增量拼接方案

发布时间:2026/10/26 14:25:19 来源:尧图企业网站定制
1. 问题现场语音识别结果被“吞”了做 ChatFly 这个项目的过程中我遇到过一个非常诡异的问题用户对着麦克风说了一段话前端录音上传到后端ASR 接口也正常返回了结果但最终展示在聊天界面里的文字却少了一截。不是完全没识别出来而是“吃字”——开头几个字没了或者中间某一段凭空消失。这个问题不是每次必现大概十次里出现两三次而且跟说话内容、语速、环境噪音都没有明显的相关性。最开始我以为是前端录音的问题后来排查了一圈才发现根因出在 ASR 返回结果的finish_reason字段上。这篇文章就把整个排查过程和解决方案完整记录下来包括 macOS 环境下 WAV 音频的处理细节、ASR 流式返回的拼接逻辑以及finish_reason在不同状态下的含义和应对策略。如果你也在做语音相关的应用尤其是涉及实时语音转文字、流式返回拼接的场景这篇内容应该能帮你少走不少弯路。我会从整体架构设计讲到具体的代码实现再到实际踩过的坑和排查技巧尽量把每个环节都讲透。2. ChatFly 语音链路整体设计2.1 为什么选择流式 ASR 而不是整段识别ChatFly 是一个对话式应用用户按住麦克风说话松开后发送。最初的版本用的是“整段录音上传等 ASR 返回完整结果”的方案。这个方案实现简单前端用MediaRecorder录一段音频转成 WAV 格式通过 HTTP POST 发给后端后端调用 ASR 接口拿到完整文本后返回给前端展示。但实际用下来体验很差。用户说完话之后要等两三秒才能看到文字如果网络稍微慢一点等待时间更长。而且整段识别的方案没法做“边说边出字”的效果用户感知不到系统在实时处理。后来改成了流式 ASR前端录音的同时把音频数据分片通过 WebSocket 推给后端后端再转发给 ASR 服务ASR 一边接收音频一边返回中间结果。这样用户说话的过程中就能看到文字逐渐出现体验好很多。但流式方案引入了一个新的复杂度返回结果的拼接。ASR 不是一次性返回完整文本而是返回多个片段每个片段可能包含修正、覆盖、追加等不同语义。如果拼接逻辑写错了就会出现“吃字”的现象。2.2 macOS 环境下音频采集与格式转换ChatFly 的桌面端跑在 macOS 上音频采集用的是AVAudioEngine。这里有一个容易被忽略的细节macOS 默认的音频输入格式是 44.1kHz 或 48kHz 的 Float32 采样而大多数 ASR 服务要求的是 16kHz、16bit、单声道的 PCM 数据。所以采集到的音频必须做重采样和格式转换。我一开始用的是AVAudioConverter做转换但发现转换后的音频偶尔会出现开头一小段静音或者数据丢失的情况。后来换了一种方式先用AVAudioEngine采集原始 PCM然后手动做降采样和位深转换。具体来说48kHz 降到 16kHz 就是每 3 个采样点取 1 个Float32 转 Int16 就是乘以 32767 后取整。// 简化的降采样与格式转换逻辑 func convertToASRFormat(_ inputBuffer: AVAudioPCMBuffer) - Data? { guard let floatData inputBuffer.floatChannelData?[0] else { return nil } let frameCount Int(inputBuffer.frameLength) let sampleRate inputBuffer.format.sampleRate let ratio Int(sampleRate / 16000) var output [Int16]() output.reserveCapacity(frameCount / ratio) for i in stride(from: 0, to: frameCount, by: ratio) { let sample floatData[i] let clamped max(-1.0, min(1.0, sample)) output.append(Int16(clamped * 32767)) } return output.withUnsafeBufferPointer { Data(buffer: $0) } }这段代码看起来简单但有一个坑如果frameCount不是ratio的整数倍最后一个不完整的采样组会被丢弃。对于 48kHz 到 16kHz 的转换ratio是 3如果每次采集的帧数不是 3 的倍数就会累积丢失采样点。虽然每次丢的不多但长时间累积下来音频的时长会比实际说话时间短ASR 处理时就会感觉“少了点什么”。解决办法是在采集端保证每次回调的帧数是ratio的整数倍或者在转换时保留余数下次拼接后再处理。我选择的是后者用一个缓冲区暂存余数采样点。2.3 WAV 封装与传输格式的选择音频数据在 WebSocket 里传输时我最初直接发裸 PCM 数据。但后来发现裸 PCM 没有头部信息接收端不知道采样率、位深、声道数调试起来很麻烦。而且有些 ASR 服务要求音频必须是带头的 WAV 格式。所以后来改成了发送 WAV 格式的数据。WAV 的头部是 44 字节的 RIFF 结构包含采样率、位深、声道数、数据长度等信息。对于流式传输每次发送的音频片段都封装成独立的 WAV 文件接收端解析头部后提取 PCM 数据。import struct def create_wav_header(pcm_data_length, sample_rate16000, channels1, bits_per_sample16): byte_rate sample_rate * channels * bits_per_sample // 8 block_align channels * bits_per_sample // 8 header struct.pack( 4sI4s4sIHHIIHH4sI, bRIFF, 36 pcm_data_length, bWAVE, bfmt , 16, 1, # PCM format channels, sample_rate, byte_rate, block_align, bits_per_sample, bdata, pcm_data_length ) return header这里有一个细节pcm_data_length是实际音频数据的字节数不包括头部。如果这个值写错了有些 ASR 服务会直接拒绝处理或者只处理一部分数据。我踩过的坑是在流式场景下每次发送的片段长度不固定如果头部里的长度字段和实际数据长度不一致ASR 服务可能会截断数据导致识别结果不完整。3. finish_reason 的语义与拼接逻辑3.1 finish_reason 到底代表什么ASR 流式接口返回的每个结果片段里通常有一个finish_reason字段。这个字段的含义在不同的 ASR 服务里可能略有差异但大体上分为以下几种finish_reason 值含义处理方式null / 空中间结果还会继续返回追加到当前句子stop正常结束这句话说完了标记句子结束准备下一句length达到最大长度限制被截断需要拼接下一段content_filter内容被过滤丢弃或提示用户tool_calls触发了工具调用根据业务逻辑处理我遇到的问题就出在finish_reason为null和stop的切换上。ASR 返回中间结果时finish_reason是null这时候返回的文本可能是对前面内容的修正也可能是新增内容。如果简单地追加就会出现重复如果直接覆盖就会丢失之前的内容。3.2 中间结果与最终结果的拼接策略正确的做法是维护一个“当前句子”的缓冲区每次收到中间结果时用新结果替换缓冲区的内容而不是追加。只有当finish_reason为stop时才把缓冲区的内容固化到最终结果里并清空缓冲区准备下一句。class ASRResultAssembler: def __init__(self): self.final_text self.current_segment def process(self, result): text result.get(text, ) finish_reason result.get(finish_reason) if finish_reason is None: # 中间结果替换当前片段 self.current_segment text elif finish_reason stop: # 最终结果固化到最终文本 self.current_segment text self.final_text self.current_segment self.current_segment elif finish_reason length: # 被截断固化当前片段并继续 self.final_text self.current_segment self.current_segment return self.final_text self.current_segment这个逻辑看起来没问题但实际跑起来还是会出现“吃字”。原因在于ASR 返回中间结果的频率不是固定的有时候会连续返回多个null的中间结果有时候会直接跳到一个stop的结果。如果在stop之前没有收到完整的中间结果而stop返回的文本又比之前的中间结果短就会丢失内容。3.3 我踩过的坑中间结果覆盖导致丢字具体来说我遇到的情况是这样的用户说“今天天气怎么样”ASR 返回中间结果1{text: 今天, finish_reason: null}ASR 返回中间结果2{text: 今天天气, finish_reason: null}ASR 返回最终结果{text: 天气怎么样, finish_reason: stop}如果按照“最终结果覆盖当前片段”的逻辑第4步会把current_segment替换成“天气怎么样”然后固化到final_text。但前面的“今天”是在中间结果里出现的没有被固化结果就是“今天”丢了。这个问题的根因是ASR 的最终结果不一定包含完整的句子。在某些实现里最终结果只包含最后一个片段的内容前面的内容需要通过中间结果来拼接。但中间结果又可能是对前面内容的修正不能简单追加。3.4 解决方案基于时间戳的增量拼接我最终的解决方案是利用 ASR 返回结果里的时间戳信息来做增量拼接。每个结果片段除了text和finish_reason通常还会包含start_time和end_time表示这段文字对应的音频时间范围。有了时间戳就可以判断新返回的文本是新增内容还是修正内容如果新结果的start_time大于等于当前缓冲区的end_time说明是新增内容追加到缓冲区。如果新结果的start_time小于当前缓冲区的end_time说明是修正内容替换重叠部分。class TimestampedAssembler: def __init__(self): self.segments [] # 存储 (start_time, end_time, text) def process(self, result): text result.get(text, ) start result.get(start_time, 0) end result.get(end_time, 0) finish_reason result.get(finish_reason) # 移除与新区间重叠的旧片段 self.segments [ seg for seg in self.segments if seg[1] start or seg[0] end ] # 插入新片段 if text: self.segments.append((start, end, text)) self.segments.sort(keylambda x: x[0]) # 拼接所有片段 full_text .join(seg[2] for seg in self.segments) if finish_reason stop: # 句子结束可以做一些后处理 pass return full_text这个方案的核心思想是用时间戳来管理文本片段的重叠和覆盖关系而不是依赖finish_reason来判断是追加还是替换。finish_reason只用来判断句子是否结束不参与拼接逻辑。4. 实操过程与核心环节实现4.1 音频采集与分片发送的完整流程在 macOS 端音频采集的完整流程是这样的初始化AVAudioEngine设置输入节点的格式为 16kHz、16bit、单声道。安装 tap 回调在回调里获取音频数据。将音频数据转换成 WAV 格式的 Data。通过 WebSocket 发送给后端。let audioEngine AVAudioEngine() let inputNode audioEngine.inputNode let recordingFormat AVAudioFormat( commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: true )! inputNode.installTap(onBus: 0, bufferSize: 1600, format: recordingFormat) { buffer, time in guard let data buffer.toWAVData() else { return } self.webSocket.send(data) } audioEngine.prepare() try audioEngine.start()这里bufferSize设置为 1600对应 16kHz 下 100ms 的音频数据。这个值不能太小太小会导致 WebSocket 发送频率过高增加网络开销也不能太大太大会导致延迟增加用户感知不到实时性。100ms 是一个比较平衡的选择。4.2 后端转发与 ASR 调用后端收到 WebSocket 的音频数据后需要转发给 ASR 服务。这里有两种方式方式一后端作为代理把音频数据通过 WebSocket 转发给 ASR 服务ASR 返回的结果再通过 WebSocket 推回前端。方式二后端把音频数据缓存起来等用户说完后一次性调用 ASR 的 HTTP 接口。ChatFly 用的是方式一因为需要实时返回中间结果。后端用 Python 的websockets库维护两条 WebSocket 连接一条连接前端一条连接 ASR 服务。import asyncio import websockets async def handle_client(websocket): async with websockets.connect(ASR_WS_URL) as asr_ws: async def forward_to_asr(): async for message in websocket: await asr_ws.send(message) async def forward_to_client(): async for message in asr_ws: result json.loads(message) assembled assembler.process(result) await websocket.send(json.dumps({text: assembled})) await asyncio.gather(forward_to_asr(), forward_to_client())这段代码看起来简单但有一个隐患asyncio.gather会等待两个协程都完成才返回。如果其中一个协程因为异常退出另一个协程可能还在运行导致资源泄漏。实际生产环境需要加上异常处理和超时控制。4.3 前端展示与状态管理前端收到后端返回的文本后需要更新聊天界面。这里有一个细节中间结果和最终结果的展示方式不同。中间结果用灰色斜体显示表示“正在识别”最终结果用正常字体显示表示“已确认”。function updateTranscript(text, isFinal) { const element document.getElementById(transcript); element.textContent text; element.style.fontStyle isFinal ? normal : italic; element.style.color isFinal ? #333 : #999; }状态管理方面我用了一个简单的状态机IDLE空闲状态等待用户按下麦克风。RECORDING正在录音音频数据持续发送。PROCESSING录音结束等待 ASR 返回最终结果。DONE识别完成展示最终文本。状态切换的触发条件当前状态触发事件下一状态IDLE用户按下麦克风RECORDINGRECORDING用户松开麦克风PROCESSINGPROCESSING收到 finish_reasonstopDONEPROCESSING超时5秒DONEDONE用户再次按下麦克风RECORDING超时处理很重要。有时候 ASR 服务因为网络问题没有返回finish_reasonstop如果不加超时前端会一直卡在PROCESSING状态。我设置的是 5 秒超时超时后直接把当前缓冲区的内容作为最终结果展示。5. 常见问题与排查技巧实录5.1 识别结果开头丢字现象用户说话后识别结果的前几个字经常丢失。排查思路检查音频采集的起始时间。AVAudioEngine启动后第一次回调可能会有延迟导致开头一小段音频没有被采集到。检查 WAV 头部是否正确。如果头部里的数据长度字段写错了ASR 服务可能会跳过开头的数据。检查 ASR 服务的start_time配置。有些 ASR 服务默认从音频的 0.5 秒开始识别前面的内容会被忽略。解决方案在AVAudioEngine启动后先丢弃前几个回调的音频数据等引擎稳定后再开始发送。同时确认 WAV 头部的数据长度字段与实际数据长度一致。5.2 中间结果与最终结果不一致现象中间结果显示“今天天气不错”最终结果却变成“天气不错”少了“今天”。排查思路检查拼接逻辑。如果最终结果直接覆盖了中间结果而最终结果又不包含完整内容就会丢字。检查 ASR 服务的返回格式。有些服务在finish_reasonstop时只返回最后一个片段前面的内容需要通过中间结果拼接。解决方案使用基于时间戳的增量拼接不依赖finish_reason来判断追加还是替换。5.3 WebSocket 连接断开导致识别中断现象用户说话过程中识别突然停止界面卡住。排查思路检查 WebSocket 连接是否稳定。网络波动可能导致连接断开。检查后端是否有心跳机制。如果没有心跳连接可能被中间设备断开。检查 ASR 服务的超时配置。如果音频数据发送间隔太长ASR 服务可能主动断开连接。解决方案在前端和后端都加上心跳机制每隔 10 秒发送一个 ping 消息。同时在后端加上重连逻辑连接断开后自动重连 ASR 服务。5.4 常见问题速查表问题现象可能原因排查方法解决方案开头丢字音频采集延迟检查第一次回调时间丢弃前几个回调中间丢字拼接逻辑错误打印每次返回的文本改用时间戳拼接结尾丢字超时设置太短检查超时时间延长超时或手动触发结束识别结果重复追加逻辑错误检查是否重复追加用替换而非追加连接断开网络波动检查 WebSocket 状态加心跳和重连识别延迟高分片太大检查 bufferSize减小分片大小5.5 实操心得三个容易被忽略的细节第一个细节WAV 头部的字节序。WAV 格式是小端字节序如果打包时用了大端ASR 服务解析出来的采样率会是错的导致识别结果乱码或者完全识别不出来。Python 的struct.pack默认是小端但如果你手动拼接字节一定要注意顺序。第二个细节音频数据的静音检测。用户按下麦克风后可能不会立刻说话如果直接把静音数据发给 ASR不仅浪费资源还可能导致 ASR 返回空结果。我在前端加了一个简单的能量检测如果音频的 RMS 值低于阈值就不发送数据。function calculateRMS(audioData) { let sum 0; for (let i 0; i audioData.length; i) { sum audioData[i] * audioData[i]; } return Math.sqrt(sum / audioData.length); } const rmsThreshold 0.01; if (calculateRMS(audioData) rmsThreshold) { webSocket.send(audioData); }第三个细节finish_reason 的时序问题。有些 ASR 服务会在返回finish_reasonstop之后继续返回几个finish_reasonnull的结果。如果代码里收到stop就立刻停止处理后面的结果就会被丢弃。正确的做法是收到stop后再等待一小段时间比如 500ms确认没有新的结果返回后再结束。6. 音频格式转换的补充经验6.1 ncm 转 WAV 的批量处理在测试 ASR 效果时我收集了一些音频素材其中有一部分是 ncm 格式的。ncm 是某些音乐平台特有的加密格式需要先转换成 WAV 或 MP3 才能用于测试。转换工具我用的是ncmdump命令行操作很简单ncmdump -d input.ncm -o output.wav批量转换的话写一个简单的 shell 脚本for file in *.ncm; do ncmdump -d $file -o ${file%.ncm}.wav done转换后的 WAV 文件默认是 44.1kHz 立体声如果直接喂给 ASR需要先降采样到 16kHz 单声道。我用ffmpeg做这一步ffmpeg -i input.wav -ar 16000 -ac 1 -sample_fmt s16 output_16k.wav这几个参数的含义-ar 16000设置采样率为 16kHz-ac 1设置单声道-sample_fmt s16设置采样格式为 16bit 有符号整数。6.2 macOS 上音频格式转换的工具选择在 macOS 上做音频格式转换有几个选择ffmpeg功能最全支持几乎所有格式命令行操作适合批量处理。sox老牌音频处理工具功能也很强但语法比 ffmpeg 稍微复杂一点。afconvertmacOS 自带的音频转换工具不需要额外安装但支持的格式有限。我平时用 ffmpeg 比较多因为它的文档齐全参数直观。安装的话用 Homebrewbrew install ffmpeg如果你只是偶尔转换一下格式用afconvert就够了afconvert -f WAVE -d LEI1616000 -c 1 input.wav output.wav这个命令的意思是输出格式为 WAVE数据格式为小端 16bit 整数、16kHz 采样率单声道。6.3 音频质量对 ASR 识别率的影响测试下来音频质量对 ASR 识别率的影响非常大。同样是“今天天气怎么样”这句话用不同的音频参数识别结果差异明显采样率位深声道识别结果备注48kHz16bit立体声今天天气怎么样正确16kHz16bit单声道今天天气怎么样正确8kHz16bit单声道今天天怎么样丢了“气”字16kHz8bit单声道今天天气怎模样“怎么样”识别错44.1kHz16bit立体声今天天气怎么样正确从测试结果看16kHz、16bit、单声道是性价比最高的配置。8kHz 虽然数据量小但识别率明显下降。8bit 位深也不够会导致识别错误。44.1kHz 和 48kHz 的识别效果和 16kHz 差不多但数据量大三倍没必要。7. 流式 ASR 的性能优化7.1 减少 WebSocket 发送频率最初我的实现是每收到一个音频回调就发送一次 WebSocket 消息回调的 bufferSize 是 800对应 16kHz 下 50ms 的音频。这样每秒要发送 20 次 WebSocket 消息网络开销很大而且后端处理压力也大。后来我把 bufferSize 改成 1600每 100ms 发送一次发送频率降低了一半。再后来我在前端加了一个缓冲区把 3 个 100ms 的音频片段合并成一个 300ms 的片段再发送发送频率降到了每秒 3 次左右。这样既保证了实时性300ms 的延迟用户基本感知不到又大大减少了网络开销。let audioBuffer []; function onAudioData(data) { audioBuffer.push(data); if (audioBuffer.length 3) { const merged mergeBuffers(audioBuffer); webSocket.send(merged); audioBuffer []; } }7.2 后端并发处理与连接池ChatFly 的后端需要同时处理多个用户的 WebSocket 连接每个连接对应一个 ASR 会话。如果每个会话都新建一个到 ASR 服务的连接连接数会很快耗尽。我的做法是用连接池维护一组到 ASR 服务的 WebSocket 连接每个用户会话从池里借一个连接用完还回去。连接池的大小根据并发用户数动态调整初始大小为 10最大为 100。class ASRConnectionPool: def __init__(self, max_size100): self.pool asyncio.Queue(maxsizemax_size) self.max_size max_size async def acquire(self): try: return self.pool.get_nowait() except asyncio.QueueEmpty: if self.pool.qsize() self.max_size: conn await self._create_connection() return conn return await self.pool.get() async def release(self, conn): await self.pool.put(conn)这个连接池的实现比较简单实际生产环境还需要加上健康检查、超时回收等机制。7.3 前端音频数据的压缩16kHz、16bit、单声道的 PCM 数据每秒的数据量是 16000 * 2 32000 字节也就是 32KB/s。如果用户说 10 秒就是 320KB。这个数据量对于 WebSocket 来说不算大但如果网络条件不好还是会有延迟。我试过用 Opus 编码压缩音频压缩后每秒只有 4KB 左右是原始数据的八分之一。但 Opus 编码需要额外的编解码开销而且 ASR 服务不一定支持 Opus 格式。最终我选择了不压缩直接用 PCM因为 32KB/s 的带宽需求在现代网络环境下完全可以接受。8. 从 ASR 问题延伸出的架构思考8.1 状态同步的重要性这次“吃字”问题的根因表面上是拼接逻辑写错了但更深层的原因是前后端状态不同步。前端认为“这句话还没说完”后端可能已经收到了finish_reasonstop把句子固化了。两边对“当前句子”的理解不一致就会导致展示的文本和实际识别的文本不一致。解决这个问题的关键是让状态变更有一个明确的源头。在我的实现里finish_reason是状态变更的唯一依据前端和后端都根据这个字段来切换状态。前端不自己判断句子是否结束而是等后端推送finish_reasonstop的消息。8.2 容错设计当 ASR 服务不可用时ASR 服务不是 100% 可用的网络波动、服务重启、限流等都可能导致识别失败。如果 ASR 服务挂了ChatFly 的语音输入功能就完全不可用了。我的容错设计是当 ASR 服务连续失败 3 次后自动降级到“录音后整段上传”的模式。虽然体验差一点但至少功能可用。同时在前端给出提示“语音识别服务暂时不可用已切换到普通模式”。class ASRFallback: def __init__(self): self.failure_count 0 self.max_failures 3 self.mode streaming async def recognize(self, audio_data): if self.mode streaming: try: result await self.streaming_recognize(audio_data) self.failure_count 0 return result except Exception: self.failure_count 1 if self.failure_count self.max_failures: self.mode batch raise else: return await self.batch_recognize(audio_data)8.3 日志与监控如何快速定位 ASR 问题ASR 相关的问题往往很难复现因为涉及到音频数据、网络传输、服务端处理等多个环节。如果没有详细的日志排查起来非常困难。我在关键节点都加了日志前端每次发送音频数据时记录数据长度和时间戳。后端每次收到音频数据时记录数据长度和来源。后端每次收到 ASR 结果时记录text、finish_reason、start_time、end_time。前端每次更新界面时记录当前展示的文本和状态。这些日志用 JSON 格式输出方便后续用工具分析。比如如果发现某次识别的finish_reason一直是null从来没有变成stop那就说明 ASR 服务可能有问题或者音频数据没有正确发送。9. 写在最后这次“ASR 吃字”的问题从发现到彻底解决前后花了大概一周时间。中间试过很多方案有的治标不治本有的引入了新的问题。最后用时间戳做增量拼接的方案是目前最稳定的。如果你也在做类似的事情我的建议是不要依赖 finish_reason 来做拼接逻辑。finish_reason只用来判断句子是否结束拼接应该基于时间戳或者其他更可靠的信息。另外音频格式的转换一定要仔细WAV 头部的字节序、数据长度字段、采样率参数任何一个写错都可能导致识别结果异常。还有一个我个人的习惯在开发 ASR 相关功能时我会先把音频数据保存到本地用工具比如 Audacity打开听一下确认音频本身没有问题。很多时候问题不是出在 ASR 服务上而是出在音频采集或转换环节。把音频数据保存下来可以快速排除这一部分的嫌疑。这个项目后续还可以扩展的方向包括支持多语言识别、加入热词表提升特定词汇的识别率、以及做端上的降噪处理。不过这些都是后话了先把基础链路做稳定再说。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑