OpenHarmony WS63 设备把一段语音送到服务端时上传的不是 WAV 文件也不是可以直接交给识别模型的浮点数组而是一串有先后关系的 Opus packet。服务端必须先保留同一个解码器状态把 packet 还原为 PCM16再做数值归一化最后把样本交给 sherpa-onnx 的中文 Zipformer CTC 模型。任何一层把采样率、声道数、帧边界或生命周期理解错最终都可能只得到空文本或解码错误。小鸿 AI 当前服务端把这条链路集中在asr_service.py由server.py在收到listen/stop后调用。它是 OpenHarmony mini/LiteOS-M 设备语音链路的服务端一半WS63 与 CI1302 负责采集、VAD 和 Opus 上行Python 服务负责解码与中文识别。本篇只讨论当前真实源码已经存在的实现和边界不把源码阅读或替身测试写成一次新的实机识别结论。ASR 入口不是每来一帧就识别一次服务端的Session会先累积本轮audio_packets。设备发送的 binary frame 经过协议头剥离后进入这个列表直到 VAD 段结束对应的listen/stop到达才在线程中执行recognize_session_audio()。因此当前实现的基本单位是一整段 utterance而不是持续返回 partial result 的实时字幕。入口先检查 hello 中声明的格式是否为opus然后把 packet 列表、采样率和声道数传给 ASR 封装def recognize_session_audio(session: Session) - str: if session.audio_format.lower() ! opus: raise asr_service.AudioDecodeError( funsupported uplink audio format: {session.audio_format} ) return asr_service.transcribe_opus_packets( session.audio_packets, sample_ratesession.sample_rate, channelssession.channels, )这段代码也说明了一条边界名字叫StreamingChineseASR底层也使用 sherpa-onnx 的 OnlineRecognizer但服务端没有边收包边输出识别文字。当前流程仍然是先收齐一轮 packet再一次性送入 recognizer。把“使用在线识别器 API”直接写成“已经实现流式增量字幕”会夸大源码能力。libopus 必须沿着一段语音复用解码状态Opus packet 不是彼此完全无关的文件片段。当前OpusPacketDecoder在构造时只创建一次 decoder随后顺序解码整个 packet iterable结束后再销毁。这样可以保留一段语音内部的编解码状态也避免为每个 40 ms packet 反复创建 native 对象。库加载没有把系统文件名写死为唯一值而是先问ctypes.util.find_library(opus)再尝试 Linux 常见 sonamestaticmethod def _load_library() - ctypes.CDLL: candidates [ctypes.util.find_library(opus), libopus.so.0, libopus.so] for candidate in candidates: if not candidate: continue try: return ctypes.CDLL(candidate) except OSError: continue raise ASRUnavailableError(libopus is not installed)requirements-asr.txt只固定了numpy和sherpa-onnx没有把 libopus 当成 Python wheel 安装。项目 README 对应要求系统侧安装libopus0。因此部署检查要同时覆盖 Python 虚拟环境与操作系统动态库只看到pip install成功不能证明 Opus 解码路径可用。采样率和声道校验只是第一道门构造器接受 Opus 规范支持的 8000、12000、16000、24000、48000 Hz以及 1 或 2 声道不在集合内会抛出AudioDecodeError。但“构造器允许”不等于“项目已验证”。当前 WS63 hello、服务端 hello、模型配置与冒烟样本共同指向 16000 Hz、单声道、40 ms这才是项目已经形成一致契约的组合。服务端 hello 解析会采用设备传来的sample_rate和channels而 sherpa recognizer 创建时明确配置为 16000 Hz。双声道 PCM 也没有额外的下混处理。因此对当前项目最准确的表述是libopus 封装具有更宽的参数检查范围端到端已设计并使用的是 16 kHz 单声道其他组合不能仅凭这两个集合就宣称已经支持。每次调用opus_decode()时缓冲区按 Opus 允许的最长 120 ms 准备而实际设备 packet 是 40 ms。返回值是“每声道 sample 数”所以复制字节数还要乘声道数和每个 int16 的 2 字节for packet in packets: if not packet: continue encoded (ctypes.c_ubyte * len(packet)).from_buffer_copy(packet) sample_count self._lib.opus_decode( self._decoder, encoded, len(packet), pcm, max_samples, 0, ) if sample_count 0: raise AudioDecodeError(self._error_text(sample_count)) decoded.extend(ctypes.string_at(pcm, sample_count * self.channels * 2))空 packet 会被跳过如果整轮没有产生 PCM则抛出no decodable Opus audio。当前实现没有用空 packet 主动触发 packet-loss concealment也没有在服务端重排 packet。WebSocket 本身提供有序传输但网络断开或客户端漏发的处理仍需要通过会话日志和端到端测试确认。PCM16 进入模型前要转换到浮点幅值libopus 通过ctypes.c_int16输出主机原生字节序的 PCM16当前部署平台通常是小端但源码没有显式把字节序固定为 little-endian。transcribe_pcm16()用 NumPy 的原生int16从同一 buffer 建立视图转换为 float32 后除以 32768使样本进入大致[-1, 1)的范围。这里没有另外做降噪、自动增益、重采样或声道混合输入质量仍取决于设备侧采集与 CI1302 输出。samples np.frombuffer(pcm16, dtypenp.int16).astype(np.float32) samples / 32768.0 recognizer self._load() with self._decode_lock: stream recognizer.create_stream() stream.accept_waveform(sample_rate, samples)np.frombuffer()不会凭空修复奇数长度或错误字节序幸运的是 PCM 来自 libopus 自己填充的c_int16数组字节数由sample_count * channels * 2计算。真正值得监控的是 PCM 时长、峰值、静音比例与识别耗时只有最终文本长度的日志仍不足以区分“用户没说话”“音频幅值太低”和“模型解码失败”。sherpa-onnx 模型采用懒加载而不是启动即加载StreamingChineseASR初始化时不立即导入 sherpa-onnx也不立即创建 recognizer。第一次真实识别才进入_load()先检查 ASR 开关和模型文件再导入模块并构造 OnlineRecognizer。这样服务进程可以先启动 HTTP 与 WebSocket但也意味着健康检查看到模型文件存在不等于第一次模型加载一定成功。当前模型配置来自实际代码self._recognizer sherpa_onnx.OnlineRecognizer.from_zipformer2_ctc( modelstr(ASR_MODEL_FILE), tokensstr(ASR_TOKENS_FILE), num_threadsASR_NUM_THREADS, sample_rate16000, feature_dim80, enable_endpoint_detectionFalse, decoding_methodgreedy_search, providercpu, )模型目录默认指向sherpa-onnx-streaming-zipformer-small-ctc-zh-int8-2025-04-01必须至少包含model.int8.onnx与tokens.txt。ASR_NUM_THREADS默认 2且被限制为不小于 1。当前 provider 是 CPU解码方法是 greedy searchendpoint detection 被关闭分段终点由设备 VAD 与listen/stop决定而不是识别器在服务端自行切句。补半秒静音是当前 utterance 收尾策略把整段波形送入 stream 后代码还追加 0.5 秒零值样本然后调用input_finished()循环decode_stream()直到 recognizer 不再 ready最后读取结果。这个处理帮助 CTC 解码器消化尾部但它不是设备真实录到的 0.5 秒环境声。stream.accept_waveform( sample_rate, np.zeros(int(sample_rate * 0.5), dtypenp.float32), ) stream.input_finished() while recognizer.is_ready(stream): recognizer.decode_stream(stream) return recognizer.get_result(stream).strip()因此时延分析要区分两部分用户真实说话的音频时长以及识别收尾时额外喂入的零样本。零样本是计算输入不会让服务端真的等待 500 ms但会增加一定特征与解码工作。若未来改成真正的在线 partial resultendpoint、尾静音和 VAD 的职责需要重新划分不能直接在现有循环旁边多加一个回调就算完成。两把锁分别保护加载和解码服务端使用全局_RECOGNIZER实例。_load_lock防止两个并发请求同时创建模型_decode_lock则让同一个 recognizer 的 create/accept/decode/get_result 区域串行执行。server.py虽然用asyncio.to_thread()避免 ASR 阻塞事件循环但多个设备同时结束说话时最终仍会在 decode lock 前排队。这不是代码错误而是明确的容量选择单模型实例减少内存与重复加载成本串行区段换取线程安全。要评估是否需要 worker pool 或多实例必须记录队列等待时间、纯解码时间、峰值并发和模型内存而不能只看单次本地识别有多快。错误分类决定设备最终听到什么ASR 封装区分ASRUnavailableError与AudioDecodeError。前者覆盖 ASR 被关闭、模型文件缺失、sherpa-onnx 或 libopus 不可用后者覆盖非法参数、decoder 创建失败、packet 解码失败与无有效 PCM。server.py对这两类错误返回不同中文提示并为其他异常保留第三条兜底。识别成功但结果为空也不是异常它会返回“没有听清问题”。识别成功且有文本才调用answer_question()。无 binary frame 的 stop 则走唤醒文本或预置问题分支。这些分支解释了为什么设备能正常播放一句回答并不必然证明那句话来自真实语音识别。封装的最后两步非常短却明确了组件边界packet_list list(packets) with OpusPacketDecoder(sample_ratesample_rate, channelschannels) as decoder: pcm16 decoder.decode(packet_list) return _RECOGNIZER.transcribe_pcm16(pcm16, sample_ratesample_rate)先物化 packet iterable 可以保证同一轮只消费一次context manager 保证正常和异常路径都销毁 decoder。若 native decoder 在构造中途失败_decoder尚不存在时close()也通过getattr()安全处理。项目里有三种测试证明范围并不相同protocol_smoke.py用 fake recognizer、fake LLM 和 fake TTS 验证 hello、packet 收集、stop 顺序与 ASR→LLM→TTS 编排。本轮重新运行通过输出包含asr_to_llmok和 4 个下行 Opus 包。但 recognizer 被替换所以这不是 libopus 或中文模型的真实测试。asr_model_smoke.py会读取模型目录中的 WAV用系统 libopus 编成 40 ms packet再调用transcribe_opus_packets()。它覆盖“WAV→Opus→PCM→模型→文本”需要 Linux 动态库、NumPy、sherpa-onnx 和模型文件齐全。本轮没有在当前工作机执行该模型冒烟。live_asr_roundtrip.py则会连接真实 WebSocket发送项目保存的 packet 文件等待 TTS start、sentence_start、binary audio 和 stop。它还会经过 LLM 与 TTS 外部依赖。本轮没有连接生产服务也没有据此生成新的线上通过记录。health 的 model_ready 只检查文件存在当前status()返回 enabled、固定 backend 名称以及两个文件是否存在def status() - dict: return { enabled: ASR_ENABLED, backend: sherpa-onnx-zipformer-small-ctc, model_ready: ASR_MODEL_FILE.is_file() and ASR_TOKENS_FILE.is_file(), }它没有主动加载 ONNX、跑一段音频或验证 libopus。因此enabledtrue且model_readytrue只适合当作配置与文件探针不能替代一次真实识别。更严格的就绪探针可以在部署阶段运行模型 smoke把结果写进发布证据常规 health 则保持轻量避免每次探活都消耗模型推理资源。本轮能够确认什么仍然不能确认什么本轮逐行核对了asr_service.py、server.py、asr_model_smoke.py、live_asr_roundtrip.py、protocol_smoke.py、依赖文件和 README并用逐文件 SHA-256 固定证据版本。确定性协议冒烟重新通过能确认 stop 后调用链、错误分支接口和下行帧编排没有在当前源码中断裂。本轮没有在 Linux 服务器加载实际 Zipformer INT8 模型没有用真实普通话录音测字错率也没有重新连接 WS63 实机验证噪声、远场、口音、丢包和并发。文章中的六张图应是依据这些源码绘制的数据流与边界图不是实机截图。后续若要把“模型可用”提升为“设备体验可用”至少还需要固定语料集、识别准确率、首字/整句时延、并发排队时间以及一条从 CI1302 到设备播报的可回放端到端记录。