zjchina.net

Drogon · ROS 2 · 独立开发笔记

ADR-00122026-11-07

L0 内核接线纪律:事件带几何、OCR 异步、TTS 三级兜底、单版本 ORT

拍板:zj

背景

三个 L0 内核(渲染/语音/视觉)已到「能用」,但接线侧暴露出四处缺口——都不在领域层,而在契约与工程纪律:

  1. 视觉事件只出裸文本:rapidocr-core 的 OcrOutput.lines 本就按阅读顺序排、且带 bbox/score,内核把它们丢掉了。信息不是“没有“,是“没传“;
  2. OCR 单帧稳态 340–380ms(推理固定开销主导),消费侧却在 UI 主线程同步调用,等于把一次推理塞进帧预算;
  3. TTS 只有 edge-tts(联网 + 本地 venv)一条路径,合成失败只 return:断网即哑、且不可观测;
  4. 同时链入 speech(sherpa-onnx 静态自带 ONNX Runtime 1.28.2)与 vision/piper(ort)时,链接命中 sherpa 那一版,而 ort 默认 api-31 要求运行时 1.31 → GetApi(31) 取空 → Failed to initialize ORT API panic,OCR 与离线 TTS 一起失效(tw-render release 二进制中 strings 只见 1.28 那份,已实测确认)。

ADR-0011 的注记只写到“补三条纪律“,随着 tw-render 真机接线完成,这四条需要独立成文,避免被下一轮实现者当成可选建议。

选项

  • A. 内核只给文本,消费侧各自适配。 改动最小,但几何、异步、离线兜底会在每个消费方重复实现一遍;ORT 冲突更是无解(消费者改不动上游 feature)。
  • B. 在 L0 契约与消费纪律里补齐(选定)。 事件带几何;异步作为消费侧硬纪律;TTS 三级兜底;用 ort api 上限把「单进程单版本 ORT」变成可验证条文。
  • C. 把 OCR/TTS 拆成独立进程(sidecar)。 能根除 ORT 冲突,也能隔离崩溃;但多进程生命周期、音频设备争用、模型热加载都要重做,当前收益不足,保留为将来手段。

证据

  • 耗时:release、M 系列 CPU、tw-render 消费侧实测——800×200 稳态 340–380ms;2400×600 与 800×200 相差不到 20%,说明固定推理开销主导,缩图(max_edge)不是性能主手段。
  • 契约:OcrOutput.lines 已按阅读顺序排序并带框与分数,升级事件形状不需要改 OCR 引擎,只改内核投影。
  • 链接:jchina-vision 单独 workspace 时 OCR 正常(ort 下载 1.31);tw-render 里只有 sherpa 的 1.28.2 被链入 → 初始化 panic。把两侧 ort 降到 api-24 后,同一二进制内 OCR 与离线 Piper 两项真机冒烟均通过。
  • 断网:edge-tts 依赖网络与本地 venv,失败路径原先既不换后端也不留痕;离线 Piper 已在本机模型上跑通「合成 → afplay 播完」全链。

拍板

  1. 视觉事件带几何。 VisionEvent::Text { text, points: [[f32; 2]; 4], score },坐标为相对送入图像的四角点 + 置信度。正文仍留内核环、时间线仍只存 seq/短码(不改 ADR-0011 拍板一)。理由:几何是“看到什么“的一部分,事后补字段等于再断一次契约。消费者若做过缩放,需按同比例还原到原图坐标。
  2. OCR 不得在 UI 主线程执行。 消费侧固定「后台线程 + channel 回传 + 主线程逐帧 poll」:贴图即返回,识别完再上事实轨。OCR 并发度是 1(内核全局单例模型 + 互斥锁),上一张未回时重复请求直接提示、不排队叠加;发送端断开(含线程 panic)必须解除“识别中“状态,不许永久卡住。
  3. TTS 三级兜底。 edge-tts → 离线 Piper → macOS say。Piper 进程内单例、懒加载(TIDEWATCH_PIPER_DIR → {HOME}/Library/Application Support/tidewatch/piper-plus),加载失败的结果缓存、不逐句重试;非阻塞路径的装载/合成整段丢后台线程,阻塞路径只供语音闭环(念完才发“再听“信号)。
  4. 单进程单版本 ONNX Runtime。 ort 的 api 特性上限必须 ≤ 链路中自带 ONNX Runtime 的最小版本(现 sherpa-onnx 2.0.1 自带 1.28.2 → 钉 api-24;piper 亦只需 24)。新增任何 ONNX 消费者时先核对这条;违反时的表现是“某引擎莫名 panic“,极难定位。
  5. 不做:不把 OCR/TTS 拆进程(选项 C 留作将来手段);不为几何新增时间线字段、不把框坐标写进 note;不为降级加 feature flag。

