diff --git a/有趣的项目/AI桌面陪伴助手/AI桌面陪伴助手方案.md b/有趣的项目/AI桌面陪伴助手/AI桌面陪伴助手方案.md index 798ea77..375cbb8 100644 --- a/有趣的项目/AI桌面陪伴助手/AI桌面陪伴助手方案.md +++ b/有趣的项目/AI桌面陪伴助手/AI桌面陪伴助手方案.md @@ -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争抢。 diff --git a/有趣的项目/AI桌面陪伴助手/ESP32-S3与2.8寸SPI触摸屏接线记录.md b/有趣的项目/AI桌面陪伴助手/ESP32-S3与2.8寸SPI触摸屏接线记录.md index 6ba8688..5287716 100644 --- a/有趣的项目/AI桌面陪伴助手/ESP32-S3与2.8寸SPI触摸屏接线记录.md +++ b/有趣的项目/AI桌面陪伴助手/ESP32-S3与2.8寸SPI触摸屏接线记录.md @@ -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`(在代码仓库)。 - 已取消开机三点校准,直接显示眼睛,使用代码顶部的默认触摸范围。 - 点击时眨眼,视线朝向触摸位置;按住移动可跟随。 diff --git a/有趣的项目/AI桌面陪伴助手/P0-DeepSeek验证报告.md b/有趣的项目/AI桌面陪伴助手/P0-DeepSeek验证报告.md new file mode 100644 index 0000000..1177f55 --- /dev/null +++ b/有趣的项目/AI桌面陪伴助手/P0-DeepSeek验证报告.md @@ -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 直连正常,不需要代理。 diff --git a/有趣的项目/AI桌面陪伴助手/P0-Qwen3TTS报告.md b/有趣的项目/AI桌面陪伴助手/P0-Qwen3TTS报告.md new file mode 100644 index 0000000..2eb61ac --- /dev/null +++ b/有趣的项目/AI桌面陪伴助手/P0-Qwen3TTS报告.md @@ -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` diff --git a/有趣的项目/AI桌面陪伴助手/P0-本地LLM验证报告.md b/有趣的项目/AI桌面陪伴助手/P0-本地LLM验证报告.md new file mode 100644 index 0000000..89c4976 --- /dev/null +++ b/有趣的项目/AI桌面陪伴助手/P0-本地LLM验证报告.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 里的解释器。 diff --git a/有趣的项目/AI桌面陪伴助手/P0-语音基准报告.md b/有趣的项目/AI桌面陪伴助手/P0-语音基准报告.md new file mode 100644 index 0000000..ecd76bb --- /dev/null +++ b/有趣的项目/AI桌面陪伴助手/P0-语音基准报告.md @@ -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 规则上反复花时间。 diff --git a/有趣的项目/AI桌面陪伴助手/esp32_smile_test/esp32_smile_test.ino b/有趣的项目/AI桌面陪伴助手/esp32_smile_test/esp32_smile_test.ino deleted file mode 100644 index f625601..0000000 --- a/有趣的项目/AI桌面陪伴助手/esp32_smile_test/esp32_smile_test.ino +++ /dev/null @@ -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 -#include -#include -#include - -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); -} diff --git a/有趣的项目/AI桌面陪伴助手/esp32_touch_eyes/esp32_touch_eyes.ino b/有趣的项目/AI桌面陪伴助手/esp32_touch_eyes/esp32_touch_eyes.ino deleted file mode 100644 index 7e1fe81..0000000 --- a/有趣的项目/AI桌面陪伴助手/esp32_touch_eyes/esp32_touch_eyes.ino +++ /dev/null @@ -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 -#include -#include -#include -#include - -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; - } -}