AI桌面陪伴助手:方案更新至 v2.4,代码剥离到独立仓库

代码剥离
- 助手本体、TTS 服务、P0 基准脚本、ESP32 固件移到 ~/workspace/ai-companion
  (独立 git 仓库),本目录只留方案、P0 报告与硬件记录
- 因此删除 esp32_smile_test.ino / esp32_touch_eyes.ino
- 同时移除已失效的 .gitignore(原本只为挡住模型体积,代码已搬走)

方案 v2.4 重大变更:架构从"单机"改为"服务器即助手、本地为客户端"
- 起因:实测 N5095 无 AVX2,跑不动可用的语音模型
- N5095 降为客户端/外设桥,只做 USB 接入、DoA、WebRTC 客户端
- 实施阶段表加进度列(P0/P1/P3/P4a 部分完成),新增三条验收要求
- 采购清单按实际到货更新,标出屏幕形态待确认(2.8寸 vs 原定1.28寸圆屏)

技术栈(实测更新)
- LLM:DeepSeek deepseek-flash(云端,必须关思考);本机 27B 已停用
- TTS:Qwen3-TTS 1.7B(服务器 3090);vits-zh-ll 因音色不满意被替换
- STT:Zipformer int8 未变,中文识别率仍是未闭合项

新增两份 P0 报告
- P0-Qwen3TTS报告.md:TTS 换型过程与实测
- P0-DeepSeek验证报告.md:四条路径验证

