流式ASR语音识别“吃字”问题排查与时间戳增量拼接方案
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_rate=16000, channels=1, bits_per_sample=16): byte_rate = sample_rate * channels * bits_per_sample // 8 block_align = channels * bits_per_sample // 8 header = struct.pack( '<4sI4s4sIHHIIHH4sI', b'RIFF', 36 + pcm_data_length, b'WAVE', b'fmt ', 16, 1, # PCM format channels, sample_rate, byte_rate, block_align, bits_per_sample, b'data', 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(key=lambda 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 | 用户按下麦克风 | RECORDING |
| RECORDING | 用户松开麦克风 | PROCESSING |
| PROCESSING | 收到 finish_reason=stop | DONE |
| PROCESSING | 超时(5秒) | DONE |
| DONE | 用户再次按下麦克风 | RECORDING |
超时处理很重要。有时候 ASR 服务因为网络问题没有返回finish_reason=stop,如果不加超时,前端会一直卡在PROCESSING状态。我设置的是 5 秒超时,超时后直接把当前缓冲区的内容作为最终结果展示。
5. 常见问题与排查技巧实录
5.1 识别结果开头丢字
现象:用户说话后,识别结果的前几个字经常丢失。
排查思路:
- 检查音频采集的起始时间。
AVAudioEngine启动后,第一次回调可能会有延迟,导致开头一小段音频没有被采集到。 - 检查 WAV 头部是否正确。如果头部里的数据长度字段写错了,ASR 服务可能会跳过开头的数据。
- 检查 ASR 服务的
start_time配置。有些 ASR 服务默认从音频的 0.5 秒开始识别,前面的内容会被忽略。
解决方案:在AVAudioEngine启动后,先丢弃前几个回调的音频数据,等引擎稳定后再开始发送。同时确认 WAV 头部的数据长度字段与实际数据长度一致。
5.2 中间结果与最终结果不一致
现象:中间结果显示“今天天气不错”,最终结果却变成“天气不错”,少了“今天”。
排查思路:
- 检查拼接逻辑。如果最终结果直接覆盖了中间结果,而最终结果又不包含完整内容,就会丢字。
- 检查 ASR 服务的返回格式。有些服务在
finish_reason=stop时只返回最后一个片段,前面的内容需要通过中间结果拼接。
解决方案:使用基于时间戳的增量拼接,不依赖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_reason=stop之后,继续返回几个finish_reason=null的结果。如果代码里收到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 稍微复杂一点。
- afconvert:macOS 自带的音频转换工具,不需要额外安装,但支持的格式有限。
我平时用 ffmpeg 比较多,因为它的文档齐全,参数直观。安装的话用 Homebrew:
brew install ffmpeg如果你只是偶尔转换一下格式,用afconvert就够了:
afconvert -f WAVE -d LEI16@16000 -c 1 input.wav output.wav这个命令的意思是:输出格式为 WAVE,数据格式为小端 16bit 整数、16kHz 采样率,单声道。
6.3 音频质量对 ASR 识别率的影响
测试下来,音频质量对 ASR 识别率的影响非常大。同样是“今天天气怎么样”这句话,用不同的音频参数识别,结果差异明显:
| 采样率 | 位深 | 声道 | 识别结果 | 备注 |
|---|---|---|---|---|
| 48kHz | 16bit | 立体声 | 今天天气怎么样 | 正确 |
| 16kHz | 16bit | 单声道 | 今天天气怎么样 | 正确 |
| 8kHz | 16bit | 单声道 | 今天天怎么样 | 丢了“气”字 |
| 16kHz | 8bit | 单声道 | 今天天气怎模样 | “怎么样”识别错 |
| 44.1kHz | 16bit | 立体声 | 今天天气怎么样 | 正确 |
从测试结果看,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_size=100): self.pool = asyncio.Queue(maxsize=max_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_reason=stop,把句子固化了。两边对“当前句子”的理解不一致,就会导致展示的文本和实际识别的文本不一致。
解决这个问题的关键是:让状态变更有一个明确的源头。在我的实现里,finish_reason是状态变更的唯一依据,前端和后端都根据这个字段来切换状态。前端不自己判断句子是否结束,而是等后端推送finish_reason=stop的消息。
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 服务上,而是出在音频采集或转换环节。把音频数据保存下来,可以快速排除这一部分的嫌疑。
这个项目后续还可以扩展的方向包括:支持多语言识别、加入热词表提升特定词汇的识别率、以及做端上的降噪处理。不过这些都是后话了,先把基础链路做稳定再说。