背景
三个 L0 内核(渲染/语音/视觉)已到「能用」,但接线侧暴露出四处缺口——都不在领域层,而在契约与工程纪律:
- 视觉事件只出裸文本:
rapidocr-core的OcrOutput.lines本就按阅读顺序排、且带bbox/score,内核把它们丢掉了。信息不是“没有“,是“没传“; - OCR 单帧稳态 340–380ms(推理固定开销主导),消费侧却在 UI 主线程同步调用,等于把一次推理塞进帧预算;
- TTS 只有 edge-tts(联网 + 本地 venv)一条路径,合成失败只
return:断网即哑、且不可观测; - 同时链入 speech(sherpa-onnx 静态自带 ONNX Runtime 1.28.2)与 vision/piper(ort)时,链接命中 sherpa 那一版,而
ort默认api-31要求运行时 1.31 →GetApi(31)取空 →Failed to initialize ORT APIpanic,OCR 与离线 TTS 一起失效(tw-render release 二进制中strings只见 1.28 那份,已实测确认)。
ADR-0011 的注记只写到“补三条纪律“,随着 tw-render 真机接线完成,这四条需要独立成文,避免被下一轮实现者当成可选建议。
选项
- A. 内核只给文本,消费侧各自适配。 改动最小,但几何、异步、离线兜底会在每个消费方重复实现一遍;ORT 冲突更是无解(消费者改不动上游 feature)。
- B. 在 L0 契约与消费纪律里补齐(选定)。 事件带几何;异步作为消费侧硬纪律;TTS 三级兜底;用
ortapi 上限把「单进程单版本 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 播完」全链。
拍板
- 视觉事件带几何。
VisionEvent::Text { text, points: [[f32; 2]; 4], score },坐标为相对送入图像的四角点 + 置信度。正文仍留内核环、时间线仍只存seq/短码(不改 ADR-0011 拍板一)。理由:几何是“看到什么“的一部分,事后补字段等于再断一次契约。消费者若做过缩放,需按同比例还原到原图坐标。 - OCR 不得在 UI 主线程执行。 消费侧固定「后台线程 + channel 回传 + 主线程逐帧 poll」:贴图即返回,识别完再上事实轨。OCR 并发度是 1(内核全局单例模型 + 互斥锁),上一张未回时重复请求直接提示、不排队叠加;发送端断开(含线程 panic)必须解除“识别中“状态,不许永久卡住。
- TTS 三级兜底。
edge-tts→ 离线 Piper → macOSsay。Piper 进程内单例、懒加载(TIDEWATCH_PIPER_DIR→{HOME}/Library/Application Support/tidewatch/piper-plus),加载失败的结果缓存、不逐句重试;非阻塞路径的装载/合成整段丢后台线程,阻塞路径只供语音闭环(念完才发“再听“信号)。 - 单进程单版本 ONNX Runtime。
ort的 api 特性上限必须 ≤ 链路中自带 ONNX Runtime 的最小版本(现 sherpa-onnx 2.0.1 自带 1.28.2 → 钉api-24;piper 亦只需 24)。新增任何 ONNX 消费者时先核对这条;违反时的表现是“某引擎莫名 panic“,极难定位。 - 不做:不把 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(CIinvariantsjob); - 真机冒烟(均
#[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。