两份旧报告加了状态说明(结论仍有效但选型已变),
并修正了随代码移走而失效的引用与复现命令。
This commit is contained in:
ylhan
2026-09-15 11:08:56 +08:00
parent 152b919b25
commit bdd6bfb63d
8 changed files with 762 additions and 289 deletions
@@ -1,51 +1,87 @@
# AI 桌面陪伴助手 · 实施规划 v2.3
# AI 桌面陪伴助手 · 实施规划 v2.4
> 更新:2026-09-12。状态:架构方向确认,具体采购及环境待确认;尚未实现或实机验证。
> 已确认:Ubuntu,复用 N5095 / 16G;麦克风、音箱、屏幕、摄像头均未购;LLM先用DeepSeek V4.1 Flash;语音优先本地运行。以本人使用为主,加入基础多人能力并预留身份扩展;预算分阶段投入。
> 📁 **代码已与本文档分离**(2026-09-15)。本目录(Obsidian 笔记库)只保留
> 方案、P0 报告和硬件记录;所有代码在 **`~/workspace/ai-companion/`**
> (独立 git 仓库),包括:助手本体、TTS 服务、P0 基准脚本、ESP32 固件、
> `README.md`(代码说明)、`部署说明.md`(运维)——这两份也在代码仓库。
>
> 更新:2026-09-15。状态:**助手本体已在服务器上实现并跑通**(端到端测试 16/16);设备侧(N5095 外设桥、ESP32)尚未开始。
> 已确认:Ubuntu 24.04;N5095 / 16G 作为**客户端**;助手本体部署在 cadadmin-server(i9-14900K / 3090);麦克风、音箱、摄像头均未购;屏幕已于 09-14 到货并点亮(2.8 寸 ILI9341 触摸屏,非原定的 1.28 寸圆屏)。
> 核心目标:听得清、回应及时、可打断、表情明确、转头自然。
> 最新外形要求:屏幕不用太大,暂沿用1.28英寸圆屏;摄像头水平可用旋转总行程至少180°,按正前方左右各90°理解,不是镜头视场角180°。
> 最新外形要求:屏幕不用太大;摄像头水平可用旋转总行程至少180°,按正前方左右各90°理解,不是镜头视场角180°。
> 原方案备份:AI桌面陪伴助手方案.v1备份.md。本版指标为拟定目标,不是已测性能。
>
> **本版重大变更:架构从"单机"改为"服务器即助手、本地为客户端"。**
> 起因是实测发现 N5095(无 AVX2)跑不动可用的语音模型——详见
> `P0-语音基准报告.md`。变更后 N5095 只做 USB 外设接入与网络桥接。
> 详细部署现状见代码仓库里的 `部署说明.md`,实测数据见本目录的四份 P0 报告。
## 1. 范围与待讨论事项
第一版做好本地中文语音、屏幕表情、按用户隔离的记忆;同一轮开发加入多人脸跟踪、轮流交互和显式身份切换。录入式人脸识别作为可关闭的试验功能,实测达标后用于身份建议;重叠说话分离、可靠声纹认人不作为首版要求。视觉问答和米家后续加入。
第一版做好中文语音、屏幕表情、按用户隔离的记忆;同一轮开发加入多人脸跟踪、轮流交互和显式身份切换。录入式人脸识别作为可关闭的试验功能,实测达标后用于身份建议;重叠说话分离、可靠声纹认人不作为首版要求。视觉问答和米家后续加入。
| 项目 | 状态/建议 | 影响 |
|---|---|---|
| 主机 | 已有 N5095 / 16G,复用,不追加主机/GPU | 当前规划基础 |
| **主机分工** | **服务器(cadadmin-server)跑助手本体;N5095 降为客户端/外设桥** | **v2.4 重大变更,见 §2** |
| 服务器 | i9-14900K / 32线程 / 188G / RTX 3090 24G,局域网延迟 1–2ms | 与 N5095 同局域网,1ms 级 |
| N5095 角色 | USB 麦克风阵列、USB 摄像头、DoA 读取、WebRTC 客户端、串口给 ESP32 | 不跑任何推理 |
| 使用人数 | 本人使用为主,同时考虑多人扩展 | 首版基础多人,身份与会话从一开始分离 |
| 新增预算 | 用户暂未设上限,先看选型;分阶段购买 | 音频优先,其次屏幕,再机械 |
| 操作系统、现有外设 | 已确认Ubuntu;麦克风、音箱、屏幕、摄像头都没有 | 需采购;P0仍需确认Ubuntu版本和USB接口 |
| 新增预算 | 服务器为复用已有机器,不算追加采购 | 音频优先,其次屏幕,再机械 |
| 操作系统、现有外设 | Ubuntu 24.04 已确认;麦克风、音箱、摄像头都没有;屏幕已到货 | 需采购音频与摄像头 |
| 使用距离 | 待确认;暂以桌面约0.5~2米作为测试设计假设 | 不能当作已确认需求或性能保证 |
| 屏幕 | 用户要求不用太大,原型沿用1.28英寸圆屏,显示表情和短状态 | 暂不扩大屏幕 |
| 屏幕 | **已到货:2.8 寸 ILI9341 触摸屏**(与原定 1.28 寸圆屏不符,待确认最终形态) | 见 `ESP32-S3与2.8寸SPI触摸屏接线记录.md` |
| 摄像头旋转 | 水平可用总行程至少180°,正前方左右各90° | 优先屏幕与摄像头共用一轴头部,麦克风固定底座 |
| 模型及月费 | DeepSeek V4.1 Flash API;中文STT/TTS本地优先;云端语音仅为可选后备 | 本地速度实测;月费主要来自LLM/按需视觉 |
| **LLM** | **DeepSeek `deepseek-flash`(云端,关思考)**;本机 Qwen3.8-27B 已停用 | 首 token 0.8–1.2s;月费来自 LLM 与按需视觉 |
| **TTS** | **Qwen3-TTS 1.7B(服务器 3090)**,Serena 音色 + 轻快活泼指令 | RTF 0.55–0.94;首音延迟的主要来源 |
| **STT** | Zipformer 中英双语 int8(CPU)——**仍是 2023 年模型,中文识别率未测** | v2.4 未替换,遗留项 |
| 米家设备 | 到扩展阶段收集型号、地区、网关/HA情况 | 集成与采购选择 |
| 结构 | 屏幕/摄像头到货后称重,确认加工条件 | 电机、支架、电源 |
## 2. 总体架构
```text
USB 麦克风阵列(固定底座) ──处理后音频、DoA──┐
USB 摄像头(随头部转动) ──本地图像───────────┤
▼
N5095 / 16G
会话状态机 + Pipecat:本地VAD / STT → DeepSeek API → 本地分句TTS
TTS → 阵列 USB 播放端点 → 阵列音频输出 → 有源音箱
本地人脸检测 / 目标锁定 → 角度控制(不依赖LLM响应)
SQLite 记忆 / 受限工具调用 / 日志与故障恢复
│ USB 串口,初始115200
▼
ESP32:本地表情、运动轨迹、限位、心跳超时处理
├─ SPI → GC9A01屏幕
┌─ cadadmin-server(192.168.0.20)—— 助手本体 ────────────────┐
│ 会话引擎(身份 → 记忆 → LLM → TTS) │
│ ├─ LLM:DeepSeek deepseek-flash(云端,关思考) │
│ ├─ TTS:Qwen3-TTS 1.7B(RTX 3090,独立 HTTP 服务 :18040) │
│ ├─ STT:Zipformer 中英宽带 int8(CPU 4 线程) │
│ ├─ 记忆:SQLite(按 owner_user_id 强制隔离) │
│ └─ 身份:手动切换 + 访客兜底 │
└────────────────────────────────────────────────────────────┘
▲ WebRTC(计划) ▲ 手机/浏览器客户端
│ 局域网 1–2ms │
┌─ N5095 / 16G —— 客户端 / 外设桥 ───────────────────────────┐
│ USB 麦克风阵列(固定底座)──处理后音频、DoA──┐ │
│ USB 摄像头(随头部转动)──本地图像───────────┤ │
│ ├─ DoA 读取(本地,**不过网**——转头回路延迟敏感) │
│ ├─ 本地人脸检测 / 目标锁定 → 角度控制(不依赖LLM响应) │
│ ├─ 插话检测、音频编解码、WebRTC 客户端 │
│ └─ USB 串口 115200 → ESP32 │
└────────────────────────────────────────────────────────────┘
│
▼
ESP32-S3:本地表情、运动轨迹、限位、心跳超时处理
├─ SPI → 2.8寸 ILI9341 触摸屏(已到货)
└─ 控制信号 → 一轴执行器(独立合适供电支路)
```
- Pipecat作为唯一会话管线框架;本地音频已有示例,系统音频依赖仍需验证。[官方示例](https://github.com/pipecat-ai/pipecat/blob/main/examples/README.md)
- **分工原则:延迟敏感且硬件绑定的留在本地**(音频进出、唤醒、VAD、
插话检测、DoA、表情/动作),**算力密集的放服务器**(STT、TTS、LLM 调用、记忆)。
- Pipecat作为唯一会话管线框架。服务器侧用 WebRTC 传输(SmallWebRTC),
**WebRTC 自带 AEC、抖动缓冲、丢包恢复**——这解决了方案 P2 要求的 AEC 难题,
原本需要自行实现。[传输选型](https://docs.pipecat.ai/client/concepts/choosing-a-transport)
- 不叠加Vision Agents:它是Stream的独立框架,不是Pipecat官方扩展。人脸检测另选轻量本地模型。[项目官方仓库](https://github.com/GetStream/vision-agents)
- STT/TTS采用本地可替换适配器,LLM使用DeepSeek V4.1 Flash。官方2026-09-10公告指定API模型名为`deepseek-flash`;这是可更新别名,实施时记录实际版本并做流式/工具调用验证。[官方公告](https://deepseek.com/news/deepseek-v4-1-flash/)
- 视觉跟随先试640×480、5~10fps,按CPU、温度和延迟调整,未承诺该帧率已可达到。视觉问答按请求抓拍和缩放上传,不持续发送视频。
- STT/TTS采用可替换适配器(本地/远端两种后端已实现)。LLM 为 DeepSeek
`deepseek-flash`;这是可更新别名,实施时记录实际版本并做流式/工具调用验证。
**实测必须显式关闭思考模式**,否则多轮与工具回灌一律 400。
[官方公告](https://deepseek.com/news/deepseek-v4-1-flash/) · 详见 `P0-DeepSeek验证报告.md`
- **视觉跟随在客户端做**(N5095 本地),先试640×480、5~10fps,
按CPU、温度和延迟调整,未承诺该帧率已可达到。视觉问答按请求抓拍和缩放上传,
不持续发送视频。
- CPU标称TDP不能当整机常开功耗;实际插座功率、风扇噪声、温度在原型阶段测量。
- **降级策略**:服务器或网络失效时,本地按键、静音和屏幕继续工作;
建议保留一个极小的本地 TTS 兜底(piper-x_low 仅 20MB、RTF 0.17),
用于播报"网络断了"——这是设备失联时唯一能出声的东西。
### 视频采集与框架边界
@@ -59,35 +95,54 @@ ESP32:本地表情、运动轨迹、限位、心跳超时处理
阶段通过后再扩大范围,不预先给出未经验证的固定工期。
| 阶段 | 实施内容 | 拟定完成标准 | 硬件依赖 |
**进度列说明**:✅ 完成 · 🔶 部分完成 · ⬜ 未开始
| 阶段 | 实施内容 | 拟定完成标准 | 进度 |
|---|---|---|---|
| P0 环境与服务 | 盘点Ubuntu版本/USB;固定依赖;本地STT/TTS基准及DeepSeek请求 | 样例音频/文本先测试;设备到货后10次录放测试;记录RTF、首音延迟、CPU和内存 | 无外设可先测文件;实时测试待音频套件 |
| P1 基础语音 | 按键启动;流式回复;停止按钮;超时提示 | 20轮固定中文用例至少18轮完成;短问答结束到首段有声回复,目标P50≤2秒、P95≤4秒 | 耳麦或音频套件 |
| P2 外放与表情 | AEC、打断、唤醒;听/想/说/离线/静音表情 | 指定距离音量下20次打断至少18次成功;检测到有效用户语音至停播目标≤300ms;30分钟外放无自我触发;运行2小时稳定 | 阵列、音箱、MCU、屏幕 |
| P3 多用户记忆 | 用户档案、显式切换、偏好/摘要/修改/遗忘 | 两个用户同一问题得到各自偏好;未知用户无私人记忆;重启保留、更正/删除有效 | 无新增 |
| P4 一轴转头 | 静态DoA/人脸验证,再接电机;锁定和限位 | 左/中/右各10次唤醒至少27次转向正确区域;轻微移动不频繁摆动;断联/重启不撞限位;运动时重复P2测试 | 摄像头、电机、支架、供电 |
| P4a 基础多人/身份试验 | 2~3张脸跟踪;轮流发言;可选录入式人脸识别 | 20次轮流唤醒至少18次选中正确目标;同方向/交叉/遮挡时允许拒判;未知人、身份切换不得串记忆 | 同一阵列和单目摄像头,无新增专用硬件 |
| P5 扩展 | 按需视觉问答;HA接一盏灯 | 检查10次视觉请求;开关灯、离线、超时均反馈真实结果 | 现有米家设备优先 |
| P0 环境与服务 | 盘点Ubuntu版本/USB;固定依赖;本地STT/TTS基准及DeepSeek请求 | 样例音频/文本先测试;设备到货后10次录放测试;记录RTF、首音延迟、CPU和内存 | 🔶 **环境盘点、STT/TTS/LLM 基准、DeepSeek 四条路径均已实测**;缺 10 次录放(无音频硬件)。**STT 中文识别率仍是未闭合项** |
| P1 基础语音 | 按键启动;流式回复;停止按钮;超时提示 | 20轮固定中文用例至少18轮完成;短问答结束到首段有声回复,目标P50≤2秒、P95≤4秒 | 🔶 **链路已跑通**(文本/音频 → STT → 记忆 → LLM → TTS → 音频,16/16 通过);缺实时音频进出、按键、20 轮用例。首音实测约 1.5s,加 VAD 后 **2.0–2.2s,卡在目标线** |
| P2 外放与表情 | AEC、打断、唤醒;听/想/说/离线/静音表情 | 指定距离音量下20次打断至少18次成功;检测到有效用户语音至停播目标≤300ms;30分钟外放无自我触发;运行2小时稳定 | ⬜ 未开始。**AEC 计划由 WebRTC 提供**(不自行实现) |
| P3 多用户记忆 | 用户档案、显式切换、偏好/摘要/修改/遗忘 | 两个用户同一问题得到各自偏好;未知用户无私人记忆;重启保留、更正/删除有效 | 🔶 数据层已实现并测试(身份隔离、越权删除、级联清理、候选不参与检索共 28 项);缺"修改/遗忘"的用户入口 |
| P4 一轴转头 | 静态DoA/人脸验证,再接电机;锁定和限位 | 左/中/右各10次唤醒至少27次转向正确区域;轻微移动不频繁摆动;断联/重启不撞限位;运动时重复P2测试 | ⬜ 未开始,待摄像头与电机 |
| P4a 基础多人/身份试验 | 2~3张脸跟踪;轮流发言;可选录入式人脸识别 | 20次轮流唤醒至少18次选中正确目标;同方向/交叉/遮挡时允许拒判;未知人、身份切换不得串记忆 | 🔶 `IdentityProvider` 接口与手动切换已实现(人脸候选**不自动切换**已测试);缺人脸检测 |
| P5 扩展 | 按需视觉问答;HA接一盏灯 | 检查10次视觉请求;开关灯、离线、超时均反馈真实结果 | 🔶 工具调用链路已验证(DeepSeek 与本机 vLLM 均通过);缺米家与视觉 |
验收记录距离、背景声音、播放音量、网络、模型版本、样本数。目标用于定位瓶颈,不保证所有环境都达到。耳麦通过不能代替远场外放验收。
**v2.4 新增的验收项**:
- **首音延迟必须按部署机(服务器)测**,不能沿用开发机的数字。
同一份代码在 N5095 上首音 1980ms,在服务器上 179ms(vits 时代)。
- **TTS 与 STT 的线程配置分别测**。实测两者线程扩展方向相反:
STT 超过 2 线程急剧退化(N5095 无 AVX2,4 线程慢 3.9 倍),
TTS 是线程越多越快。不能沿用一套配置。
- **换语音模型时必须重测首音延迟**。Qwen3-TTS 比 vits-zh-ll 音质好一个档次,
但首音从 179ms 涨到 1545ms(8.6 倍)。音质与延迟在这个硬件上是此消彼长的。
P2的30分钟/2小时测试仅是初步门槛,不能证明长期稳定。阶段通过后做一次正常音量下至少8小时运行:记录误唤醒次数、内存增长、音频欠载、重连及温度。P4a把“转对人”和“认对身份”分开统计,额外报告误认、拒判、切换次数,不用总成功率掩盖串身份。测试人数不足时,多用户记忆可用模拟身份测试,人脸/发言者匹配则明确标记未实测。
## 4. 分批采购清单
本表未报价:用户预算尚未确定,具体SKU和商家现价需核价。已确认麦克风、音箱、屏幕、摄像头均需购买;MCU和线材是否已有待核对。
> **v2.4 更新(2026-09-15)**:ESP32-S3 开发板与屏幕**已于 09-14 到货并点亮**,
> 但屏幕是 **2.8 寸 ILI9341 触摸屏(240×320)**,与原定的 1.28 寸 GC9A01 圆屏不符。
> 接线与测试程序见 `ESP32-S3与2.8寸SPI触摸屏接线记录.md`。
> **最终屏幕形态待确认**——这会影响外壳与转台结构设计,越早定越省返工。
> 下列清单已按现状调整。
### 第一批:可先购买的通用原型件
本表未报价:用户预算尚未确定,具体SKU和商家现价需核价。**当前实际还缺的只有音频(麦克风阵列+音箱)和摄像头。**
| 物品 | 数量 | 购买规格 | 备注 |
|---|---:|---|---|
| ESP32-S3开发板 | 1 | 优先N8R8等带PSRAM型号,已焊2.54mm排针,有原理图/引脚表/USB下载说明 | PSRAM便于放素材,简单表情并非必须 |
| GC9A01圆屏模块 | 1 | 1.28英寸240×240、SPI、3.3V逻辑;明确供电和背光接法;已焊排针 | 不买裸屏FPC;当前沿用原方案圆屏形态 |
| USB数据线 | 按缺口 | 匹配板卡插口,明确支持数据,原型约0.5~1米 | 阵列和摄像头线按套装补齐 |
| 杜邦线/面包板/绝缘固定件 | 1套 | 与排针匹配 | 只用于逻辑低电流,不走电机大电流 |
| 瞬时按键 | 2 | 普通低压按键,接空闲GPIO | 会话停止、软件静音;不宣称物理断麦 |
### 第一批:已基本到位
如更想减少接线,可以用ESP32-S3带1.28英寸屏幕的一体板替代前两项,不能重复买。须先核实外露GPIO、接插件间距和电机控制引脚。一体板不同SKU不能混用引脚表。[Waveshare官方参考](https://www.waveshare.com/product/esp32-s3-lcd-1.28.htm)
| 物品 | 数量 | 状态 |
|---|---:|---|
| ESP32-S3开发板 | 1 | ✅ 已有 N16R8(带 PSRAM) |
| 屏幕 | 1 | ✅ 已有 2.8 寸 ILI9341 触摸屏(**非原定 1.28 寸圆屏**) |
| USB数据线 | 按缺口 | 按缺口补 |
| 杜邦线/面包板/绝缘固定件 | 1套 | 按缺口补;只用于逻辑低电流,不走电机大电流 |
| 瞬时按键 | 2 | 待购;会话停止、软件静音,不宣称物理断麦 |
若最终决定用 1.28 寸圆屏形态,仍需单独采购 GC9A01 模块;若接受 2.8 寸方屏,则本批已完成。
两种形态的表情素材、外壳和结构设计都不同,**先定形态再动结构**。
### 同期优先核价:音频套件
@@ -126,19 +181,34 @@ P2的30分钟/2小时测试仅是初步门槛,不能证明长期稳定。阶
## 5. 音频与陪伴行为
### 本地STT/TTS优先方案
### 语音方案(v2.4 已实测定稿)
STT是语音转文字,TTS是文字转语音,都不要求云API。N5095负责本地推理,DeepSeek负责理解和生成回复;Pipecat编排这些组件,不要求每个组件都上云。
STT是语音转文字,TTS是文字转语音。**v2.4 起语音推理全部在服务器**,
N5095 只做音频进出——实测证明 N5095 跑不动可用的语音模型,见 `P0-语音基准报告.md`。
| 模块 | 首选试验 | 备选与限制 |
| 模块 | 采用方案 | 实测 | 备选与限制 |
|---|---|---|---|
| VAD | 本地轻量VAD | — | 单独处理唤醒和说完判断 |
| 唤醒 | sherpa-onnx中文关键词检测作为候选 | — | 单独模型/词表,用户确定称呼后调阈值;普通STT不会自动等于唤醒引擎 |
| STT | **Zipformer 中英双语 int8**(服务器 CPU 4线程) | RTF 0.03 | **仍是 2023 年模型,中文识别率从未测过**(无参考文本)。候选:SenseVoice / faster-whisper large-v3,已下载未测 |
| TTS | **Qwen3-TTS 1.7B-CustomVoice**(服务器 3090,独立 HTTP 服务) | RTF 0.55–0.94 | 音色 Serena + 指令"用轻快活泼的语气说"(用户试听选定)。备选:vits-zh-ll(RTF 0.09 但音色不满意)、CosyVoice 2/3(未测) |
| LLM | **DeepSeek `deepseek-flash`**(云端,关思考) | 首 token 0.8–1.2s | 本机 Qwen3.8-27B 的 vLLM 已停用——GPU 让给 TTS。可改 `AI_LLM_PRIMARY=local` 切回,但切回后 GPU 不够跑 Qwen3-TTS |
[sherpa-onnx官方功能](https://k2-fsa.github.io/sherpa/intro.html)、[SenseVoice模型说明](https://k2-fsa.github.io/sherpa/onnx/sense-voice/pretrained.html)、[中文VITS模型](https://k2-fsa.github.io/sherpa/onnx/tts/pretrained_models/vits.html)。以上说明模型/运行时支持,不构成性能保证。
**音质与延迟的取舍(v2.4 的核心权衡)**:同一台服务器上
| 配置 | 首音延迟 | 音质 |
|---|---|---|
| VAD | 本地轻量VAD | 单独处理唤醒和说完判断 |
| 唤醒 | sherpa-onnx中文关键词检测作为候选 | 单独模型/词表,用户确定称呼后调阈值;普通STT不会自动等于唤醒引擎 |
| STT | sherpa-onnx中文流式Zipformer模型 | 比较量化SenseVoiceSmall+VAD分段;后者不是原生流式识别,不混淆分段处理与流式中间结果 |
| TTS | sherpa-onnx中文VITS,例如aishell3 | 对比中文Piper/Melo模型的速度和听感;先固定音色,不做克隆 |
| LLM | DeepSeek V4.1 Flash API | 本地语音不等于整个助手离线:智能回复仍依赖网络 |
| 本地 Qwen3.8-27B + vits-zh-ll | **179 ms** | 用户不满意 |
| DeepSeek + Qwen3-TTS | **1545 ms** | 用户试听选定 |
[sherpa-onnx官方功能](https://k2-fsa.github.io/sherpa/intro.html)、[SenseVoice模型说明](https://k2-fsa.github.io/sherpa/onnx/sense-voice/pretrained.html)、[中文VITS模型](https://k2-fsa.github.io/sherpa/onnx/tts/pretrained_models/vits.html)。以上说明模型/运行时支持,不构成N5095性能保证。
用约 1.4 秒的首音延迟换音质。这个取舍是可逆的(改配置即可),
但**在 24GB 显存下两者不可兼得**。后续若装成 flash-attn 或实现真流式,
延迟可望收回一部分——详见 `P0-Qwen3TTS报告.md`。
P0测试10~20条不同长度中文,覆盖数字、日期、中英混读。记录STT识别错误、语音结束后等待时间、TTS首段耗时和RTF(生成/处理耗时除以音频时长,低于1表示平均快于实时)。音频与视觉并发时重测,限制推理线程和视觉帧率,避免CPU争抢。
**v2.4 补充:STT 与 TTS 的线程配置必须分别调**——实测两者线程扩展方向相反。
P0测试10~20条不同长度中文,覆盖数字、日期、中英混读。记录STT识别错误、语音结束后等待时间、TTS首段耗时和RTF(生成/处理耗时除以音频时长,低于1表示平均快于实时)。音频与视觉并发时重测,限制推理线程和视觉帧率,避免CPU争抢。
@@ -93,7 +93,7 @@ touch.setRotation(1);
### 笑脸与触摸基础测试
文件:`esp32_smile_test/esp32_smile_test.ino`。
文件:`~/workspace/ai-companion/esp32_smile_test/esp32_smile_test.ino`(在代码仓库,不在本笔记库)。
- 启动切换红、绿、蓝,再显示笑脸。
- 按下屏幕时笑脸在黄色/青色之间切换,显示触摸次数。
@@ -101,7 +101,7 @@ touch.setRotation(1);
### 动态眼睛测试
文件:`esp32_touch_eyes/esp32_touch_eyes.ino`。
文件:`~/workspace/ai-companion/esp32_touch_eyes/esp32_touch_eyes.ino`(在代码仓库)。
- 已取消开机三点校准,直接显示眼睛,使用代码顶部的默认触摸范围。
- 点击时眨眼,视线朝向触摸位置;按住移动可跟随。
@@ -0,0 +1,113 @@
# P0 验证报告 · DeepSeek API
日期:2026-09-14。机器:N5095 / Ubuntu 24.04.3 / Python 3.13.5。
脚本:`~/workspace/ai-companion/p0/verify_deepseek.py`。结果:**11/11 通过**。
复现:
```bash
set -a; . ~/.config/ai_assistant/env; set +a
cd ~/workspace/ai-companion && python3 p0/verify_deepseek.py
```
密钥存放:`~/.config/ai_assistant/env`(权限 600,不在项目目录内,不进版本管理)。
## 结论摘要
| # | 项目 | 结果 |
|---|---|---|
| 0 | 模型清单 | 仅 `deepseek-flash`、`deepseek-v4-pro` |
| 1a | 纯文本流式 | 可用 |
| 1b | 思考内容分离 | on 模式有 `reasoning_content`;off 模式无 |
| 1c | 首 token 延迟 | **P50 = 786ms,P95 = 1100ms**(N=8) |
| 2a | 流式工具调用 | 可用,参数 JSON 正确 |
| 2b | 工具结果回灌 | 可用 |
| 2c | 思考模式约束 | 开启时**必须回传 `reasoning_content`** |
| 3a | **图片输入** | **可用**,正确读出图中数字 |
| 4a | 中途断开(打断) | 无异常 |
| 4b | 超时 | 正确抛出 `TimeoutError` |
| 4c | 错误模型名 | HTTP 400,可作启动自检 |
## 逐条说明
### 1. 模型名:方案文档正确,Pipecat 默认值错误
`GET /v1/models` 实际返回:
```json
[{"id": "deepseek-flash"}, {"id": "deepseek-v4-pro"}]
```
- 方案文档 §2 记的 `deepseek-flash` **正确**。
- **Pipecat 的 `DeepSeekLLMService` 默认模型是 `deepseek-v4-flash`,该模型不存在**,直接用会 400。
实例化时必须显式覆盖为 `deepseek-flash`。
### 2. 思考模式必须显式关闭(重要)
`deepseek-flash` 默认开启思考模式。此时 API 要求**把上一轮 assistant 消息里的
`reasoning_content` 原样回传**,否则任何多轮对话与工具回灌都返回:
```
400 invalid_request_error: The `reasoning_content` in the thinking mode must be passed back to the API.
```
实测三种手工构造 assistant 消息的写法(`content=None` / `content=""` / 不带 tools)**全部 400**;
只有回传服务端返回的原始 assistant 消息才通过。
**影响**:多数框架的上下文层按 OpenAI 格式存储消息,不保留 `reasoning_content`——
一旦开启思考模式,Pipecat 的上下文管理与工具循环会持续踩这个坑。
**决定**:会话主路径传 `"thinking": {"type": "disabled"}`。两条理由:
1. 彻底消除 `reasoning_content` 回传要求,上下文层可安全裁剪消息;
2. 开启时**第一个流式 chunk 是 `reasoning_content` 而非 `content`**,若直接送 TTS 会念出思考过程。
关闭后两者合一,无需分流。
Pipecat 该服务的默认值恰好是 `DeepSeekThinkingConfig(type="disabled")`,与本结论一致。
若将来要开思考以提升回答质量,必须同时改造上下文层保证 `reasoning_content` 不丢。
### 3. 首 token 延迟
N=8,短问答,`thinking=disabled`:**P50 = 786ms,P95 = 1100ms**(min 515 / max 1100)。
对照方案 P1 验收目标"短问答结束到首段有声回复 P50≤2 秒、P95≤4 秒",
LLM 占约 0.8 秒,**余下约 1.2 秒预算**留给 STT 收尾判定 + TTS 首段合成。这个余量是否够,
取决于本地 TTS 的 RTF(待测,见下)。
注:thinking 开关对首包时间影响不大(约 700ms vs 718ms),但关闭后总耗时更短,因为不再生成思考 token。
### 4. 图片输入可用(P5 的意外好消息)
`deepseek-flash` 接受 `image_url` 的 base64 data URI,正确读出图中文字("7391")。
方案 §P5 的视觉问答不需要另找模型,按需抓拍 → 缩放 → base64 上传即可。
**未测**:视觉请求的 TTFT(预估显著长于纯文本)、大图 token 消耗、并发下的表现。
按需视觉是按次计费项,接 P5 时需单独测成本。
### 5. 工具调用链路完整
流式工具调用可用,`finish_reason="tool_calls"`,参数 JSON 正确。
回灌工具结果后模型能生成自然语言确认("客厅的灯已经打开了")。
这是 P5 米家控制的基础链路,已验证通畅。
**注意**:回灌时 `tool_call_id` 必须用服务端返回的真实值,assistant 消息必须用服务端返回的原始对象,不能手工构造。
### 6. 打断与超时
- **打断**(收到若干 chunk 后主动 close):无异常抛出,连接正常释放。对应方案"打断时取消旧回复生成"的客户端能力成立。
- **超时**:非流式长生成 + 2 秒预算,正确抛出 `TimeoutError`。
- 流式下 socket 超时不会因首包快而触发——**"打断"和"超时"必须分开实现检测**,不能只做一种。
### 7. 计费与缓存
`usage` 返回 `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens`,实测命中 256/398。
系统提示词固定可吃到缓存,对月费有利。方案 §8 要求的费用统计应记录这两个字段。
## 遗留与未验证
- 视觉请求的延迟与 token 成本未测。
- 长上下文(多轮 + 记忆注入)下的延迟退化未测。
- 并发请求未测(多用户场景下的表现)。
- 本次仅验证 API 链路,**未接入 Pipecat、未写任何管线代码**。
- 本机 `github.com` / `huggingface.co` 直连不通,需走本地 mihomo 代理 `127.0.0.1:7890`
或国内镜像。DeepSeek API 直连正常,不需要代理。
@@ -0,0 +1,128 @@
# P0 报告 · Qwen3-TTS 选型与部署
日期:2026-09-15。起因:用户试听后反馈 vits-zh-ll 音色不满意,
要求换"体量更大效果更好的模型",并提出 LLM 可以用线上 API、
把 GPU 让给语音。
服务:`~/tts-qwen/`(服务器)· 模型:`~/tts-service/models/Qwen3-TTS-1.7B-CustomVoice`
## 一、为什么选 Qwen3-TTS
查证后的结论(来源见文末):
- **实时对话场景的首选是 Qwen3-TTS 或 CosyVoice 2/3**;
IndexTTS2 和 F5-TTS 虽然中文指标也强,但流式不是它们的目标,不做实时助手首选
- Qwen3-TTS 官方标称**流式首包 97ms**,RTX 3090 上实测约 130ms
- 官方推荐硬件就是 **RTX 3090/4090 24GB**,与现有硬件吻合
- Apache-2.0,10 种语言 + 中文方言
- 9 个预置音色 + **自然语言风格指令**(这是 vits 那一代没有的能力)
## 二、部署路上撞到的两个障碍
### 障碍 1:vLLM-Omni 装不起来能用的服务
原本的打算是走 vLLM-Omni(它提供 OpenAI 兼容的流式 `/v1/audio/speech`
分块接口,是延迟最优的路径)。两个问题叠加:
1. **`--task-type` 参数没被注册**。vllm-omni 把 CLI 扩展注册在
`vllm.general_plugins`,但 vllm 0.28 的 serve 解析器没加载它,
`vllm-omni serve --help` 里根本看不到这个参数。
2. **绕过后仍崩溃**。模型架构能正确识别
(`Resolved architecture: Qwen3TTSForConditionalGeneration`),
但 vLLM 因为模型 config 里的 mrope 参数
(transformers 5.14 报 `Unrecognized keys in rope_parameters:
{'interleaved', 'mrope_section'}`)把它当多模态模型走 mm 路径,
崩在 `assert isinstance(model, SupportsMRoPE)`。
根因是 **transformers / vllm / vllm-omni 三者的版本组合问题**,
不是配置错误。没有继续深挖。
### 障碍 2:qwen-tts 与 vllm-omni 依赖互斥
改用厂商自己的 `qwen-tts` 包后,发现它会把 transformers 从
**5.14.1 降到 4.57.3**、huggingface_hub 从 **1.31.0 降到 0.36.2**,
直接破坏 vllm-omni(报 `StrictDataclassFieldValidationError`)。
**结论:两者不能共存于一个 venv。** 最终分两个 venv,
TTS 跑独立进程,助手通过 HTTP 调用。
## 三、实测数据
硬件:RTX 3090 24GB,1.7B 模型,非流式,`Serena` 音色。
| 文本 | 音频时长 | 合成耗时 | RTF |
|---|---|---|---|
| 好的,我记住了。(8字) | 1.52s | 1429ms | **0.94** |
| 今天北京晴转多云…(19字) | 3.36s | 1881ms | 0.56 |
| 我查了一下,明天上午…(26字) | 4.64s | 2527ms | 0.55 |
**关键发现:短句 RTF 反而最差。**
每次调用有约 1 秒的固定开销,与文本长度无关
(talker 模型自回归解码的启动成本 + 声码器解码)。
这条推翻了"让模型先说个短句来降低首音延迟"的直觉——
短句单位时间成本更高,反而不划算。
输出:**24kHz 单声道**(vits-zh-ll 是 16kHz,接播放链路时注意重采样)。
## 四、用户选定的配置
试听样本见 `out/tts_qwen/`。用户选定:
```
音色:Serena —— 温暖柔和的年轻女声
指令:用轻快活泼的语气说
```
(`instruct_7849.wav`,1.44s。通过时长比对确认对应关系:
默认 2.56s、轻快活泼 1.44s、温柔关切 1.72s。)
9 个预置音色中 5 个是中文:
Vivian(明亮微冷)、**Serena(温暖柔和)**、Uncle_Fu(低沉男声)、
Dylan(京腔男声)、Eric(成都话男声)。
## 五、代价:首音延迟涨了 8.6 倍
| 配置 | 首音延迟 |
|---|---|
| 本地 Qwen3.8-27B + vits-zh-ll | **179 ms** |
| DeepSeek + Qwen3-TTS | **1545 ms** |
拆解这 1545ms:DeepSeek 网络往返约 600ms + Qwen3-TTS 固定开销约 900ms。
加上 VAD 判定(200–500ms),总首音落在 **2.0–2.2 秒**,
方案 P1 目标是 P50≤2 秒——**刚好卡线,没有余量**。
换来的是音质从"用户不满意"到"用户试听选定"。
## 六、速度还能救,但都受阻
| 手段 | 预估收益 | 障碍 |
|---|---|---|
| flash-attn | 1.5–2× | 预编译 wheel 最高支持 torch 2.9,本机 2.14;源码编译要从 GitHub 拉依赖而该机下 GitHub release 断连 |
| 真流式(接管 codes→音频 解码循环) | 可绕开约 900ms 固定开销 | qwen-tts 封装把解码放在全部 codes 生成完之后,拿不到增量;要自己实现 |
| 降 torch 版本装 flash-attn | 1.5–2× | 会牵动 vLLM 0.28 的依赖 |
**真流式的收益最大**——那 900ms 固定开销正是流式能覆盖掉的部分。
但工作量也最大。
## 七、备份路线(未采用)
若 Qwen3-TTS 的速度最终不可接受,可考虑的替代:
- **CosyVoice 2/3**:同为实时对话第一梯队,Apache-2.0,工程成熟度最高,
18+ 中文方言。但搜索结果里首包延迟数据有冲突(150ms vs 1.5s),需实测。
- **云端 TTS API**:最快出效果,但助手要说的话会出网,
与方案"不自动发送音频到第三方"的口径有冲突(发的是文本不是音频,
但内容一样出网),且按量计费。
未测:CosyVoice 的本地部署与本机延迟。
## 八、来源
- [2026 开源 TTS 模型横向对比](https://juejin.cn/post/7649764223339495475)
- [Best Open-Source TTS Models for Production in 2026](https://instavar.com/research/tts/open-source-tts-models)
- [Qwen3-TTS 与 vLLM 集成部署](https://blog.csdn.net/weixin_30633869/article/details/157918741)
- [Speech Generation on vLLM-Omni:性能优化](https://docs.vllm.ai/projects/vllm-omni/en/stable/design/qwen3_omni_tts_performance_optimization/)
- [vllm-omni PR #3932:Code2Wav CUDA Graph 调优](https://github.com/vllm-project/vllm-omni/pull/3932)
- 模型 README:`~/tts-service/models/Qwen3-TTS-1.7B-CustomVoice/README.md`
@@ -0,0 +1,169 @@
# P0 验证报告 · 本地 Qwen3.8-27B
> ⚠️ **状态:该服务已于 2026-09-15 停用。**
> 原因:用户要求换更强的语音模型(vits-zh-ll 音色不满意),
> 而 3090 只有 24GB——27B 的 vLLM 占 21.5GB,装不下 Qwen3-TTS。
> 决定让 LLM 走云端 DeepSeek,GPU 让给语音。见 `P0-Qwen3TTS报告.md`。
>
> **本报告的结论仍然有效**(用于将来重启该服务,或评估其它本地 LLM):
> 关思考可让首 token 从 1390ms 降到 367ms;思考 token 会吃掉 max_tokens 预算;
> 只接受一条 system 消息;思考字段名与 DeepSeek 不同。
日期:2026-09-14。端点:`http://192.168.0.20:18020/v1`(vLLM,局域网 1.4ms)。
脚本:`~/workspace/ai-companion/p0/verify_local_llm.py`。结果:**5/6 通过**(未通过项为视觉,见 §5)。
复现:
```bash
set -a; . ~/.config/ai_assistant/env; set +a
cd ~/workspace/ai-companion && python3 p0/verify_local_llm.py
```
## 一、服务实况
方案文档假设 LLM 用 DeepSeek 云 API。实测发现局域网内已有可用服务:
| 项 | 值 |
|---|---|
| 服务 | **vLLM**(不是 llama.cpp,服务器上两套都有) |
| 模型 | `Qwen3.8-27B-W4A16-AutoRound-fast`(4bit 权重,16bit 激活) |
| `max_model_len` | 65536 |
| 思考解析 | `--reasoning-parser qwen3` |
| 到 N5095 延迟 | **1.4ms** |
| 视觉 | **不支持**(见 §5) |
## 二、思考模式:可关,且必须关
关闭方式(**已实测有效**):
```json
"chat_template_kwargs": {"enable_thinking": false}
```
实测三种模式(同一个数学题,`max_tokens=512`):
| 模式 | 思考字数 | 完成 tokens | 首 token(回答) |
|---|---|---|---|
| 默认(不传) | 168 | 214 | 527ms |
| **enable_thinking=false** | **0** | 512 | **81ms** |
| enable_thinking=true | 289 | 239 | 620ms |
**开思考的代价不只是慢**。看上面第二组数字:开思考时完成 tokens 只有 239,
而关思考是 512——因为思考 token 占用 `max_tokens` 预算。
在真实语音 prompt 上这个问题更严重(`max_tokens=200`):
| 模式 | 首 token(回答)P50 | 完成 tokens 中位 | 实际回答 |
|---|---|---|---|
| **关思考** | **367ms** | 10 | 「物流显示昨天已签收。」 |
| 开思考 | **1390ms** | 187 | 「我不」 ← **被截断** |
开思考时首 token 慢 3.8 倍,而且 200 的预算被思考吃光,回答只吐出两个字。
## 三、对比 DeepSeek 云 API
| 指标 | 本机 Qwen3.8-27B | DeepSeek `deepseek-flash` |
|---|---|---|
| 首 token(关思考) | **367ms** | 786ms |
| 网络延迟 | 1.4ms | 含在 786ms 内 |
| 思考模式 | 可关(`chat_template_kwargs`) | 可关(`thinking`),但字段名不同 |
| 视觉 | ❌ 本实例不支持 | ✅ 可用 |
| 费用 | 0(本地) | 按 token 计费 |
| 数据 | 不出局域网 | 上传第三方 |
**本机首 token 比云端快一倍多**,因为省掉了网络往返与排队。
## 四、两个会改设计的集成约束
### 1. 思考内容的字段名不一致
| 后端 | 字段名 |
|---|---|
| 本机 vLLM | `reasoning` |
| DeepSeek | `reasoning_content` |
适配层**必须两个都认**。只按 DeepSeek 的字段名写,会静默丢弃思考内容——
不报错,但"把思考念出来"或"思考占用显示"这类问题会莫名其妙出现。
### 2. 只允许一条 system 消息
发送两条 system 消息直接 400:
```
System message must be at the beginning.
```
**这推翻了原记忆设计**。原方案打算"检索到的记忆作为一条独立 system message 注入",
在本机 vLLM 上会直接失败。
修正后的做法:**保持唯一一条 system 消息,每轮重建**:
```
system = 人设 + 规则 + 本轮检索到的记忆
```
这其实更干净,也和"注入的记忆每轮替换而非追加"的要求一致。
## 五、未通过项:视觉
本实例返回:
```
At most 0 image(s) may be provided in one prompt.
```
模型目录 `Qwen3.8-27B-W4A16-AutoRound-fast/` 只有 safetensors 与 config,
**没有视觉塔**,是纯文本部署。
服务器上另有视觉能力:`~/models/mmproj-F16.gguf`(928MB)配合
llama.cpp 的 `128k_vision.sh`,以及 llama.cpp 的 `--mmproj` 参数。
但那是另一套服务,需要单独起。
**影响**:方案 §P5 的视觉问答在本端点不可用,需要么另起一个带 mmproj 的
llama.cpp 实例,要么走 DeepSeek。留到 P5 再定。
## 六、其余验证项
| 项 | 结果 |
|---|---|
| 工具调用(关思考) | ✅ `set_light({"room":"客厅","on":true})` |
| 纯文本流式 | ✅ |
| 端点与模型清单 | ✅ |
**未测**:取消/超时、长上下文退化、多客户端并发。
## 七、对 P1 延迟预算的影响
更新后的预算(用本机 LLM):
| 环节 | 耗时 | 来源 |
|---|---|---|
| VAD 判定说完 | 200–500ms | 估算,待实测 |
| STT 冲刷 | ≈0ms | 实测 |
| **LLM 首 token** | **367ms** | 实测 |
| TTS 首音(vits-zh-ll @服务器) | 57–152ms | 实测 |
| 网络 | 1.4–2ms | 实测 |
| **合计** | **约 0.6–1.0 秒** | |
方案 P1 目标 P50≤2 秒。**余量超过一倍。**
## 八、上下文建议
实测语音 prompt 约 406 tokens(人设 + 记忆 + 3 轮对话)。
建议按 4k tokens 设上限,超出时截断最旧的轮次:
- system(人设 + 本轮记忆):400–800 tokens
- 最近对话轮次:500–1000 tokens
- 当前输入:~50 tokens
vLLM 服务端的 `max_model_len=65536` 是全局配置,**不需要为助手改动**
(那是共享服务)——短上下文靠客户端控制实际 prompt 长度即可。
短上下文不只省资源,还直接降低首 token 延迟,这对"回应及时"是关键。
## 九、工程陷阱记录
1. **本机 vLLM 思考字段名是 `reasoning`**,不是 `reasoning_content`(DeepSeek 的写法)。
2. **只接受一条 system 消息**,多条直接 400。
3. **开思考会吃掉 `max_tokens` 预算**,导致回答被截断。
语音场景下 `max_tokens` 本来就要设小,这个问题会被放大。
4. N5095 上系统 `python3` 无 PIL,测试脚本要用 venv 里的解释器。
@@ -0,0 +1,228 @@
# P0 基准报告 · 本地 STT / TTS
> ⚠️ **TTS 选型已于 2026-09-15 变更。**
> 本报告选出的 vits-zh-ll 虽然速度达标(服务器 RTF 0.09),
> 但用户试听反馈**音色不满意**,最终换成 Qwen3-TTS 1.7B(RTF 0.55–0.94)。
> 见 `P0-Qwen3TTS报告.md`。
>
> **本报告的核心结论仍然有效且在用**:
> - STT 线程数不能按"核多给多"猜(N5095 上 4 线程比 2 线程慢 3.9 倍)
> - 冲刷耗时 ≈ 0,流式解码已持续输出
> - VITS 非自回归、`max_num_sentences=1` 会静默截断——这两条对 Qwen3-TTS 同样适用
> - STT 中文识别率未测(无参考文本),此结论仍然成立
>
> 另:本报告测出的"服务器(i9-14900K)上 STT RTF 0.03"仍是当前生产配置。
日期:2026-09-14。机器:Celeron N5095(4 核,**无 AVX2**)/ Ubuntu 24.04.3 / 16G。
脚本目录:`p0/stt_tts_bench/`。全部为实测数据,非估算。
复现:
```bash
cd ~/workspace/ai-companion/p0/stt_tts_bench
./download_models.sh # 走 hf-mirror
python3 bench_stt.py # STT
python3 bench_tts_compare.py # TTS 横向对比
python3 bench_concurrent.py # 并发
python3 bench_roundtrip.py # 往返
python3 bench_threads.py # 线程扫描
```
## 一、结论
**本地语音方案成立,但必须换 TTS 模型。**
内核:**STT 没问题,vits-zh-ll 不行,Piper 可以。**
| 组件 | 选型 | RTF | 判定 |
|---|---|---|---|
| STT | 流式 Zipformer 中英双语 int8 | 0.22(单跑)/ 0.50(并发) | ✅ 可用 |
| TTS | vits-zh-ll(115MB) | **1.86** | ❌ 慢于实时,不可用 |
| TTS | **piper-huayan-medium(61MB)** | **0.36** | ✅ 推荐 |
| TTS | piper-huayan-x_low(20MB) | 0.17 | ⚠ 快但音质存疑,待试听 |
## 二、STT
### 速度
| 线程数 | RTF 中位 | 单块解码 p95 | 判定 |
|---|---|---|---|
| 1 | 0.37 | 1.19 | 平均够,但存在突发积压 |
| **2** | **0.22** | **0.79** | ✅ 最佳 |
| 3 | 0.63 | — | 反而变慢 |
| 4 | 0.86 | 3.78 | 严重变慢 |
**关键发现:线程数超过 2 会急剧退化**,2→4 线程 RTF 从 0.22 涨到 0.86(慢 3.9 倍)。
重复 5 轮稳定复现,不是噪声。
原因判断:N5095(Jasper Lake)是**单通道内存**,且 L2 仅 1.5MB、L3 仅 4MB。
并且 onnxruntime 空闲工作线程默认自旋占核;4 个 ORT 线程 + Python 主线程 = 5 线程抢 4 个核,
主线程(负责喂音频)被饿死。
→ **实现时 STT 固定 `num_threads=2`。** 不要"核多就多给线程"。
### 其他指标
- **首字延迟**:喂进 0.5~1.4 秒音频后出现第一个中间结果。这是模型的算法前瞻量,与算力无关。
- **冲刷耗时 = 0ms**:`input_finished()` 后立即拿到最终结果。流式解码过程中已经持续输出,
收尾不额外花钱。**"说完到出结果"的开销主要在 VAD 判定说完的等待,不在识别器。**
- 注:并非每个 100ms 块都触发解码(约每 3 块解一次,即模型 chunk 约 300ms),
所以"单块 p95 > 1"不等于积压,应看 RTF。
### 识别正确率:未测
4 段测试音频是 sherpa-onnx 自带的中英双语真人录音,**没有参考文本**,无法算字错率。
尝试用"TTS 合成已知文本 → 喂给 STT"来算 CER,但**这个方法在这台机器上不成立**:
合成音频会让 STT 产生大量重复("晴晴转转"、"月月月"),而同样的解码配置下真人录音完全正常。
换 greedy / beam_search、换块大小、开关端点检测,四种配置结果完全一致 —— 说明问题出在合成音频
本身,不是解码配置。
**结论:STT 在中文真人语音上的正确率目前没有可信数据。** 需要一份带标注的中文真人测试集,
或用麦克风录一批带文本的语音。这是 P0 尚未闭合的一项,不能靠现有数据下结论。
## 三、TTS
### 速度
| 模型 | 大小 | RTF(2线程) | 19字句首音延迟 | 判定 |
|---|---|---|---|---|
| vits-zh-ll | 115MB | 1.86 | 2748ms | ❌ |
| piper-x_low | 20MB | 0.17 | 585ms | ✅ 快 |
| piper-huayan-medium | 61MB+espeak | 0.36 | ~650ms | ✅ 推荐 |
**VITS 非自回归**:验收时确认回调只在合成完成后触发一次,
所以**首音延迟 = 总合成耗时**,没有"边合成边播"的可能。
分句播放是唯一的提前出声手段(`max_num_sentences=1` 逐句合成)。
### 连续播放模拟(5 句)
| 模型 | 各句空档 | 最大空档 |
|---|---|---|
| vits-zh-ll | 2.0 / 1.6 / 0.5 / 2.9 / 3.8 秒 | **3.80s** |
| piper-x_low | 0.5 / 0 / 0 / 0 / 0 秒 | 0.47s(仅首句) |
vits-zh-ll 每句之间都会出现 0.5~3.8 秒的静默空档,说话断断续续。
Piper 除首句外无空档。
### 音质:必须人工试听
往返测试的 CER 不可信(见上),但可以给出**相对**信号:
| 模型 | 往返 CER | 输出峰值 | 输出 RMS |
|---|---|---|---|
| vits-zh-ll | 0.25 | **0.15** | 0.045 |
| piper-x_low | 0.72 | 0.49 | 0.092 |
| piper-huayan-medium | 0.32 | 0.59 | 0.105 |
两点值得注意:
1. **vits-zh-ll 输出音量极低**(峰值 0.15,只有 Piper 的 1/4)。接音箱时必须补增益,
否则要么听不见,要么底噪被一起放大。
2. **piper-x_low 音质存疑**:合成时有 `Unknown token: ̪` 警告,且往返 CER 明显偏差。
速度虽好(RTF 0.17),但**不建议直接用**。
已生成三个模型同一句话的试听文件,请人工判断:
```
~/workspace/ai-companion/p0/stt_tts_bench/listening_test/vits-zh-ll.wav
p0/stt_tts_bench/listening_test/piper-x_low.wav
p0/stt_tts_bench/listening_test/piper-medium.wav
```
**音质只能靠耳朵定,仪器测不出来。这一项在试听前不能算通过。**
## 四、并发(这是最接近真实负载的测试)
助手说话时麦克风不关(要检测打断),所以 STT 必须与 TTS 同时跑。
| TTS 模型 | STT 单跑 | TTS 单跑 | STT 并发 | TTS 并发 | 退化 | 判定 |
|---|---|---|---|---|---|---|
| piper-x_low | 0.26 | 0.19 | **0.50** | **0.33** | 1.9× / 1.7× | ✅ 余量 2.0× / 3.1× |
| vits-zh-ll | 0.26 | 1.97 | 0.48 | **2.39** | 1.9× / 1.2× | ❌ 跟不上 |
并发下两者都退化约 1.8 倍(4 核争抢所致,符合预期)。
用 Piper 时 STT 0.50 + TTS 0.33,都远低于 1,**架构成立**。
但这只是 STT + TTS 两项。方案还要同时跑人脸检测/跟踪。视觉再加进来是否还够,
需要单独测(未测)。
## 五、对方案 P1 指标的影响
方案 P1 目标:"短问答结束到首段有声回复,P50≤2 秒、P95≤4 秒"。
用实测数据推算(`thinking` 关闭):
| 环节 | 耗时 | 来源 |
|---|---|---|
| VAD 判定说完 | 200~500ms(估) | 待实测 |
| STT 冲刷 | ≈0ms | 实测 |
| LLM 首 token | 786ms(P50) | P0-DeepSeek 报告 |
| TTS 首音(Piper medium,短句) | ~650ms | 实测 |
| **合计** | **约 1.6~2.0 秒** | |
**用 Piper 时 P50 目标勉强可达**,但没有富余;P95 需要看网络抖动。
用 vits-zh-ll 则完全不可能(TTS 单项就 2.7 秒)。
## 六、未测事项(不要当成已通过)
- **STT 中文真人识别率**:无参考文本,未测。
- **TTS 音质**:待人工试听 `listening_test/`。
- **视觉并发**:人脸检测加入后的 CPU 争抢未测。
- **真实音频链路**:麦克风阵列、远场、AEC、回声参考全部未测,无硬件。
- **int8 量化损失**:只测了 int8 encoder,未与 fp32 对照(fp32 315MB,本机大概率跑不动)。
- **长时稳定性**:未做 2 小时/8 小时测试。
## 六之二、补充:更多 TTS 候选(同日追加)
用户试听后反馈 **vits-zh-ll 听感最好**,但它是 RTF 1.86 的不可用项。
据此追加测试了 5 个候选,全部实测:
| 模型 | 大小 | 单独 RTF(最佳线程) | 并发 RTF | 备注 |
|---|---|---|---|---|
| piper-x_low | 20MB | 0.17 @2 | 0.33 | 有 Unknown token 警告,音质存疑 |
| **piper-medium** | 61MB | **0.32 @2** | **0.51** | 用户反馈不如 vits |
| **matcha-icefall-zh-baker** | 88MB + 3.6MB 声码器 | **0.38 @2** | **0.94** | 新候选,流匹配 + HiFi-GAN |
| vits-zh-ll | 115MB | 1.14 @4 / 1.87 @2 | 1.48–2.39 | 听感最好,但不可用 |
| kokoro fp32 | 383MB | 1.96 @4 | — | 排除 |
| kokoro int8 | 181MB | **3.54 @4** | — | 排除 |
### 两条新增的机理结论
1. **TTS 的线程扩展性与 STT 相反**。STT 超过 2 线程急剧退化(4线程慢 3.9 倍),
而 vits-zh-ll 是线程越多越快(1线程 3.59 → 4线程 1.14)。
原因:STT 是大量小算子(每算子一次线程同步,线程越多同步开销越大),
TTS 是少数大算子(CPU 密集,可有效并行)。
**两类服务的线程配置必须分开调,不能统一。**
2. **int8 量化在这台机器上反而更慢**。Kokoro int8(3.54)比 fp32(1.96)慢近一倍。
原因:Tremont 没有 VNNI 指令,int8 的快速点积路径用不上,
权重需要现场反量化,额外开销盖过了带宽收益。
→ **本机不要走 int8 量化路线做加速。**(STT 的 int8 encoder 是发布方直接训练的量化模型,
不是运行时量化,不受此限。)
### 当前选型状态
- **速度与余量最优**:piper-medium(并发 0.51,余量约 2 倍)
- **架构档次更高、速度尚可**:matcha-baker(并发 0.94,**几乎没有余量**)
- matcha 的并发退化(0.38→0.94,2.5 倍)明显大于 piper-medium(0.32→0.51,1.6 倍),
判断与 HiFi-GAN 声码器吃内存带宽有关(本机单通道内存)。
**若选 matcha,人脸检测等其它负载再加进来很可能压垮实时性,需要实测。**
已生成响度归一化的 A/B 试听(统一到 -20 dBFS RMS,
避免"响度差被误当成音质差"——vits-zh-ll 原始峰值仅 0.17,比 Piper 低 3.5 倍):
```
~/workspace/ai-companion/p0/stt_tts_bench/listening_ab/{vits-zh-ll,matcha-baker,piper-medium}_{0,1}.wav
```
## 七、工程陷阱记录
1. **`max_num_sentences=1` 会截断多句输入**为第一句,且不报错。
传含多个句号的文本会静默丢失后半段。必须自己先分句逐句送。
2. **hf-mirror 对 urllib 默认 UA 返回 403**,需伪装 User-Agent。
3. **espeak-ng-data 里有带空格的文件名**(`voices/!v/Mr serious`),URL 必须转义。
4. **代码曾位于 note_sync(Obsidian 笔记仓库)内,是经软链接访问的**,
模型一度差点被推进笔记仓库。**2026-09-15 已把代码剥离为独立仓库**
(`~/workspace/ai-companion/`),笔记库只剩文档,这个问题不复存在。
教训:代码和笔记共用版本控制,迟早要在 ignore 规则上反复花时间。
@@ -1,94 +0,0 @@
// ESP32-S3 + ILI9341 240x320 display test.
// Install Adafruit ILI9341, Adafruit GFX Library and their dependencies.
// Display: SCK=12, SDI/MOSI=11, CS=10, DC=9, RESET=8, GND=GND.
// Touch: T_CLK=12, T_DIN=11, T_DO=13, T_CS=7, T_IRQ=6.
// Install XPT2046_Touchscreen by Paul Stoffregen as well.
// Display SDO and SD card are not connected. Backlight is externally powered.
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <XPT2046_Touchscreen.h>
constexpr int TFT_SCK = 12;
constexpr int TFT_MOSI = 11;
constexpr int TFT_CS = 10;
constexpr int TFT_DC = 9;
constexpr int TFT_RST = 8;
constexpr int TOUCH_MISO = 13;
constexpr int TOUCH_CS = 7;
constexpr int TOUCH_IRQ = 6;
Adafruit_ILI9341 tft(&SPI, TFT_DC, TFT_CS, TFT_RST);
// Poll first to test SPI independently of the IRQ wire.
XPT2046_Touchscreen touch(TOUCH_CS);
bool wasPressed = false;
bool alternateColor = false;
uint32_t touchCount = 0;
uint32_t lastReport = 0;
void drawFace(uint16_t color);
void setup() {
Serial.begin(115200);
delay(300);
pinMode(TFT_CS, OUTPUT);
digitalWrite(TFT_CS, HIGH);
pinMode(TOUCH_CS, OUTPUT);
digitalWrite(TOUCH_CS, HIGH);
pinMode(TOUCH_IRQ, INPUT_PULLUP);
SPI.begin(TFT_SCK, TOUCH_MISO, TFT_MOSI, TFT_CS);
tft.begin(10000000); // Start conservatively at 10 MHz for jumper wires.
tft.setRotation(1); // Landscape: 320 x 240. Use 3 if upside down.
touch.begin(SPI);
touch.setRotation(1);
const uint16_t colors[] = {ILI9341_RED, ILI9341_GREEN, ILI9341_BLUE};
for (uint16_t color : colors) {
tft.fillScreen(color);
delay(400);
}
tft.fillScreen(ILI9341_NAVY);
drawFace(ILI9341_YELLOW);
Serial.println("Touch test ready. Raw coordinates are not calibrated pixels.");
}
void drawFace(uint16_t color) {
const int cx = tft.width() / 2;
const int cy = 105;
tft.fillCircle(cx, cy, 82, color);
tft.fillCircle(cx - 28, cy - 22, 10, ILI9341_BLACK);
tft.fillCircle(cx + 28, cy - 22, 10, ILI9341_BLACK);
// Parabolic smile: the center is lower than the corners.
for (int x = -42; x <= 42; ++x) {
int y = cy + 43 - (x * x) / 65;
tft.fillCircle(cx + x, y, 3, ILI9341_BLACK);
}
tft.fillRect(0, 199, tft.width(), 41, ILI9341_NAVY);
tft.setTextColor(ILI9341_WHITE, ILI9341_NAVY);
tft.setTextSize(2);
tft.setCursor(16, 207);
tft.print("Touches: ");
tft.print(touchCount);
}
void loop() {
bool pressed = touch.touched();
if (pressed) {
TS_Point point = touch.getPoint();
if (!wasPressed) {
++touchCount;
alternateColor = !alternateColor;
drawFace(alternateColor ? ILI9341_CYAN : ILI9341_YELLOW);
}
if (millis() - lastReport >= 150) {
lastReport = millis();
Serial.printf("raw x=%d y=%d pressure=%d IRQ=%d\n",
point.x, point.y, point.z, digitalRead(TOUCH_IRQ));
}
}
wasPressed = pressed;
delay(20);
}
@@ -1,141 +0,0 @@
// ESP32-S3 N16R8 + ILI9341 + XPT2046-compatible touch.
// Libraries: Adafruit ILI9341, Adafruit GFX Library, XPT2046_Touchscreen.
// LCD: SCK=12 MOSI=11 CS=10 DC=9 RESET=8. LCD SDO disconnected.
// Touch: T_CLK=12 T_DIN=11 T_DO=13 T_CS=7 T_IRQ=6 (polled).
// SD disconnected. Keep the existing working power/backlight connections.
// No startup calibration. Adjust touch mapping constants below if needed.
#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ILI9341.h>
#include <XPT2046_Touchscreen.h>
#include <math.h>
Adafruit_ILI9341 tft(&SPI, 9, 10, 8);
XPT2046_Touchscreen touch(7);
// Small off-screen canvas avoids visible erase/redraw flashes.
GFXcanvas16 eyes(280, 112);
// Approximate raw range after touch.setRotation(1), not calibrated pixels.
constexpr int TOUCH_MIN_X = 200, TOUCH_MAX_X = 3900;
constexpr int TOUCH_MIN_Y = 200, TOUCH_MAX_Y = 3900;
constexpr bool TOUCH_SWAP_XY = false;
constexpr bool TOUCH_FLIP_X = true, TOUCH_FLIP_Y = true;
constexpr uint32_t IDLE_AFTER_MS = 15000;
uint32_t idleBlinkAt = 0, idleLookAt = 0;
bool idle = false, idleLookingAway = false;
float gazeX = 0, gazeY = 0, targetX = 0, targetY = 0;
uint32_t lastTouch = 0, blinkStart = 0, lastFrame = 0, lastDown = 0;
bool looking = false, blinking = false, contact = false;
int previousX = 999, previousY = 999, previousOpen = -1;
void renderEyes(int dx, int dy, int opening) {
eyes.fillScreen(ILI9341_BLACK);
for (int i = 0; i < 2; ++i) {
int x = i == 0 ? 72 : 208;
eyes.fillRoundRect(x-49, 12, 98, 88, 30, ILI9341_WHITE);
eyes.fillCircle(x+dx, 56+dy, 25, ILI9341_CYAN);
eyes.fillCircle(x+dx, 56+dy, 14, ILI9341_BLACK);
eyes.fillCircle(x+dx-7, 49+dy, 5, ILI9341_WHITE);
int cover = (100-opening)*44/100;
if (cover > 0) {
eyes.fillRect(x-50, 12, 100, cover, ILI9341_BLACK);
eyes.fillRect(x-50, 100-cover, 100, cover, ILI9341_BLACK);
}
if (opening < 7) eyes.fillRoundRect(x-39, 54, 78, 4, 2, ILI9341_CYAN);
}
tft.drawRGBBitmap(20, 54, eyes.getBuffer(), 280, 112);
}
void setup() {
Serial.begin(115200);
pinMode(10, OUTPUT); digitalWrite(10, HIGH);
pinMode(7, OUTPUT); digitalWrite(7, HIGH);
pinMode(6, INPUT_PULLUP);
SPI.begin(12, 13, 11, 10);
tft.begin(10000000);
tft.setRotation(1);
touch.begin(SPI);
touch.setRotation(1);
if (!eyes.getBuffer()) {
tft.fillScreen(ILI9341_RED);
Serial.println("Eye canvas allocation failed.");
while (true) delay(1000);
}
randomSeed(micros());
tft.fillScreen(ILI9341_BLACK);
tft.setTextColor(ILI9341_CYAN);
tft.setTextSize(2);
tft.setCursor(46, 207);
tft.print("Touch to look!");
lastFrame = lastTouch = millis();
}
void loop() {
uint32_t now = millis();
if (touch.touched()) {
TS_Point p = touch.getPoint();
float rawX = TOUCH_SWAP_XY ? p.y : p.x;
float rawY = TOUCH_SWAP_XY ? p.x : p.y;
float px = constrain((rawX-TOUCH_MIN_X)*319.0f/(TOUCH_MAX_X-TOUCH_MIN_X), 0.0f, 319.0f);
if (TOUCH_FLIP_X) px = 319.0f-px;
float py = constrain((rawY-TOUCH_MIN_Y)*239.0f/(TOUCH_MAX_Y-TOUCH_MIN_Y), 0.0f, 239.0f);
if (TOUCH_FLIP_Y) py = 239.0f-py;
idle = false;
idleLookingAway = false;
if (!contact) { blinking = true; blinkStart = now; }
contact = true;
lastDown = lastTouch = now;
looking = true;
targetX = (px-159.5f)/159.5f*20.0f;
targetY = (py-119.5f)/119.5f*14.0f;
} else if (contact && now-lastDown > 100) {
contact = false; // Short release debounce prevents repeated blinks.
}
if (looking && now-lastTouch >= 3000) {
looking = false;
targetX = targetY = 0;
}
// "Unattended" currently means no touch, not camera-based presence detection.
if (!contact && !looking && now-lastTouch >= IDLE_AFTER_MS) {
if (!idle) {
idle = true;
idleLookingAway = false;
idleBlinkAt = now + random(400, 1600);
idleLookAt = now + random(900, 2200);
}
if (!blinking && int32_t(now-idleBlinkAt) >= 0) {
blinking = true;
blinkStart = now;
idleBlinkAt = now + random(2500, 6500);
}
if (int32_t(now-idleLookAt) >= 0) {
idleLookingAway = !idleLookingAway;
if (idleLookingAway) {
targetX = random(0, 2) ? random(9, 21) : -random(9, 21);
targetY = random(-12, 13);
idleLookAt = now + random(800, 2000);
} else {
targetX = targetY = 0;
idleLookAt = now + random(1800, 4500);
}
}
}
if (now-lastFrame < 33) { delay(2); return; }
float dt = (now-lastFrame)/1000.0f;
lastFrame = now;
float blend = 1.0f-expf(-12.0f*dt);
gazeX += (targetX-gazeX)*blend;
gazeY += (targetY-gazeY)*blend;
int opening = 100;
if (blinking) {
uint32_t elapsed = now-blinkStart;
if (elapsed < 100) opening = 100-int(elapsed);
else if (elapsed < 170) opening = 0;
else if (elapsed < 330) opening = int((elapsed-170)*100/160);
else blinking = false;
}
int dx = lroundf(gazeX), dy = lroundf(gazeY);
if (dx != previousX || dy != previousY || opening != previousOpen) {
renderEyes(dx, dy, opening);
previousX = dx; previousY = dy; previousOpen = opening;
}
}