落地与验证

  • 契约:zjchina-vision/src/lib.rs(VisionEvent / FrameGeometry / ObservedFrame)、 zjchina-vision/src/screen.rs(投影);
  • 消费状态机:zjchina-vision/src/worker.rs(OcrWorker:单飞 + 非阻塞轮询 + 线程异常兜底), 消费方只提交与轮询,不再各写一遍;
  • 消费:tw-render/src/vision.rs(ocr_frame/VlmAnalyzer)、tw-render/src/main.rs(poll_ocr);
  • TTS 兜底链:zjchina-speech/src/chain.rs(FallbackSynthesizer:edge-tts → Piper → say, 内核不读 TIDEWATCH_*,只认路径与模型目录约定);tw-render/src/agentdock.rs 只留部署定位 (edge_tts_bin/piper_model_dir)与 speak_say/speak_say_wait 两个薄包装;
  • 视觉级联:zjchina-agent/src/vision.rs(VlmCascade:prompt 组装 / OCR 按字符截断 / 图数守卫), tw-render/src/vision.rs 的 VlmAnalyzer 只保留域词(财报/公告/行情);
  • 依赖:zjchina-vision/Cargo.toml 与 vendor/rapidocr-core/Cargo.toml 的 ort 特性(api-24), 守卫 scripts/check-ort-single-version.py(CI invariants job);
  • 真机冒烟(均 #[ignore],需本地模型):zjchina-vision 的 observe_ocr_returns_text、 tw-render 的 ocr_frame_carries_geometry_with_local_model 与 offline_piper_speaks_with_local_model。

2026-11-07 实现注记:拍板一的「消费者按同比例还原」不再靠消费方自己算——内核直接交代缩图 比例。Vision::observe 改为返回 ObservedFrame { obs_id, geometry, events },points 固定在 geometry.scaled 空间,还原走 geometry.to_source();同一批把拍板二的状态机收进 OcrWorker, 把「单飞/不阻塞/线程 panic 不卡死」从每个消费方各写一遍变成内核保证一次。

2026-11-07 闭环批注记:拍板三的三级兜底从 tw-render 收进 zjchina-speech::chain——链序与 「全失败可观测」由内核保证(speak 非阻塞只记 last_error,speak_blocking 返回 Err); 链内一律以 wait=true 调后端,否则 Piper 的「已入队」会被误记成功而永远落不到 say。 拍板四的 VLM 侧同批收口:prompt 组装、OCR 按字符截断(默认 4000,不切坏中文)、图数守卫(≤2) 只在 zjchina-agent::VlmCascade 一处,消费方不重复实现。 验证:zjchina-speech 18 项(含链序/空链/模型挑选/配置定位)、zjchina-agent 27 项(含 prompt 组装/截断/守卫)、tw-render 127 项全绿。

2026-11-07 全双工批注记(语音 C 阶段:流式 + 全双工 + 打断):Partial 流式在 B 阶段已就绪, 本批补「播放期不关麦、可叫停」两件事,并把回声处理如实降级为 AEC-lite。

  • 可叫停:zjchina-speech/src/control.rs(SpeechControl:playing/interrupted 两位 + 停播钩子)。消费侧持克隆调 interrupt(),不锁合成器——阻塞朗读期间那把锁一直被合成 线程握着,主线程去 lock 会把主循环一起卡住。停播落到子进程:afplay / say / edge-tts 一律经 track_child 托管 + try_wait 轮询,打断时 kill;打断后链内不兜底(run_tiers 见到 interrupted 直接返回成功,否则刚叫停的话会由下一级再念一遍)。
  • AEC-lite(zjchina-speech/src/duplex.rs):外部播放器拿不到播放参考信号,真 AEC 在本架构 下做不了(留作将来手段:改成进程内播放才有可对齐的参考)。代之以两路廉价证据—— 播放期采集 RMS 连续超阈(默认 0.03 / 240ms)+ 识别文本与播报不重叠;并在定稿上再过 looks_like_echo:回音被 endpoint 定稿必须丢弃后重听,否则会自问自答。
  • 消费侧(tw-render/src/agentdock.rs):SpokenEvent::Start 即开麦(播放与采集并行), Done 收尾;Partial 先回音过滤再打断判定,Final 走「回音丢弃 / 停播转听 / 提交」; barge_epoch 让还在路上的回复只显示文字、不朗读。
  • 采集电平:SherpaRecognizer::level()(回调内平滑 RMS),Recognizer::level 默认 0 (不支持电平的实现退化为只看文本,不会因此误停播)。

验证:zjchina-speech 26 项(新增 control 3 / duplex 4 / 链打断 2 / 采集电平 1)、 zjchina-agent 27 项、tw-render 130 项(新增回音、打断纪元、能量判定 3 项)。

日期:2026-11-07(承接 ADR-0011 的 2026-11-06 实现注记,升为独立条目)。拍板者 zj。

← 返回决策日志