Compare commits

..
Author SHA1 Message Date
leefer 00f8b9774b docs(preview): 预览区只保留内嵌播放器
- 「先看一眼」只放一个 <video controls>,Gitea 会渲染为页面内播放(自动改写相对路径到 /media)
- 移除封面图与文字说明、时间表、早期版本小节;docs/promo-poster.jpg 一并删除
- 视频文件本身不变(1600×900 / 30fps / 45s)
2026-10-28 22:46:12 +08:00
leefer 3ab581634a docs(preview): 首页预览换成 45 秒宣传片
- 新增 docs/promo.mp4(1600×900 / 30fps / 45s / 1.7MB)与封面 docs/promo-poster.jpg
- README「先看一眼」改为按七章列出时间表,注明数值来源与「语音与形象未接通」
- 22 秒介绍动画保留为「早期版本」,intro.html / render_preview.py / 四张静帧未改动
- .gitattributes 补 *.mp4 / *.webp 的二进制标记
2026-10-28 22:38:57 +08:00
leefer 3a986726d8 Merge pull request 'HEL-697: 视觉总监接手整体视觉与代码,记录需求v1.8' (#7) from agent/agent/6f5a7d8d7cb6 into main 2026-10-22 23:05:35 +08:00
白栖知andmultica-agent 81d7cb523d HEL-697: record restored director execution and runtime change provenance
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 21:18:13 +08:00
白栖知andmultica-agent c6dd174253 HEL-697: record director handoff and confirmed authentication blocker
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 20:53:20 +08:00
白栖知andmultica-agent 94ba2b654e HEL-697: assign visual design and implementation to visual director
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 20:50:54 +08:00
leefer 4242f8377e Merge pull request 'HEL-697: 完整需求跨任务继承与验收约束' (#5) from agent/agent/5004131dd07f into main 2026-10-22 19:50:12 +08:00
白栖知andmultica-agent 97aaefe5fa HEL-697: record task migration and requirement checks
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 19:39:13 +08:00
白栖知andmultica-agent 159253a12f HEL-697: preserve complete requirements and enforce task handoffs
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 19:33:51 +08:00
leefer 4984293998 Merge HEL-748: 完整前端与演示适配
独立审核通过,白栖知已验收前端演示版;主卡继续推进真实功能接入。
2026-10-22 11:28:51 +08:00
白栖知andmultica-agent 6c3b09e693 HEL-748: implement S1 frontend with explicit local demo
Co-authored-by: multica-agent <github@multica.ai>
2026-10-22 00:56:18 +08:00
123 changed files with 11204 additions and 5947 deletions
+2
View File
@@ -11,6 +11,8 @@
# 二进制
*.wav binary
*.mp3 binary
*.mp4 binary
*.webp binary
*.png binary
*.jpg binary
*.onnx binary
+37
View File
@@ -0,0 +1,37 @@
# CyberGril 项目必读规则
白栖知已于主卡 HEL-697 评论 `01a1635b-b47c-79ed-ac66-9408c7905af9` 批准需求继承办法。
## 开始任何工作前
1. 读取 `docs/requirements/README.md`、`current.md`、`coverage.md`,以及本次相关的原始来源。完整需求不能被任务卡摘录替代。记录所用版本和读取时的仓库提交。
2. 项目现行需求入口优先于旧 `docs/SPEC.md`、历史设计稿、样机、代码现状与任务卡中的旧指令。明确的用户新决定才能覆盖旧决定,并须更新需求变更记录。遇到冲突先核对原话,无法解决才向总管报告具体问题,不擅自降低要求。
3. 所有 CyberGril 任务应归属 CyberGril 项目,并包含需求入口、版本、关联要求编号、交付与验收、未完成项及后续承接、需求变更来源。现有历史卡的迁移不授权重启已停止的试验或重新执行已交付工作。
## 施工和交接
- 白栖知于主卡评论 `01a163a5-e2a7-7ac8-afdb-991868e721bc` 明确:本项目整体视觉由视觉总监接手设计与实际落地,职责不止审核把关。施工员无法实现对应效果时,由视觉总监直接编写相关代码。本轮对话及沉浸界面交由视觉总监亲自负责;总管负责协调、核对需求和交付,不再以自己的视觉原型替代它接手。此项是CyberGril项目分工,不修改队员在其他项目的全局职责、模型或认证。
- 使用 `docs/requirements/task-handoff-template.md`,写出本次交付及完整需求的关联;完整当前需求始终有效。阶段不做的内容列明承接,不删除需求。
- 需求、技术方案、阶段范围、实现状态、用户验收分开记录。不得把智能体建议、技术困难、模型登录失败或某阶段通过当作修改需求的授权。
- 不用“当前一两句”以外的长消息滚动主界面替代已确认的交流体验。长回复需完整可读,具体分句等方案先通过动态小样验证。
- 不用固定模板长文冒充 CG-014 的模型诠释自由、CG-015 的自主初始习惯与成长。出生底色不变;后天成长另记,不得因 UI 修复重算已有伴侣。
- 演示数据与真实能力严格区分。未分享生活不得借列表、搜索、摘要或旧链接泄露;真实服务须在响应前裁剪,前端过滤不是权限控制。
- 保留已有伴侣、名字、生日、聊天与记忆;写入测试使用隔离数据。用户认可界面小样前不替换现有正式界面。当前产品 PR#4 未获合并授权。
## 修改需求和审核
修改 `baseline.json` 中的要求时,须在 `changes.json` 和 `changes.md` 中记录白栖知明确批准的原话来源、旧要求、新要求及影响;原始来源只增补,不篡改。先核实来源作者和语义,不能用智能体确认代替。
交付前执行:
```bash
python3 scripts/check_requirements.py --base-ref origin/main
python3 scripts/check_requirements.py --base-ref origin/main --handoff <本次交接JSON文件>
```
检查脚本只发现结构缺项、来源损坏和编号失配。审核人仍须逐项核对原意、实际画面及动态过程;未实际验证真机或 Safari 就明确注明。前端演示通过、工程测试通过、用户阶段验收和整项目完成是不同事实。
比较前先获取真实目标分支;若本卡目标不是 main,使用实际目标引用。初次引入基准时目标没有基准文件,由独立审核核对整包来源;以后需求有变必须升版、追加新的用户授权,历史来源和变更记录只增不改。项目尚未配置自动CI合并门禁,当前由交付与审核执行本检查,不能声称平台已经强制拦截合并。
主任务 HEL-697 持续追踪完整功能,不因局部通过而归档。平台交付路由遵守当次运行指令,总管负责向白栖知汇报。
+7 -25
View File
@@ -4,35 +4,13 @@
不是聊天机器人。区别在于:**它在你不说话的时候也在运行**。
设计、施工与审核先读 [完整需求入口](docs/requirements/README.md) 和 [项目必读规则](AGENTS.md)。后续逐项确认的需求优先于本仓库早期构想;演示通过不代表整个项目完成。
---
## 先看一眼
![晚晚 · 介绍动画](docs/preview.webp)
[**▶ 打开完整动画**](docs/intro.html) — 22 秒,纯代码生成,无任何外部资源
```
docs/intro.html 单文件动画,双击即播
docs/preview.webp 渲染好的预览(800×450 / 10fps / 22s / 0.5MB)
tools/render_preview.py 重新渲染、换分辨率、导出静帧
```
动画里有一条贯穿的主线:**强调色 = 当前效价 `v`**。
情绪低落时画面整体转冷,而不是靠字幕告诉你"她现在很难过"。
| 幕 | 时间 | 讲什么 |
|---|---|---|
| 膜 | 0–6s | 物质撞在膜上弹回,信息穿过去 |
| 心跳 | 6–12s | 没有输入时它也在跳;难过恢复得慢 |
| 记忆 | 12–17s | 记得的会沉下去,但沉不到底 |
| 名字 | 17–22s | — |
四张静帧:`docs/01-membrane.png` `02-heartbeat.png` `03-memory.png` `04-title.png`
动画本身的技术取舍:时间轴是纯函数 `render(t)`,不做 RAF 累加。
所以任意一帧都能独立渲染——这也是 `render_preview.py` 能逐帧截图、
而不需要真实录屏的原因。
<video src="docs/promo.mp4" controls width="100%"></video>
---
@@ -306,3 +284,7 @@ pytest -k degradation -v # 降级机制
## 许可
私有项目,未授权。
## S1 前端演示
独立的 React / TypeScript 前端位于 `web/`,运行 `cd web && npm ci && npm run dev` 后从普通入口进入明确标识的本机演示。真实后台、语音、视觉和备份尚未接通;不自动连接模型或现有服务。安装、构建和测试命令见 [web/README.md](web/README.md),实现与后续接线见 [docs/frontend/IMPLEMENTATION.md](docs/frontend/IMPLEMENTATION.md)。
+1
View File
@@ -0,0 +1 @@
screenshots/
+48
View File
@@ -0,0 +1,48 @@
HEL-748 · S1 实现与接线说明
本轮范围是可构建的完整前端及离线演示,不部署,不实现 B1–B3。基于正式仓库 main 的 `1e9b08aed81b4ebf163721232a8d65707b1c3183` 新建任务分支;检出时工作区干净,无仓库内额外 AGENTS,无平台关联 PR。未改 Python 核心、既有测试、服务配置、SSH 信任、端口、GPU 或模型。
依据为本卡《整套设计验收与施工裁定-v1》、HEL-746 R2、HEL-738 结构与接口边界和需求 v1.6。名字、关系不预选、未分享即隐藏三项裁定覆盖旧原型。首建底色明确为示例,真实排盘待接已有 Python 编译器,不在浏览器重写八字规则。
主要目录与职责:
- `web/src/app/`:入口、首建守卫、导航、全局 UI 偏好、返回/主动/断线演示。
- `web/src/manage/`:四步首建;只读身份;关系/城市/服务/显示草稿;保存取消失败;外观标识;演示导出与同身份恢复。
- `web/src/chat/`:文字草稿、IME 与快捷键、字幕/记录、取消重试、模拟语音/观察授权及结束。
- `web/src/life/`:生活与四类回忆、双向关联、原位保留、手机详情推入、分页和深链守卫。
- `web/src/domain/`:模型、首建校验、公开视图。所有可见事件、回忆、消息、摘要统一经过 `publicView`,未知关联默认拒绝;详情也从公开视图查找。
- `web/src/adapters/`:独立可替换聊天适配器、turn_id/epoch 门槛、AbortController、本地持久化、严格 JSON 重建校验。
- `web/src/demo/`:固定场景数据,与页面和传输分离。
- `web/src/stage/`:背景空间与 `FallbackPresenter`。Presenter 只消费 SceneDesc,不写生活;参考图片失效也有文字降级,不黑屏。
- `web/src/shared/`:复用按钮、图标、字段、状态和带焦点边界的确认弹层。
- `web/src/styles/`:R2 单一 Token 和组件样式来源;没有第三方 UI 皮肤或叠加覆盖样式表。
两主题使用相同 DOM 和布局;人物拱窗、交谈展开、阅读面板与手机断点沿用获批样式。静帧仅呈现参考人物/房间,不渲染整页图片。语音/观察不访问 `getUserMedia`、不调用 ASR/TTS、不发外部请求。演示天气固定并标注模拟;当前视觉标签来自演示记录公开视图。
F1–F9 走法:
| 流程 | 入口与边界 |
|---|---|
| F1 | 无本机档案 → 四步创建 → 名字/成年生日/关系/勾选校验 → 本机持久化 → 首晚空生活 |
| F2 | 普通入口说明未接服务 → 明确演示入口;重开直接回到同一位她 |
| F3 | 演示场景 → 模拟两天后返回;只从已分享数据生成摘要,不生成后台生活 |
| F4 | 演示场景 → 主动消息;来源是已分享事件,回她定位聊天并保留来源区域 |
| F5 | 交流麦克风 → 模拟授权 → 倾听界面;文字发送推进思考/说话,可打断/结束;无真实收音 |
| F6 | 演示断线 → 暂停发送,保留草稿,结束模拟语音、取消旧轮;恢复失败可重试,不自动重发 |
| F7 | 设置城市 → 搜索/选择 → 保存成功/失败/重试;不调用天气 API |
| F8 | 本地演示素材 JSON → 结构检查/模拟对比/确认;只更新外观标识,真实媒体导入待后续 |
| F9 | 本地演示 JSON 下载/同身份校验/输入确认/先导出再恢复;服务器备份明确未接通 |
取消与错误:新轮、手动取消、切离交流会 abort 当前模拟回复;事件还需同时匹配 turn_id 与 epoch,非协作适配器的迟到消息同样被门槛拒绝。断线 resync 提升 epoch;恢复不自动续接旧轮。管理保存切区/关区会 abort;草稿保留,取消恢复上次提交值;存储损坏不进入新的首建覆盖。导入只执行 JSON.parse 后按固定 schema 重建;不执行脚本、不作为 HTML、不加载媒体/服务 URL。
后续接线清单(当前均未提供):
1. B1 同源 API 与 WS:hello/sync、游标和持久 epoch、能力声明、HTTP AbortSignal。真实模式不得直接替换成未经审定的 LLM URL。
2. 服务端授权与隐私:必须在响应前裁剪未分享记录、未知引用、回忆和消息及摘要;本轮前端过滤不构成服务器权限控制。
3. 接已有 Python 生辰预览/只读档案、真实关系更新;身份 ID、底色指纹由服务端负责。
4. B2/B3 后台生活、主动性、记忆与增长层;前端只订阅 LifeScene/事件,不从素材反编生活。
5. S2 流式文字;S3 ASR/TTS 二进制协议、session_id/seq 隔离、权限与实际中断;当前没有真实音频流,因此不声称达到延迟指标。
6. S4 生活/回忆服务分页;S5 城市/天气、素材包校验和完整服务器备份/自动备份;演示 JSON 的 MIME 和结构不是媒体真伪校验。
7. S6 视觉摘要时效与 HTTPS、真实权限;Presenter 的视频/音频/口型驱动器可注入,当前只交静帧降级。
测试部署由总工审核后执行。建议在独立目录安装构建,将 `web/dist/` 作为静态站点挂到总工选定的未占用测试端口;正常路径 `/`,演示 `/?demo=1`。本轮未占用固定服务端口,也未更改 8010/18080/18081 或现网。部署若换 origin,浏览器本地演示资料不会自动迁移,可通过同身份演示 JSON 手动恢复。
+11
View File
@@ -0,0 +1,11 @@
HEL-748: S1 完整前端与本机演示(不含后端或部署)
原仓库只有 Python 核心,没有产品前端。新增独立 `web/` React/TypeScript/Vite 工程,将获批 HEL-746 R2 的两主题、相处/交流/日子/管理和 F1–F9 演示落地为可构建组件。
创建必须输入名字并亲自选关系,刷新保留同一位档案;未分享生活及未知引用统一隐藏,包括详情、回忆、聊天和离开摘要。普通入口说明服务未接通,只有显式 `?demo=1` 进入本机演示;导入只解析校验 JSON,不执行 HTML/脚本、不请求外部媒体或服务。轮次/epoch/AbortSignal 隔离旧回复,管理草稿按分区保存或取消,恢复需同身份、输入确认并先导出。
验证:TypeScript、ESLint、Vite 生产构建、11 项领域测试、原有 Python 78 项测试;实际 Chromium 在 1440×900、1280×800、390×844 走查两主题及关键失败/恢复路径。浏览器记录与接线清单见 `docs/frontend/`。真实手机/Safari、真实服务和性能指标未测。
本轮仅 S1。没有更改 Python 核心、现网配置或服务端口,没有部署/合并,没有接入 LLM/ASR/TTS/视觉/后台循环/真实备份。Presenter 使用标识清楚的参考静帧。审查通过后由总工负责独立测试部署。
目标 base: main (`1e9b08aed81b4ebf163721232a8d65707b1c3183`)
+33
View File
@@ -0,0 +1,33 @@
HEL-748 S1 · 实际验证记录 · 2026-10-22(北京时间)
环境:Ubuntu 22.04;Node 24.16.0 / npm 11.13.0;Chromium 151.0.7922.34(Python Playwright、无头真实浏览器);Python 3.11.16 独立 `.venv`。浏览器直接加载 Vite 生产产物,测试脚本启动随机 loopback HTTP 端口并在结束时关闭。
| 检查 | 实际结果 |
|---|---|
| `npm run typecheck` | 通过 |
| `npm run lint` | 通过,无调试输出残留 |
| `npm test` | 11 项通过:隐私公开视图/未知引用、首建拦截、持久化失败与损坏、恢复身份/结构/脚本边界、turn/epoch/Abort 隔离、HTTP 标识兼容 |
| `npm run build` | 通过,41 个模块;JS 197.50KB / gzip 64.67KB,CSS 61.20KB / gzip 13.05KB |
| Python 既有测试 | `.venv/bin/python -m pytest -q`:78 项通过(Python 3.11.16);默认 Python 3.10.12 亦通过 78 项 |
| 运行时依赖检查 | `npm audit --omit=dev --registry=https://registry.npmjs.org` 返回 0 漏洞;只检查生产依赖 |
| 三尺寸主走查 | 1440×900、1280×800、390×844,两套主题;16 组结果通过,88 次本机请求,无未捕获浏览器错误、无外部服务请求 |
| 末次手机复测 | 390×844 两主题重跑,包含保存错误说明进入可见区域的断言;结果另存 `browser-results-390.json` |
| 导入与草稿专项 | 7 组通过,见 `import-results.json` |
主走查是本轮新增的真实操作脚本,未引用原型自检数字:
- 普通入口有未接服务说明,不能直接进入伪在线状态;正常入口没有演示控制台。
- 首建空名字、未成年生日、未选关系、未勾选均拦截;关系无预选;创建模拟失败资料保留,可重试;创建后刷新不再首建,名字统一使用。
- 两套主题下首页、交流、生活详情、回忆关联返回、管理表单、保存取消与关系二次确认;主题刷新仍保留。
- 未分享列表项、详情深链、回忆、聊天引用、离开摘要不可见;未知引用默认隐藏。原始本机演示数据仍含专门的隐私测试条目,展示使用过滤后的公开视图,不能把这一点当作服务端授权。
- 手动取消回复、退出交流后的旧回复不复活;发送失败重试;中文输入法 Enter 不误发,Shift+Enter 换行,Enter 发送;用户/导入的 HTML 字符串只显示文本。
- 模拟语音的结束控件在手机视口内,文字输入常驻;观察为无采集模拟;断线暂停发送、草稿保留、失败重试;城市保存失败保留修改、重试同步顶栏。
- 离开摘要经过隐私过滤;主动消息有已分享事件来源,回她定位记录。
专项覆盖:同一位档案恢复、异人阻断、输入“恢复”与恢复前下载;非法素材清单拒绝;素材 URL/服务测试不发网络请求;换外观标识不改身份;切换分区取消尚未完成的保存;保存城市不提交服务草稿;长列表 18+12 分页、聊天返回精确保留滚动位置;坏存储不自动覆盖;模拟 `randomUUID` 缺失时创建与聊天仍能完成。
本轮实际发现并修正:表单下拉框可访问标签混入选项文字;SVG 图标视口/命名空间;手机固定操作区域与内容关系;管理分区误带其他草稿提交的风险;保存反馈在手机滚动区内不够可见;局域网 HTTP 的标识生成兼容性。手机列表/详情推入需要先按返回才能操作列表,这是获批的布局行为,测试脚本已按实际用户路径操作。
截图为本轮 DOM 实际捕获,共 45 张 PNG;`screenshots/` 不进入前端构建或 Git,随任务评论附件打包交付。包含三尺寸的两主题首页/交流记录/生活详情/管理,以及首建、创建失败、发送失败、语音、断线失败、保存失败、离开返回。截图不代表动效性能实测。
未测:真实手机硬件、Safari、触屏键盘/安全区、真实麦克风/摄像头/ASR/TTS/天气/服务器记忆与备份、真实媒体/口型、后台常驻生活、FPS/延迟/长时稳定性。上述能力当前均未接线,不声称完成。
+12
View File
@@ -0,0 +1,12 @@
{
"checks": [
"390: ordinary entry, creation validation/failure/retry and reopen",
"390: both themes, home/talk/days/memory return, relation confirm/cancel/save",
"390: private deep-link guard, cancel/exit isolation, IME/Shift+Enter and XSS text",
"390: failed send/retry, voice end reachable, no-camera simulation, reconnect failure/retry, save failure/retry",
"390: privacy-filtered away summary and proactive message",
"No uncaught browser errors, external requests, microphone or camera requests"
],
"errors": [],
"requests": 36
}
+22
View File
@@ -0,0 +1,22 @@
{
"checks": [
"1440: ordinary entry, creation validation/failure/retry and reopen",
"1440: both themes, home/talk/days/memory return, relation confirm/cancel/save",
"1440: private deep-link guard, cancel/exit isolation, IME/Shift+Enter and XSS text",
"1440: failed send/retry, voice end reachable, no-camera simulation, reconnect failure/retry, save failure/retry",
"1440: privacy-filtered away summary and proactive message",
"1280: ordinary entry, creation validation/failure/retry and reopen",
"1280: both themes, home/talk/days/memory return, relation confirm/cancel/save",
"1280: private deep-link guard, cancel/exit isolation, IME/Shift+Enter and XSS text",
"1280: failed send/retry, voice end reachable, no-camera simulation, reconnect failure/retry, save failure/retry",
"1280: privacy-filtered away summary and proactive message",
"390: ordinary entry, creation validation/failure/retry and reopen",
"390: both themes, home/talk/days/memory return, relation confirm/cancel/save",
"390: private deep-link guard, cancel/exit isolation, IME/Shift+Enter and XSS text",
"390: failed send/retry, voice end reachable, no-camera simulation, reconnect failure/retry, save failure/retry",
"390: privacy-filtered away summary and proactive message",
"No uncaught browser errors, external requests, microphone or camera requests"
],
"errors": [],
"requests": 88
}
+9
View File
@@ -0,0 +1,9 @@
[
"Reject foreign identity; same-identity restore requires typed confirmation and pre-restore export",
"Invalid manifests rejected; external media never loaded; appearance confirm/cancel preserves identity; service test has no network",
"Changing section aborts outstanding save; saving city does not commit an unsaved service draft",
"Imported model text rendered as text, never as HTML",
"Long timeline paginates 18 then 12 and retains exact scroll position after chat return",
"Corrupt persistence never falls back to overwriting first creation",
"Creation and chat work when secure-context randomUUID is unavailable"
]
BIN
View File
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
# CyberGril 需求入口
当前完整基准:**v1.8**。批准需求继承办法的主卡评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
所有新任务、续办、设计、施工、审核先读完整当前需求,再读本次相关来源。只有白栖知明确修改的要求才可变更,阶段范围不能覆盖产品要求。
| 文件 | 用途 |
| --- | --- |
| [current.md](current.md) | 59条完整有效要求:原31条产品决定、13条视觉、3条补充、6条用户整改、5条执行约束、1条交付边界;另列未定项 |
| [baseline.json](baseline.json) | 同一份要求的结构化编号、边界和来源 |
| [coverage.md](coverage.md) / [coverage.json](coverage.json) | 实现与验收状态,未完成项目继续保留 |
| [changes.md](changes.md) / [changes.json](changes.json) | 后续修改的用户授权及影响 |
| [task-handoff-template.md](task-handoff-template.md) | 每张卡的必填交接项 |
| [sources/](sources/) | 原始v1.6、完整初轮和视觉访谈、后续裁定、获批设计及分批方案 |
| [source-manifest.json](source-manifest.json) | 来源文件校验值,防止历史来源被静默改写 |
| [implementation-verification.md](implementation-verification.md) | 本次实际写入、旧卡迁移与正反向验证记录 |
`sources/comment-evidence.json` 保存初版125条来源评论(保留原样)的身份、时间、原话和父评论;正文中的编号可追溯回主卡。原始初轮访谈与完整视觉访谈均用分页取齐,后续材料按关联决定保存。其余来源文档保留原样;其中旧进度、技术推荐、旧待定项不能自动成为当前事实。已经被后续明确决定覆盖的内容,见 current.md 和 changes.md。
原仓库 `docs/SPEC.md` 是初始构想,不能盖过后续逐项确认:例如旧VRM路线、允许经历改写出生核心等,均不能用于覆盖当前要求。
## 当前返工与交付边界
用户对当前前端的**对话显示效果**和**整体沉浸界面**不满意。本轮先完善需求约束,再制作独立样图及可操作动态小样;两主题、电脑/手机、长句与身份、历史展开、交流开合和空间过渡都须能查看。小样未认可前不替换正式界面。
完整产品仍需按获批批次接入生活、记忆、主动联系、语音、天气/素材/备份及有条件的视觉观察。R1已有档案与文字能力不能视为全部要求完成。嘴型研究独立,不阻塞完整界面设计。
## 检查与人工审核
先获取实际目标分支,再运行 `python3 scripts/check_requirements.py --base-ref origin/main`(实际目标不是 main 时替换引用);有交接JSON时加 `--handoff <文件>`。检查会对照目标阻止原地改需求却不升版、不追加新用户授权,且保留历史来源和变更记录。初次引入基准时由独立审核核对整包来源。此检查不证明画面好看、评论语义构成授权或用户已经满意。人工审核必须阅读原话,体验验收仍由白栖知决定。
当前没有配置仓库自动CI合并门禁;上述命令作为交付和独立审核的必做项,不声称平台已强制拦截所有修改。
v1.8新增来源见 `sources/comment-evidence-visual-role-20261022.json`,最新分工见GOV-005。
后续以该入口所记录的最新版本为准,不把v1.7永久钉死为永不可新增的文档。每次新版本必须记录用户明确裁定,保留旧版来源与覆盖关系。
+751
View File
@@ -0,0 +1,751 @@
{
"schema_version": 1,
"version": "1.8",
"approval_comment_id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"owner_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"scope": "完整要求;v1.8按用户明确指令新增视觉总监接手及必要代码职责,原58项和已批准边界继续有效,不证明功能已实现或已验收。",
"requirements": [
{
"id": "CG-001",
"category": "产品",
"topic": "需求对齐与开工边界",
"conclusion": "先逐项问清需求,形成统一记录和方案;对单项建议的同意,仅用于确认该项,不能扩大成全部功能的开工授权。",
"boundary": "",
"source_comment_ids": [
"01a15722-86fc-7c70-9409-d2e9b8cb041f",
"01a157ab-209c-7c8f-956c-40ebb3340f4e"
]
},
{
"id": "CG-002",
"category": "产品",
"topic": "写实视觉风格",
"conclusion": "人物采用照片、电影中真人的写实质感。首版日常画面采用CG-029的人物与房间成品视频,按活动、天气和时段选片;不再要求浏览器每帧用独立人物透明层与房间合成。",
"boundary": "原仓库的VRM/建模默认路线和Alina候选,不作为当前已认可的外观方向。 真人照片般的效果仍为首选;新增条件备选见CG-027,不把讨论二维视为立即放弃真人路线。 独立控制的产品目标由统一状态和对应素材组合实现;物理分层实现要求已由CG-029覆盖。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a15717-b34f-72ac-939f-d81c90f58920",
"01a15724-dcaa-7d2b-ae23-430f24ca36b9",
"01a15726-e178-72b0-9a3c-8b2041538730",
"01a158c5-a25f-742b-975d-6af1ce8ff6b6",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-003",
"category": "产品",
"topic": "动作与交谈的混合路线",
"conclusion": "预生成场景和同一人物的日常动作、服装素材;根据实际生活状态选择、衔接。交谈实时产生内容与语音,口型和局部反应需实际验证;后台可逐步补充素材。",
"boundary": "首版不承诺即时生成任意新全身动作。现场自由生成仍是用户的理想方向,属于后续增强目标。重生成任务应给交谈让出资源。 当前首版主验证路线明确为CG-029;具体交谈驱动器和真实表现仍待测试。",
"source_comment_ids": [
"01a15724-dcaa-7d2b-ae23-430f24ca36b9",
"01a15726-e178-72b0-9a3c-8b2041538730",
"01a1599b-f137-71cc-8af2-a265499b4b6e"
]
},
{
"id": "CG-004",
"category": "产品",
"topic": "交流镜头",
"conclusion": "日常可看她在环境中生活;交谈时允许自然切近到同一人物、同一房间、同一套衣服的半身或脸部。",
"boundary": "具体切镜方式和衔接效果仍要通过小样确认,不能据此认定用户接受突兀跳切。",
"source_comment_ids": [
"01a15724-dcaa-7d2b-ae23-430f24ca36b9",
"01a15726-e178-72b0-9a3c-8b2041538730"
]
},
{
"id": "CG-005",
"category": "产品",
"topic": "工程外观与自备素材",
"conclusion": "工程阶段由总管安排选择人物外观;后续支持用户按标准自行生成图片或视频,替换人物外观。该能力从首版设计时预留。",
"boundary": "具体素材包规范和导入流程尚未定稿;一张图片本身不等于已有全部动态动作。最终外观仍经过样图验收。 工程外观须遵循CG-025的新要求;素材替换能力与仅换外观、不重建伴侣的CG-006保持不变。 首轮工程素材由小队制作,不以用户先备齐视频为前提,见CG-030。",
"source_comment_ids": [
"01a1572c-18c8-7872-94b0-c05c1b24b5c7",
"01a1572d-2c55-777c-a63f-a7c74c31c070",
"01a1573f-aedf-7d08-91a7-482b2a94f782",
"01a1581e-7925-7a60-8185-615b434a4afd",
"01a1599b-f137-71cc-8af2-a265499b4b6e"
]
},
{
"id": "CG-006",
"category": "产品",
"topic": "换外观保留同一个她",
"conclusion": "换素材仅改变外观,保留原有八字底色、性格积累、记忆和关系;创建新人物与换外观是不同概念。",
"boundary": "首版随后收缩为仅一个伴侣;这里不授权实现多个伴侣或并行运行。",
"source_comment_ids": [
"01a1573f-aedf-7d08-91a7-482b2a94f782",
"01a15749-7530-71b4-9157-b9fa7011254b"
]
},
{
"id": "CG-007",
"category": "产品",
"topic": "首版仅一个伴侣",
"conclusion": "首版只能创建、使用一个伴侣,暂不做多个伴侣及切换;支持数据备份。",
"boundary": "覆盖此前已讨论并同意的多伴侣保留、切换方向;单伴侣下的清空或重新创建规则未定。",
"source_comment_ids": [
"01a15749-7530-71b4-9157-b9fa7011254b"
]
},
{
"id": "CG-008",
"category": "产品",
"topic": "后台生活与连续性",
"conclusion": "用户没有说话或关闭网页后,伴侣在服务器运行期间仍继续自己的生活,积累有记录的事件;回来后能够延续。画面、对话、记忆应对应同一份生活状态。",
"boundary": "没有承诺服务器关机仍能运行;停机后的恢复、补算规则另待设计。",
"source_comment_ids": [
"01a15724-dcaa-7d2b-ae23-430f24ca36b9",
"01a15726-e178-72b0-9a3c-8b2041538730",
"01a15749-7530-71b4-9157-b9fa7011254b",
"01a15751-ded1-7328-80ae-39967d2b3821"
]
},
{
"id": "CG-009",
"category": "产品",
"topic": "备份与恢复",
"conclusion": "每天自动备份;支持随时手动完整导出到自己的电脑,并从备份恢复同一位伴侣。覆盖八字、性格资料、对话、记忆、关系、生活进度;手动完整导出带上所用人物、场景素材及恢复所需设定。",
"boundary": "自动副本留在服务器,首版暂不增加云盘接入;保留份数、空间策略、恢复校验等工程细节尚未定稿。",
"source_comment_ids": [
"01a15749-fc88-7295-8350-dccfb0168b70",
"01a1574f-9e0e-7bf4-b158-3f88a4c1988a"
]
},
{
"id": "CG-010",
"category": "产品",
"topic": "连续语音与文字",
"conclusion": "用户主动开启语音后自然连续交谈,支持开口打断她;结束语音后停止收音,同时保留文字聊天。",
"boundary": "响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。",
"source_comment_ids": [
"01a15750-2cf9-7f23-9269-0f150d2c5e61",
"01a15750-e061-733b-9d80-38459d306b97"
]
},
{
"id": "CG-011",
"category": "产品",
"topic": "主动联系范围",
"conclusion": "首版只在网页内主动联系:语音开启时可以主动开口,未开启时留文字消息;关闭网页后保留消息,回来再看。主动分享应有自己的生活经历作为依据。",
"boundary": "首版不接手机推送或其他聊天软件;具体频率可在方案中结合原仓库参数制定。",
"source_comment_ids": [
"01a15751-670e-7187-b94a-cea70154df3b",
"01a15751-ded1-7328-80ae-39967d2b3821"
]
},
{
"id": "CG-012",
"category": "产品",
"topic": "生辰创建方式",
"conclusion": "使用她自己的出生年月日和时辰;创建时可自动生成,也允许用户在确认前手动指定。确认后作为人格底色依据,换外观不会改变。",
"boundary": "",
"source_comment_ids": [
"01a15752-4cfe-7cd7-a4a3-8a8f8143f943",
"01a15754-b879-73b6-b367-b105898d7107"
]
},
{
"id": "CG-013",
"category": "产品",
"topic": "八字底色不可编辑",
"conclusion": "八字带来的性格底色是系统级资料,创建后只读;每轮重新提供给模型并预留上下文空间。可用结构化指标配合简短解释表达,关系提示词不得覆盖底色。",
"boundary": "程序保证资料不被改写;模型能否长期遵守仍需验证,不能把写成数字或系统提示词等同于已解决漂移。",
"source_comment_ids": [
"01a15771-3a93-7518-9b23-489509520d77",
"01a15772-3101-7d20-bf1d-a3b3092b6535",
"01a15774-ddc6-7039-9d31-991c02890d5f"
]
},
{
"id": "CG-014",
"category": "产品",
"topic": "性格诠释空间",
"conclusion": "性格描述保留适度抽象和解释空间,让模型形成有延续性的个性化诠释;同样底色允许不同表现与成长,不给每次回答规定唯一标准答案。",
"boundary": "不能将本项误记成‘用户可修改八字底色’,也不能将随机、无缘由地反复变换人设当作个性。",
"source_comment_ids": [
"01a15763-1fc5-7f36-bf43-722e8755703c",
"01a15763-e729-7aa2-a752-7b95fb69372c",
"01a15771-3a93-7518-9b23-489509520d77",
"01a15774-ddc6-7039-9d31-991c02890d5f"
]
},
{
"id": "CG-015",
"category": "产品",
"topic": "初始习惯与后天成长",
"conclusion": "创建时带少量模型自主形成的喜好和小习惯;以后通过生活、相处逐渐丰富。成长经历单独记录,保留最初的八字底色。",
"boundary": "原仓库重大经历可微调核心的旧例外,按此讨论改为另存成长层,不据旧文直接修改出生底色。",
"source_comment_ids": [
"01a15763-e729-7aa2-a752-7b95fb69372c",
"01a15765-2fa8-7494-b0b8-7a0054500556",
"01a15772-3101-7d20-bf1d-a3b3092b6535",
"01a15774-ddc6-7039-9d31-991c02890d5f"
]
},
{
"id": "CG-016",
"category": "产品",
"topic": "关系可配置、可调整",
"conclusion": "朋友或恋人、称呼、相处边界等关系设定通过独立提示词表达,相处过程中也可明确修改;保留原有记忆和互动形成的信任,不改写过去。",
"boundary": "没有确定默认必须初识或必须情侣;通用人物提示词编辑器并未获确认,允许修改的范围是关系设定。",
"source_comment_ids": [
"01a1576a-5ffe-73fb-b6f1-2aeee98a5782",
"01a15771-3a93-7518-9b23-489509520d77",
"01a15772-3101-7d20-bf1d-a3b3092b6535",
"01a15774-ddc6-7039-9d31-991c02890d5f"
]
},
{
"id": "CG-017",
"category": "产品",
"topic": "首版场景范围",
"conclusion": "先集中做好一个高层卧室,以成品视频表现对应天气、昼夜、光线以及少量服装和日常动作;由统一状态按条件选片,保留后续扩展入口。",
"boundary": "其他房间和外出场景后续扩展;素材数量先结合试片结果定,不将早期示例数量作为现行承诺。 当前唯一房间明确为CG-026所述高层卧室。 浏览器人物/环境物理分层要求由CG-029覆盖;活动、天气等逻辑状态仍独立管理,不用选片篡改生活记录。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a15775-89f4-7ec8-89eb-b3554fb05d19",
"01a1577a-e852-7551-ace8-0b2028db9ebb",
"01a1581e-7925-7a60-8185-615b434a4afd",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-018",
"category": "产品",
"topic": "电脑优先、兼顾手机",
"conclusion": "主要在电脑使用,优先大画面沉浸体验;适配手机看画面、文字聊天和连续语音,使用同一伴侣和同一份记忆。",
"boundary": "",
"source_comment_ids": [
"01a1577b-b65c-787a-b436-b0a74c38fea4",
"01a1577c-d10f-7513-8f58-62b3b387240a"
]
},
{
"id": "CG-019",
"category": "产品",
"topic": "素材工具与新增费用",
"conclusion": "首版先使用已有工具和本地机器制作图片、视频素材,暂不新增付费云端生成接口;若实测不够,再列明方案效果和费用,由用户决定。",
"boundary": "此项是素材制作的费用边界,不能误解为禁止使用已提供的外接LLM或用户自行部署的小模型服务。",
"source_comment_ids": [
"01a1577d-7203-7b2b-bf24-f880f56750cf",
"01a1577e-96f6-72c7-b66b-6c43779fc5f0"
]
},
{
"id": "CG-020",
"category": "产品",
"topic": "首版内网访问",
"conclusion": "首版在部署服务器所在内网使用,电脑和手机在该内网访问。",
"boundary": "外出访问后续扩展。摄像头和麦克风所需可信访问方式仍要在工程方案中落实。",
"source_comment_ids": [
"01a1577f-0707-7ac6-9c0f-aef2e73ba108",
"01a15780-0fd0-7438-beee-4947f4c3c928"
]
},
{
"id": "CG-021",
"category": "产品",
"topic": "按任务选模型、视觉外接",
"conclusion": "该用小模型的地方用小模型;状态和时间推进用程序,记忆等后台任务可用小模型,主模型负责主对话和较复杂理解。摄像头用小视觉模型前处理,部署由用户在其他设备负责,项目通过接口接入;用户另有4080显卡。",
"boundary": "平时把带时间和不确定项的观察摘要交给主模型,必要时保留原图复核途径。具体小模型、部署参数、接口地址和性能尚未定版。",
"source_comment_ids": [
"01a157a5-1668-723d-af23-732a612b30b9",
"01a157a6-b158-7ba5-8a3a-1d19920890ac",
"01a157a7-c9f2-7133-8340-9b497f12f7e7"
]
},
{
"id": "CG-022",
"category": "产品",
"topic": "首版摄像头范围",
"conclusion": "加入可开关的视觉观察,先聚焦人在不在、明显物品、房间环境变化;快速挥手、点头等连续动作放到后续。",
"boundary": "观察有时效,不能把旧画面继续当作眼前事实。刷新间隔和延迟以小视觉服务实测为准,5—10秒只是先前建议,不是固定性能保证。",
"source_comment_ids": [
"01a157a6-b158-7ba5-8a3a-1d19920890ac",
"01a157a7-c9f2-7133-8340-9b497f12f7e7"
]
},
{
"id": "CG-023",
"category": "产品",
"topic": "天气、昼夜与作息",
"conclusion": "用户手动选择一个真实城市,房间天气、昼夜和生活作息跟随当地节奏;城市可自行更换,无需每次打开重新定位。",
"boundary": "具体城市、天气来源及跨城市切换细节仍待设置或工程设计。",
"source_comment_ids": [
"01a157a8-510f-7d3d-9880-670dc9ae4d22",
"01a157a8-aeab-7611-9695-74afbc2dec02"
]
},
{
"id": "CG-024",
"category": "产品",
"topic": "第一道验收及后续顺序",
"conclusion": "先确认人物与房间的写实样图,再验收写实动态小样;验证环境与人物独立变化、同一人物连续性、动作循环衔接、半身/近景交谈与口型语音。用户认可后,再逐步接完整功能,并分别验收。",
"boundary": "小样通过仅表示该阶段通过,不等于记忆、后台生活、摄像头或备份已全部实现和通过验收。 用户本次确认v2静态方向并授权继续最小动态验证;不等于动态或完整功能已通过,也不扩大为全量施工及任意模型安装授权。 本次同意CG-029/030后进入已确认方案的关键技术验证;旧小样通过不等于新实时交谈、衔接或完整功能通过。 CG-031明确完整前端设计不以嘴型技术验证通过为前置条件;设计样图、交互原型与真实功能接通须分别标明,不能把设计推进等同于全部后台施工获准。",
"source_comment_ids": [
"01a157a9-bd18-7533-9b21-e5f4c3a90d7c",
"01a157ab-209c-7c8f-956c-40ebb3340f4e",
"01a1582c-7386-7515-bf62-89dfadfea3d3",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-025",
"category": "产品",
"topic": "当前工程人物外观",
"conclusion": "成年女性,年龄观感在20岁左右,冷白皮,圆鹅蛋脸,中短发,气质清纯干净。用户指出上一版人物太老、太路人,要求重新设计。",
"boundary": "用户已于2026年10月20日15:17(北京时间)认可v2人物静态方向。CG-002的写实风格、CG-006外观与伴侣身份分离保持;不代表分层和跨视频身份连续性已验证。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a1581e-7925-7a60-8185-615b434a4afd",
"01a1582c-7386-7515-bf62-89dfadfea3d3",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-026",
"category": "产品",
"topic": "当前房间设定",
"conclusion": "高层卧室,设置慵懒沙发和女性化装饰元素,整体温暖温馨。",
"boundary": "用户已认可v2高层卧室静态方向,四张v2图为当前视觉基准。CG-017的一间房、人物与环境独立、天气昼夜变化范围保持;分层几何、光照切换效果仍需实测。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a1581e-7925-7a60-8185-615b434a4afd",
"01a1582c-7386-7515-bf62-89dfadfea3d3",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-027",
"category": "产品",
"topic": "真人优先与二维备用",
"conclusion": "优先争取真人照片般的效果;如果真人路线无法达到所需效果、损害沉浸感,再考虑二维动画。",
"boundary": "真人优先、二维为条件备用保持。首版具体主验证路线现由CG-029确认;交谈身体范围按CG-028。真人转二维的具体判据和二维风格尚未定稿,不自动切换。",
"source_comment_ids": [
"01a158c5-a25f-742b-975d-6af1ce8ff6b6",
"01a1599b-f137-71cc-8af2-a265499b4b6e"
]
},
{
"id": "CG-028",
"category": "产品",
"topic": "首版交谈的实时表现与预设身体动作",
"conclusion": "首版优先让口型、表情和目光自然;身体主要使用少数预设动作,不要求肩膀、手势根据每句话实时生成新的变化。",
"boundary": "这是首版范围选择,不是禁止预设肩部/手部动作或要求人物身体完全静止。具体模型能支持哪些表情、目光和动作仍待实测;没有测试证明现有硬件绝对无法实时驱动肩部或手势。此项同意不代表选定成品视频架构、具体模型、安装、付费API或新施工。 后续用户另于01a1599b-f137-71cc-8af2-a265499b4b6e同意CG-029/030方案,不把这两次确认混成同一授权。",
"source_comment_ids": [
"01a158c7-f16f-7b98-844e-77271f16d6d8",
"01a158d7-1d0c-764c-8a4c-35359b4f5d8a",
"01a1599b-f137-71cc-8af2-a265499b4b6e"
]
},
{
"id": "CG-029",
"category": "产品",
"topic": "成品日常视频与本地实时交谈主路线",
"conclusion": "首版优先验证:人物与房间一起制作成品日常视频,按真实时间、天气及她的实际活动选片;交流时自然转入同一人物的实时半身交谈画面,结束后回到日常活动。人格、记忆、关系与后台生活由本地核心系统管理。",
"boundary": "替代首版浏览器每帧独立人物抠像合成的实施要求;天气、服装或活动变化需要对应视频与自然过渡,保持同脸、服装和光线连续。只确认架构方向,LiveTalking/MuseTalk/Ditto等具体驱动器仍需实际选型;不承诺任意新身体动作,不默认采用云端收费服务,也不代表全部功能已完成。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a158d9-2f3a-72f2-a911-5f58adf074c7",
"01a1597e-ef9e-7cfa-9456-1a0d4e248430",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-030",
"category": "产品",
"topic": "首批视频制作与后续用户自备素材",
"conclusion": "首轮工程素材由总管安排小队使用现有本地资源制作,以已确认的人物与高层卧室为基准;用户不需要先自行准备整套视频。后续提供模板、制作规范与导入检查,用户可自行生成替换外观。",
"boundary": "先验证少量日常、过渡和交谈素材的完整流程,再扩充服装/天气/动作;具体数量和格式待测试。生成有筛选、重试和处理成本,不承诺一张照片自动得到全套合格素材;换素材不改变八字、记忆和关系。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。",
"source_comment_ids": [
"01a1597d-f505-7481-a9ee-62e025fbfa2f",
"01a1597e-ef9e-7cfa-9456-1a0d4e248430",
"01a1599b-f137-71cc-8af2-a265499b4b6e",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "CG-031",
"category": "产品",
"topic": "完整前端以产品功能和使用流程为设计依据,人物与卧室样图只作视觉参考",
"conclusion": "完整前端应覆盖四类要素:相处主界面、交流界面、她的生活与共同回忆、创建与管理。设计需建立在对整个项目的充分理解之上;此前认可的人物和高层卧室图片只属于人物/场景/口型等视觉子模块的参考,不能作为整体前端的固定基座,不能据此先铺满场景再往上叠界面。",
"boundary": "完整界面的布局、导航、内容层级、交互与视觉语言应围绕产品使用流程独立设计,不能被旧样图构图锁定,也不把成品视频策略扩张为所有界面必须全屏视频。嘴型同步作为独立技术研究,不作为完整前端设计开工的前置验收。此项确认设计依据与范围,不代表已认可任何完整页面稿,不授权直接实现所有后台功能或自动改换模型;此前素材仍可作参考,不能误记成需要删除旧素材或推翻全部既有需求。",
"source_comment_ids": [
"01a15e8d-91ab-739e-84f9-fc4026bfcd70",
"01a15e92-e689-754a-aad3-4566100962b3"
]
},
{
"id": "VIS-001",
"category": "视觉",
"topic": "两人的数字空间",
"conclusion": "整体像只属于两人的数字空间,人物、交流、回忆融在其中。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-002",
"category": "视觉",
"topic": "整体气质",
"conclusion": "静谧梦幻、安静亲密,有空间深度和柔和微光。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-003",
"category": "视觉",
"topic": "多主题的一致结构",
"conclusion": "切换主题同时改变配色、背景、光影、材质和动效;页面结构与操作位置一致。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-004",
"category": "视觉",
"topic": "两套首轮主题",
"conclusion": "靛蓝月光与蓝紫雾光两套都保留;不把原型临时默认主题当成用户指定默认。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-005",
"category": "视觉",
"topic": "空间优先的首页",
"conclusion": "首页只留少量当前状态与入口,其余内容按需展开,完整功能仍保留。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-006",
"category": "视觉",
"topic": "背景层次",
"conclusion": "光雾、景深与若隐若现的景物融合;具体画面通过样图验证。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-007",
"category": "视觉",
"topic": "空间动效",
"conclusion": "平时舒缓流动与景物微动;操作时有明显景深变化和空间过渡。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-008",
"category": "视觉",
"topic": "透光浮层",
"conclusion": "内容采用透光浮层,背后保留空间光影,文字清晰可读。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-009",
"category": "视觉",
"topic": "轻盈文字",
"conclusion": "现代轻盈、字形简洁、标题克制,正文易读。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-010",
"category": "视觉",
"topic": "微光强调",
"conclusion": "少量微光集中于关键操作和选中状态,两主题各有强调色。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-011",
"category": "视觉",
"topic": "人物独立视窗",
"conclusion": "人物平时作为空间中独立视窗;交流时自然放大。已认可的人物位置、分量与首页文字分量沿用。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-012",
"category": "视觉",
"topic": "悬浮导航",
"conclusion": "底部小巧透光导航,图标配简短文字,选中亮起微光。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "VIS-013",
"category": "视觉",
"topic": "交流呈现",
"conclusion": "交流展开后以人物为主,只呈现当前一两句对话;完整记录按需展开,文字输入和语音操作均保留。",
"boundary": "具体实现不得改变已确认体验;现有界面不等于用户最终满意。",
"source_comment_ids": [
"01a15f09-db1e-705c-8baf-cde63c7651fb",
"01a15f1f-5a71-783c-a458-b326a0073d69"
],
"source_document": "sources/visual-confirmation-v2.md"
},
{
"id": "AM-001",
"category": "补充裁定",
"topic": "未分享生活不可见",
"conclusion": "等她讲过才显示,覆盖列表、详情、摘要、关联回忆与消息引用;未知分享状态不展示,不能用链接或搜索绕开。真实接入必须在服务器响应前裁剪,前端过滤不能冒充权限控制。",
"boundary": "其余已确认要求继续有效。",
"source_comment_ids": [
"01a15f42-2781-700e-b75a-3ae4b023f46c"
]
},
{
"id": "AM-002",
"category": "补充裁定",
"topic": "关系由用户选择",
"conclusion": "创建时由用户选择关系,不预选、不替用户决定;以后改变关系保留记忆与信任,底色不变。覆盖旧记录 T-02 中关系初始值仍待定的部分。",
"boundary": "其余已确认要求继续有效。",
"source_comment_ids": [
"01a15f42-2781-700e-b75a-3ae4b023f46c"
]
},
{
"id": "AM-003",
"category": "补充裁定",
"topic": "名字由用户起",
"conclusion": "首次创建由用户提供有效名字,主页、交流、首建与设置统一;参考名不能硬编码成真实身份,不额外新增改名流程。",
"boundary": "其余已确认要求继续有效。",
"source_comment_ids": [
"01a15f42-2781-700e-b75a-3ae4b023f46c"
]
},
{
"id": "UX-001",
"category": "用户整改",
"topic": "手机对话可用",
"conclusion": "手机对话不被人物、导航、底部区域遮挡,长句和键盘弹出时仍能阅读与输入;等效视口测试不能冒充真机验证。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-002",
"category": "用户整改",
"topic": "对话文字动效",
"conclusion": "文字有柔和的进入和退场,流式更新不重复闪烁;动态效果必须实际演示,不以 CSS 存在代替验收。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-003",
"category": "用户整改",
"topic": "滚动条主题一致",
"conclusion": "记录、详情、管理、首建等滚动区域与两套主题协调,保留清晰可操作性。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-004",
"category": "用户整改",
"topic": "出生资料与底色深度",
"conclusion": "创建与资料显示公历、农历、时辰及四柱说明;性格不能只用话多、直接、焦虑等浅标签概括。深度不能变成固定模板人设,仍须遵守 CG-013/014/015 的诠释自由、初始个性、成长分离。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-005",
"category": "用户整改",
"topic": "交流身份清楚",
"conclusion": "用户与实际起名的伴侣身份明确,当前对话与完整记录一致,不只依靠位置或颜色猜。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "UX-006",
"category": "用户整改",
"topic": "完整界面功能保留",
"conclusion": "按已认可的完整前端设计补齐功能和页面结构,未接通能力说明真实边界,不删成空壳也不伪造已运行。",
"boundary": "用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。",
"source_comment_ids": [
"01a162eb-1eab-7dad-8eff-66e0954f5e0b",
"01a16353-c93a-7745-8efa-2586f0ea3eee"
]
},
{
"id": "GOV-001",
"category": "执行约束",
"topic": "完整需求跨任务继承",
"conclusion": "所有新卡、续办卡、设计卡、施工卡、审核卡从同一完整需求入口开始,不依赖会话记忆或最近一条反馈;现有未完成卡补齐对照项。",
"boundary": "不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"source_document": "sources/governance-proposal-approved.md"
},
{
"id": "GOV-002",
"category": "执行约束",
"topic": "只有用户能改需求",
"conclusion": "只有白栖知明确的新决定可以覆盖原要求,保留原话来源、旧结论、新结论及影响;不能把技术困难、阶段减项、智能体建议或演示通过当成需求修改。",
"boundary": "不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"source_document": "sources/governance-proposal-approved.md"
},
{
"id": "GOV-003",
"category": "执行约束",
"topic": "对照和验收",
"conclusion": "每卡列基准版本及提交位置、关联要求编号、交付验收、未完成承接、需求变更和验证证据;自动检查缺项,人工核对含义,用户验收体验。",
"boundary": "不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"source_document": "sources/governance-proposal-approved.md"
},
{
"id": "GOV-004",
"category": "执行约束",
"topic": "本轮返工范围",
"conclusion": "先落实需求继承约束,再制作对话显示和整体沉浸界面的电脑/手机样图与可操作动态小样;小样认可前不替换正式界面,不合并当前产品 PR,不改现有伴侣和聊天数据,不切换队员全局模型。",
"boundary": "不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。",
"source_comment_ids": [
"01a1635b-b47c-79ed-ac66-9408c7905af9"
],
"source_document": "sources/governance-proposal-approved.md"
},
{
"id": "DEL-001",
"category": "交付批次",
"topic": "演示与真实接入边界",
"conclusion": "前端演示通过只代表该阶段通过;主卡保留追踪完整真实功能,后续接入按已批准 R1–R7 依赖顺序推进。阶段和技术方案不能降低完整需求。",
"boundary": "接入方案中的候选服务、声音、摄像头和硬件操作仍须满足各自前置与授权,不能从批次批准推断全部候选已经批准。",
"source_comment_ids": [
"01a161a4-842f-7c26-9be0-1812b720976a",
"01a1629c-732f-7559-8417-6f2ea05c7151"
],
"source_document": "sources/integration-plan-v1.md"
},
{
"id": "GOV-005",
"category": "执行分工",
"topic": "视觉总监接手整体视觉及必要代码",
"conclusion": "CyberGril整体视觉由视觉总监接手设计与实际落地,职责不止审核把关;施工员无法写出对应代码时,直接由视觉总监编写。本轮对话与沉浸界面交由视觉总监亲自负责。",
"boundary": "总管负责协调、完整需求核对与交付;不再用总管制作替代视觉总监接手。分工限本项目,不修改队员其他项目的全局模型、职责或认证;原产品需求与小样先验收再替换正式界面的边界继续有效。",
"source_comment_ids": [
"01a163a5-e2a7-7ac8-afdb-991868e721bc"
]
}
],
"open_items": [
{
"id": "T-01",
"topic": "八字映射细则",
"detail": "具体规则表、权重、命理解释覆盖深度与验证样例尚未定稿。用户没有直接确认‘只保留明确量化项、排除所有抽象描述’;后续已明确保留性格诠释空间。"
},
{
"id": "T-03",
"topic": "素材包标准与具体数量",
"detail": "导入模板、尺寸、帧率、背景处理、动作节点、口型预处理、预览与回退方式等需结合方案、小样定稿。未承诺固定服装/动作数量或一张参考图自动生成整套合格视频。"
},
{
"id": "T-04",
"topic": "模型与速度",
"detail": "Qwen3-VL-4B、2B和此前提到的视频/口型方案都是候选;4080部署、真实摄像头、语音与人物同时运行尚未实测。既有接口单图测试的1.5秒/7秒不代表4080速度或端到端语音延迟。"
},
{
"id": "T-05",
"topic": "外接服务接入",
"detail": "4080视觉服务地址、具体模型、输入输出能力,以及现有ASR/TTS的流式和打断能力,需后续核实或由用户提供。部署视觉小模型由用户负责。"
},
{
"id": "T-06",
"topic": "运行与恢复细节",
"detail": "单伴侣重新创建/清空、服务器停机后的状态恢复、备份保留份数和空间策略尚未定稿;不能用多伴侣旧结论补全。"
},
{
"id": "T-07",
"topic": "天气和访问细节",
"detail": "具体城市、天气数据来源、缺失天气素材时的呈现方式、内网可信HTTPS方案等留待工程设计。"
},
{
"id": "T-08",
"topic": "实现完成情况",
"detail": "本记录是需求确认台账,不证明功能已实现。旧演示的部署和测试结果不能作为新写实动态路线的验收结果。"
},
{
"id": "T-09",
"topic": "真人呈现路线与交谈动态边界",
"detail": "CG-029已选定成品日常视频配本地实时半身交谈,CG-030确定首批由小队制作。待验证具体驱动器、同脸/服装/光线连续、音频/口型/头部表情实际能力、打断、日常与交谈往返、素材制作成本及本机并发/恢复;真人转二维的条件仍待明确。云端收费服务未选定。"
},
{
"id": "T-02",
"topic": "关系界面余项",
"detail": "初始关系由用户选择已由 AM-002 确定,旧待定结论被覆盖;自由提示词不能被用于修改出生底色。"
},
{
"id": "T-10",
"topic": "当前一两句的长回复呈现",
"detail": "分句、停留和前后查看细节在独立动态小样中验证,不冒充用户已选具体方案。"
}
],
"precedence": "用户明确的新决定覆盖旧决定并记录来源;完整当前记录优先于任务摘录、技术方案和旧仓库 SPEC。无明确用户裁定的冲突先查来源,不擅自取舍。"
}
+82
View File
@@ -0,0 +1,82 @@
{
"entries": [
{
"version": "1.7",
"approval_comment_id": "01a1635b-b47c-79ed-ac66-9408c7905af9",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"previous_record": "sources/product-decisions-v1.6.json",
"summary": "整合原31项、已确认视觉与后续裁定,增加跨卡继承约束;只落实用户同意的方案,不把实现现状写成需求。",
"affected_ids": [
"AM-001",
"AM-002",
"AM-003",
"CG-001",
"CG-002",
"CG-003",
"CG-004",
"CG-005",
"CG-006",
"CG-007",
"CG-008",
"CG-009",
"CG-010",
"CG-011",
"CG-012",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-017",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"CG-022",
"CG-023",
"CG-024",
"CG-025",
"CG-026",
"CG-027",
"CG-028",
"CG-029",
"CG-030",
"CG-031",
"DEL-001",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"UX-001",
"UX-002",
"UX-003",
"UX-004",
"UX-005",
"UX-006",
"VIS-001",
"VIS-002",
"VIS-003",
"VIS-004",
"VIS-005",
"VIS-006",
"VIS-007",
"VIS-008",
"VIS-009",
"VIS-010",
"VIS-011",
"VIS-012",
"VIS-013"
],
"impact": "约束新旧卡及独立界面小样;不改产品数据、模型与现有服务。"
},
{
"version": "1.8",
"approval_comment_id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"summary": "新增GOV-005:整体视觉交给视觉总监接手,不止把关;施工员不能实现时由视觉总监直接写代码。",
"affected_ids": [
"GOV-005"
],
"impact": "本项目本轮视觉设计、动效与相应代码责任转给视觉总监;不改现网、数据、全局模型或认证。"
}
]
}
+13
View File
@@ -0,0 +1,13 @@
# 需求变更记录
v1.7:把原始31项、已确认视觉和后续用户裁定整理到同一入口,新增白栖知批准的跨任务继承办法。批准评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`;所批方案原文在 `sources/governance-proposal-approved.md`。
原始 v1.6 原样保留。后续生活披露、初始关系和名字的覆盖见 current.md 的 AM-001–003 及对应来源,不回写历史。旧文档中的历史进度不作为现在的事实。
以后每次改需求,记录:原要求、新要求、白栖知明确原话及评论编号、涉及编号、影响范围。智能体建议、实现困难、阶段通过均不能替代用户授权。检查脚本只能检查材料完整性,审核者仍必须核对原话。
## v1.8:视觉总监接手
白栖知明确裁定:整体视觉交给视觉总监接手,而不是把关,甚至若施工员无法写出相应代码,直接让视觉总监写代码
来源评论:`01a163a5-e2a7-7ac8-afdb-991868e721bc`。旧分工为总管因环境失败接手制作、后续视觉总监只把关;现按GOV-005交视觉总监亲自设计与落地,必要时直接写代码。本次不改变产品功能与原验收边界。
+363
View File
@@ -0,0 +1,363 @@
{
"baseline_version": "1.8",
"items": [
{
"requirement_id": "CG-001",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-002",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-003",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-004",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-005",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-006",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-007",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-008",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-009",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-010",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-011",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-012",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-013",
"status": "现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-014",
"status": "现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-015",
"status": "现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-016",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-017",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-018",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-019",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-020",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-021",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-022",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-023",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-024",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-025",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-026",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-027",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-028",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-029",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-030",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "CG-031",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "VIS-001",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-002",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-003",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-004",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-005",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-006",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-007",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-008",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-009",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-010",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-011",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-012",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "VIS-013",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "AM-001",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "AM-002",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "AM-003",
"status": "R1 已有相关实现与测试交付,尚不能认定整项已由用户验收",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "UX-001",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "UX-002",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "UX-003",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "UX-004",
"status": "现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "UX-005",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "UX-006",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "GOV-001",
"status": "本版本已写入规则和检查;主分支合入及后续实际执行仍需核验",
"evidence": [],
"next_step": "独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行"
},
{
"requirement_id": "GOV-002",
"status": "本版本已写入规则和检查;主分支合入及后续实际执行仍需核验",
"evidence": [],
"next_step": "独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行"
},
{
"requirement_id": "GOV-003",
"status": "本版本已写入规则和检查;主分支合入及后续实际执行仍需核验",
"evidence": [],
"next_step": "独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行"
},
{
"requirement_id": "GOV-004",
"status": "用户不满意当前对话与整体沉浸体验,独立小样待制作及验收",
"evidence": [],
"next_step": "本轮批准的界面动态小样"
},
{
"requirement_id": "DEL-001",
"status": "未完成或未整体验收;不得据旧样机或测试数字声称已满足",
"evidence": [],
"next_step": "按完整需求与接入批次落实,交付时补真实证据"
},
{
"requirement_id": "GOV-005",
"status": "用户恢复原运行时后,视觉总监已在HEL-793真实运行;设计与代码由其亲自负责",
"evidence": [
"01a163a5-e2a7-7ac8-afdb-991868e721bc",
"01a163be-0bbc-7a74-8dc1-a3bcee89cc58",
"running:01a163bc-9134-75e4-aa28-71c6371ff692"
],
"next_step": "视觉总监交实际样图和动态代码;总管按完整需求核对后交用户验收,保持用户设定的运行时"
}
]
}
+67
View File
@@ -0,0 +1,67 @@
# 完整需求对照表
此表不使用“代码存在”代替用户验收;当前所有未闭合要求继续有效。
| 要求 | 当前状态 | 后续承接 |
| --- | --- | --- |
| CG-001 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-002 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-003 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-004 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-005 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-006 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-007 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-008 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-009 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-010 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-011 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-012 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-013 | 现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-014 | 现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-015 | 现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-016 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-017 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-018 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-019 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-020 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-021 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-022 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-023 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-024 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-025 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-026 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-027 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-028 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-029 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-030 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| CG-031 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| VIS-001 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-002 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-003 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-004 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-005 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-006 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-007 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-008 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-009 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-010 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-011 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-012 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| VIS-013 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| AM-001 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| AM-002 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| AM-003 | R1 已有相关实现与测试交付,尚不能认定整项已由用户验收 | 按完整需求与接入批次落实,交付时补真实证据 |
| UX-001 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| UX-002 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| UX-003 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| UX-004 | 现有 R1 底色解释被指出偏离完整意图;不可用固定六段模板代替模型诠释与成长 | 按完整需求与接入批次落实,交付时补真实证据 |
| UX-005 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| UX-006 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| GOV-001 | 本版本已写入规则和检查;主分支合入及后续实际执行仍需核验 | 独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行 |
| GOV-002 | 本版本已写入规则和检查;主分支合入及后续实际执行仍需核验 | 独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行 |
| GOV-003 | 本版本已写入规则和检查;主分支合入及后续实际执行仍需核验 | 独立审核文档、检查脚本与旧卡迁移;后续卡逐张执行 |
| GOV-004 | 用户不满意当前对话与整体沉浸体验,独立小样待制作及验收 | 本轮批准的界面动态小样 |
| DEL-001 | 未完成或未整体验收;不得据旧样机或测试数字声称已满足 | 按完整需求与接入批次落实,交付时补真实证据 |
| GOV-005 | 用户恢复原运行时,视觉总监已在HEL-793真实运行 | 由其交实际设计与代码,保持用户配置 |
详细证据位于 `coverage.json`;空证据表示尚无本基准下整项合格证据,不表示需求取消。
+562
View File
@@ -0,0 +1,562 @@
# CyberGril 完整当前需求 · v1.8
完整要求;版本1.8保留原完整要求并新增用户明确的视觉总监接手分工,不证明已实现或已验收。
## 阅读与效力
用户明确的新决定覆盖旧决定并记录来源;完整当前记录优先于任务摘录、技术方案和旧仓库 SPEC。无明确用户裁定的冲突先查来源,不擅自取舍。
已确认的产品、视觉与补充要求全文如下;实现情况见 `coverage.md`,未定事项不视为已确认。旧方案的阶段状态、技术推荐和旧样机结论仅作历史证据。
## CG-001 · 需求对齐与开工边界
先逐项问清需求,形成统一记录和方案;对单项建议的同意,仅用于确认该项,不能扩大成全部功能的开工授权。
来源评论:`01a15722-86fc-7c70-9409-d2e9b8cb041f`、`01a157ab-209c-7c8f-956c-40ebb3340f4e`。
## CG-002 · 写实视觉风格
人物采用照片、电影中真人的写实质感。首版日常画面采用CG-029的人物与房间成品视频,按活动、天气和时段选片;不再要求浏览器每帧用独立人物透明层与房间合成。
边界:原仓库的VRM/建模默认路线和Alina候选,不作为当前已认可的外观方向。 真人照片般的效果仍为首选;新增条件备选见CG-027,不把讨论二维视为立即放弃真人路线。 独立控制的产品目标由统一状态和对应素材组合实现;物理分层实现要求已由CG-029覆盖。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a15717-b34f-72ac-939f-d81c90f58920`、`01a15724-dcaa-7d2b-ae23-430f24ca36b9`、`01a15726-e178-72b0-9a3c-8b2041538730`、`01a158c5-a25f-742b-975d-6af1ce8ff6b6`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-003 · 动作与交谈的混合路线
预生成场景和同一人物的日常动作、服装素材;根据实际生活状态选择、衔接。交谈实时产生内容与语音,口型和局部反应需实际验证;后台可逐步补充素材。
边界:首版不承诺即时生成任意新全身动作。现场自由生成仍是用户的理想方向,属于后续增强目标。重生成任务应给交谈让出资源。 当前首版主验证路线明确为CG-029;具体交谈驱动器和真实表现仍待测试。
来源评论:`01a15724-dcaa-7d2b-ae23-430f24ca36b9`、`01a15726-e178-72b0-9a3c-8b2041538730`、`01a1599b-f137-71cc-8af2-a265499b4b6e`。
## CG-004 · 交流镜头
日常可看她在环境中生活;交谈时允许自然切近到同一人物、同一房间、同一套衣服的半身或脸部。
边界:具体切镜方式和衔接效果仍要通过小样确认,不能据此认定用户接受突兀跳切。
来源评论:`01a15724-dcaa-7d2b-ae23-430f24ca36b9`、`01a15726-e178-72b0-9a3c-8b2041538730`。
## CG-005 · 工程外观与自备素材
工程阶段由总管安排选择人物外观;后续支持用户按标准自行生成图片或视频,替换人物外观。该能力从首版设计时预留。
边界:具体素材包规范和导入流程尚未定稿;一张图片本身不等于已有全部动态动作。最终外观仍经过样图验收。 工程外观须遵循CG-025的新要求;素材替换能力与仅换外观、不重建伴侣的CG-006保持不变。 首轮工程素材由小队制作,不以用户先备齐视频为前提,见CG-030。
来源评论:`01a1572c-18c8-7872-94b0-c05c1b24b5c7`、`01a1572d-2c55-777c-a63f-a7c74c31c070`、`01a1573f-aedf-7d08-91a7-482b2a94f782`、`01a1581e-7925-7a60-8185-615b434a4afd`、`01a1599b-f137-71cc-8af2-a265499b4b6e`。
## CG-006 · 换外观保留同一个她
换素材仅改变外观,保留原有八字底色、性格积累、记忆和关系;创建新人物与换外观是不同概念。
边界:首版随后收缩为仅一个伴侣;这里不授权实现多个伴侣或并行运行。
来源评论:`01a1573f-aedf-7d08-91a7-482b2a94f782`、`01a15749-7530-71b4-9157-b9fa7011254b`。
## CG-007 · 首版仅一个伴侣
首版只能创建、使用一个伴侣,暂不做多个伴侣及切换;支持数据备份。
边界:覆盖此前已讨论并同意的多伴侣保留、切换方向;单伴侣下的清空或重新创建规则未定。
来源评论:`01a15749-7530-71b4-9157-b9fa7011254b`。
## CG-008 · 后台生活与连续性
用户没有说话或关闭网页后,伴侣在服务器运行期间仍继续自己的生活,积累有记录的事件;回来后能够延续。画面、对话、记忆应对应同一份生活状态。
边界:没有承诺服务器关机仍能运行;停机后的恢复、补算规则另待设计。
来源评论:`01a15724-dcaa-7d2b-ae23-430f24ca36b9`、`01a15726-e178-72b0-9a3c-8b2041538730`、`01a15749-7530-71b4-9157-b9fa7011254b`、`01a15751-ded1-7328-80ae-39967d2b3821`。
## CG-009 · 备份与恢复
每天自动备份;支持随时手动完整导出到自己的电脑,并从备份恢复同一位伴侣。覆盖八字、性格资料、对话、记忆、关系、生活进度;手动完整导出带上所用人物、场景素材及恢复所需设定。
边界:自动副本留在服务器,首版暂不增加云盘接入;保留份数、空间策略、恢复校验等工程细节尚未定稿。
来源评论:`01a15749-fc88-7295-8350-dccfb0168b70`、`01a1574f-9e0e-7bf4-b158-3f88a4c1988a`。
## CG-010 · 连续语音与文字
用户主动开启语音后自然连续交谈,支持开口打断她;结束语音后停止收音,同时保留文字聊天。
边界:响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。
来源评论:`01a15750-2cf9-7f23-9269-0f150d2c5e61`、`01a15750-e061-733b-9d80-38459d306b97`。
## CG-011 · 主动联系范围
首版只在网页内主动联系:语音开启时可以主动开口,未开启时留文字消息;关闭网页后保留消息,回来再看。主动分享应有自己的生活经历作为依据。
边界:首版不接手机推送或其他聊天软件;具体频率可在方案中结合原仓库参数制定。
来源评论:`01a15751-670e-7187-b94a-cea70154df3b`、`01a15751-ded1-7328-80ae-39967d2b3821`。
## CG-012 · 生辰创建方式
使用她自己的出生年月日和时辰;创建时可自动生成,也允许用户在确认前手动指定。确认后作为人格底色依据,换外观不会改变。
来源评论:`01a15752-4cfe-7cd7-a4a3-8a8f8143f943`、`01a15754-b879-73b6-b367-b105898d7107`。
## CG-013 · 八字底色不可编辑
八字带来的性格底色是系统级资料,创建后只读;每轮重新提供给模型并预留上下文空间。可用结构化指标配合简短解释表达,关系提示词不得覆盖底色。
边界:程序保证资料不被改写;模型能否长期遵守仍需验证,不能把写成数字或系统提示词等同于已解决漂移。
来源评论:`01a15771-3a93-7518-9b23-489509520d77`、`01a15772-3101-7d20-bf1d-a3b3092b6535`、`01a15774-ddc6-7039-9d31-991c02890d5f`。
## CG-014 · 性格诠释空间
性格描述保留适度抽象和解释空间,让模型形成有延续性的个性化诠释;同样底色允许不同表现与成长,不给每次回答规定唯一标准答案。
边界:不能将本项误记成‘用户可修改八字底色’,也不能将随机、无缘由地反复变换人设当作个性。
来源评论:`01a15763-1fc5-7f36-bf43-722e8755703c`、`01a15763-e729-7aa2-a752-7b95fb69372c`、`01a15771-3a93-7518-9b23-489509520d77`、`01a15774-ddc6-7039-9d31-991c02890d5f`。
## CG-015 · 初始习惯与后天成长
创建时带少量模型自主形成的喜好和小习惯;以后通过生活、相处逐渐丰富。成长经历单独记录,保留最初的八字底色。
边界:原仓库重大经历可微调核心的旧例外,按此讨论改为另存成长层,不据旧文直接修改出生底色。
来源评论:`01a15763-e729-7aa2-a752-7b95fb69372c`、`01a15765-2fa8-7494-b0b8-7a0054500556`、`01a15772-3101-7d20-bf1d-a3b3092b6535`、`01a15774-ddc6-7039-9d31-991c02890d5f`。
## CG-016 · 关系可配置、可调整
朋友或恋人、称呼、相处边界等关系设定通过独立提示词表达,相处过程中也可明确修改;保留原有记忆和互动形成的信任,不改写过去。
边界:没有确定默认必须初识或必须情侣;通用人物提示词编辑器并未获确认,允许修改的范围是关系设定。
来源评论:`01a1576a-5ffe-73fb-b6f1-2aeee98a5782`、`01a15771-3a93-7518-9b23-489509520d77`、`01a15772-3101-7d20-bf1d-a3b3092b6535`、`01a15774-ddc6-7039-9d31-991c02890d5f`。
## CG-017 · 首版场景范围
先集中做好一个高层卧室,以成品视频表现对应天气、昼夜、光线以及少量服装和日常动作;由统一状态按条件选片,保留后续扩展入口。
边界:其他房间和外出场景后续扩展;素材数量先结合试片结果定,不将早期示例数量作为现行承诺。 当前唯一房间明确为CG-026所述高层卧室。 浏览器人物/环境物理分层要求由CG-029覆盖;活动、天气等逻辑状态仍独立管理,不用选片篡改生活记录。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a15775-89f4-7ec8-89eb-b3554fb05d19`、`01a1577a-e852-7551-ace8-0b2028db9ebb`、`01a1581e-7925-7a60-8185-615b434a4afd`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-018 · 电脑优先、兼顾手机
主要在电脑使用,优先大画面沉浸体验;适配手机看画面、文字聊天和连续语音,使用同一伴侣和同一份记忆。
来源评论:`01a1577b-b65c-787a-b436-b0a74c38fea4`、`01a1577c-d10f-7513-8f58-62b3b387240a`。
## CG-019 · 素材工具与新增费用
首版先使用已有工具和本地机器制作图片、视频素材,暂不新增付费云端生成接口;若实测不够,再列明方案效果和费用,由用户决定。
边界:此项是素材制作的费用边界,不能误解为禁止使用已提供的外接LLM或用户自行部署的小模型服务。
来源评论:`01a1577d-7203-7b2b-bf24-f880f56750cf`、`01a1577e-96f6-72c7-b66b-6c43779fc5f0`。
## CG-020 · 首版内网访问
首版在部署服务器所在内网使用,电脑和手机在该内网访问。
边界:外出访问后续扩展。摄像头和麦克风所需可信访问方式仍要在工程方案中落实。
来源评论:`01a1577f-0707-7ac6-9c0f-aef2e73ba108`、`01a15780-0fd0-7438-beee-4947f4c3c928`。
## CG-021 · 按任务选模型、视觉外接
该用小模型的地方用小模型;状态和时间推进用程序,记忆等后台任务可用小模型,主模型负责主对话和较复杂理解。摄像头用小视觉模型前处理,部署由用户在其他设备负责,项目通过接口接入;用户另有4080显卡。
边界:平时把带时间和不确定项的观察摘要交给主模型,必要时保留原图复核途径。具体小模型、部署参数、接口地址和性能尚未定版。
来源评论:`01a157a5-1668-723d-af23-732a612b30b9`、`01a157a6-b158-7ba5-8a3a-1d19920890ac`、`01a157a7-c9f2-7133-8340-9b497f12f7e7`。
## CG-022 · 首版摄像头范围
加入可开关的视觉观察,先聚焦人在不在、明显物品、房间环境变化;快速挥手、点头等连续动作放到后续。
边界:观察有时效,不能把旧画面继续当作眼前事实。刷新间隔和延迟以小视觉服务实测为准,5—10秒只是先前建议,不是固定性能保证。
来源评论:`01a157a6-b158-7ba5-8a3a-1d19920890ac`、`01a157a7-c9f2-7133-8340-9b497f12f7e7`。
## CG-023 · 天气、昼夜与作息
用户手动选择一个真实城市,房间天气、昼夜和生活作息跟随当地节奏;城市可自行更换,无需每次打开重新定位。
边界:具体城市、天气来源及跨城市切换细节仍待设置或工程设计。
来源评论:`01a157a8-510f-7d3d-9880-670dc9ae4d22`、`01a157a8-aeab-7611-9695-74afbc2dec02`。
## CG-024 · 第一道验收及后续顺序
先确认人物与房间的写实样图,再验收写实动态小样;验证环境与人物独立变化、同一人物连续性、动作循环衔接、半身/近景交谈与口型语音。用户认可后,再逐步接完整功能,并分别验收。
边界:小样通过仅表示该阶段通过,不等于记忆、后台生活、摄像头或备份已全部实现和通过验收。 用户本次确认v2静态方向并授权继续最小动态验证;不等于动态或完整功能已通过,也不扩大为全量施工及任意模型安装授权。 本次同意CG-029/030后进入已确认方案的关键技术验证;旧小样通过不等于新实时交谈、衔接或完整功能通过。 CG-031明确完整前端设计不以嘴型技术验证通过为前置条件;设计样图、交互原型与真实功能接通须分别标明,不能把设计推进等同于全部后台施工获准。
来源评论:`01a157a9-bd18-7533-9b21-e5f4c3a90d7c`、`01a157ab-209c-7c8f-956c-40ebb3340f4e`、`01a1582c-7386-7515-bf62-89dfadfea3d3`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-025 · 当前工程人物外观
成年女性,年龄观感在20岁左右,冷白皮,圆鹅蛋脸,中短发,气质清纯干净。用户指出上一版人物太老、太路人,要求重新设计。
边界:用户已于2026年10月20日15:17(北京时间)认可v2人物静态方向。CG-002的写实风格、CG-006外观与伴侣身份分离保持;不代表分层和跨视频身份连续性已验证。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a1581e-7925-7a60-8185-615b434a4afd`、`01a1582c-7386-7515-bf62-89dfadfea3d3`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-026 · 当前房间设定
高层卧室,设置慵懒沙发和女性化装饰元素,整体温暖温馨。
边界:用户已认可v2高层卧室静态方向,四张v2图为当前视觉基准。CG-017的一间房、人物与环境独立、天气昼夜变化范围保持;分层几何、光照切换效果仍需实测。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a1581e-7925-7a60-8185-615b434a4afd`、`01a1582c-7386-7515-bf62-89dfadfea3d3`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-027 · 真人优先与二维备用
优先争取真人照片般的效果;如果真人路线无法达到所需效果、损害沉浸感,再考虑二维动画。
边界:真人优先、二维为条件备用保持。首版具体主验证路线现由CG-029确认;交谈身体范围按CG-028。真人转二维的具体判据和二维风格尚未定稿,不自动切换。
来源评论:`01a158c5-a25f-742b-975d-6af1ce8ff6b6`、`01a1599b-f137-71cc-8af2-a265499b4b6e`。
## CG-028 · 首版交谈的实时表现与预设身体动作
首版优先让口型、表情和目光自然;身体主要使用少数预设动作,不要求肩膀、手势根据每句话实时生成新的变化。
边界:这是首版范围选择,不是禁止预设肩部/手部动作或要求人物身体完全静止。具体模型能支持哪些表情、目光和动作仍待实测;没有测试证明现有硬件绝对无法实时驱动肩部或手势。此项同意不代表选定成品视频架构、具体模型、安装、付费API或新施工。 后续用户另于01a1599b-f137-71cc-8af2-a265499b4b6e同意CG-029/030方案,不把这两次确认混成同一授权。
来源评论:`01a158c7-f16f-7b98-844e-77271f16d6d8`、`01a158d7-1d0c-764c-8a4c-35359b4f5d8a`、`01a1599b-f137-71cc-8af2-a265499b4b6e`。
## CG-029 · 成品日常视频与本地实时交谈主路线
首版优先验证:人物与房间一起制作成品日常视频,按真实时间、天气及她的实际活动选片;交流时自然转入同一人物的实时半身交谈画面,结束后回到日常活动。人格、记忆、关系与后台生活由本地核心系统管理。
边界:替代首版浏览器每帧独立人物抠像合成的实施要求;天气、服装或活动变化需要对应视频与自然过渡,保持同脸、服装和光线连续。只确认架构方向,LiveTalking/MuseTalk/Ditto等具体驱动器仍需实际选型;不承诺任意新身体动作,不默认采用云端收费服务,也不代表全部功能已完成。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a158d9-2f3a-72f2-a911-5f58adf074c7`、`01a1597e-ef9e-7cfa-9456-1a0d4e248430`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-030 · 首批视频制作与后续用户自备素材
首轮工程素材由总管安排小队使用现有本地资源制作,以已确认的人物与高层卧室为基准;用户不需要先自行准备整套视频。后续提供模板、制作规范与导入检查,用户可自行生成替换外观。
边界:先验证少量日常、过渡和交谈素材的完整流程,再扩充服装/天气/动作;具体数量和格式待测试。生成有筛选、重试和处理成本,不承诺一张照片自动得到全套合格素材;换素材不改变八字、记忆和关系。 v1.6澄清见CG-031:人物/卧室与成品视频属于视觉模块素材及技术路线参考,不能用作整体前端布局和产品设计的固定基座。
来源评论:`01a1597d-f505-7481-a9ee-62e025fbfa2f`、`01a1597e-ef9e-7cfa-9456-1a0d4e248430`、`01a1599b-f137-71cc-8af2-a265499b4b6e`、`01a15e92-e689-754a-aad3-4566100962b3`。
## CG-031 · 完整前端以产品功能和使用流程为设计依据,人物与卧室样图只作视觉参考
完整前端应覆盖四类要素:相处主界面、交流界面、她的生活与共同回忆、创建与管理。设计需建立在对整个项目的充分理解之上;此前认可的人物和高层卧室图片只属于人物/场景/口型等视觉子模块的参考,不能作为整体前端的固定基座,不能据此先铺满场景再往上叠界面。
边界:完整界面的布局、导航、内容层级、交互与视觉语言应围绕产品使用流程独立设计,不能被旧样图构图锁定,也不把成品视频策略扩张为所有界面必须全屏视频。嘴型同步作为独立技术研究,不作为完整前端设计开工的前置验收。此项确认设计依据与范围,不代表已认可任何完整页面稿,不授权直接实现所有后台功能或自动改换模型;此前素材仍可作参考,不能误记成需要删除旧素材或推翻全部既有需求。
来源评论:`01a15e8d-91ab-739e-84f9-fc4026bfcd70`、`01a15e92-e689-754a-aad3-4566100962b3`。
## VIS-001 · 两人的数字空间
整体像只属于两人的数字空间,人物、交流、回忆融在其中。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-002 · 整体气质
静谧梦幻、安静亲密,有空间深度和柔和微光。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-003 · 多主题的一致结构
切换主题同时改变配色、背景、光影、材质和动效;页面结构与操作位置一致。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-004 · 两套首轮主题
靛蓝月光与蓝紫雾光两套都保留;不把原型临时默认主题当成用户指定默认。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-005 · 空间优先的首页
首页只留少量当前状态与入口,其余内容按需展开,完整功能仍保留。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-006 · 背景层次
光雾、景深与若隐若现的景物融合;具体画面通过样图验证。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-007 · 空间动效
平时舒缓流动与景物微动;操作时有明显景深变化和空间过渡。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-008 · 透光浮层
内容采用透光浮层,背后保留空间光影,文字清晰可读。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-009 · 轻盈文字
现代轻盈、字形简洁、标题克制,正文易读。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-010 · 微光强调
少量微光集中于关键操作和选中状态,两主题各有强调色。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-011 · 人物独立视窗
人物平时作为空间中独立视窗;交流时自然放大。已认可的人物位置、分量与首页文字分量沿用。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-012 · 悬浮导航
底部小巧透光导航,图标配简短文字,选中亮起微光。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## VIS-013 · 交流呈现
交流展开后以人物为主,只呈现当前一两句对话;完整记录按需展开,文字输入和语音操作均保留。
边界:具体实现不得改变已确认体验;现有界面不等于用户最终满意。
来源评论:`01a15f09-db1e-705c-8baf-cde63c7651fb`、`01a15f1f-5a71-783c-a458-b326a0073d69`。
关联全文:`sources/visual-confirmation-v2.md`。
## AM-001 · 未分享生活不可见
等她讲过才显示,覆盖列表、详情、摘要、关联回忆与消息引用;未知分享状态不展示,不能用链接或搜索绕开。真实接入必须在服务器响应前裁剪,前端过滤不能冒充权限控制。
边界:其余已确认要求继续有效。
来源评论:`01a15f42-2781-700e-b75a-3ae4b023f46c`。
## AM-002 · 关系由用户选择
创建时由用户选择关系,不预选、不替用户决定;以后改变关系保留记忆与信任,底色不变。覆盖旧记录 T-02 中关系初始值仍待定的部分。
边界:其余已确认要求继续有效。
来源评论:`01a15f42-2781-700e-b75a-3ae4b023f46c`。
## AM-003 · 名字由用户起
首次创建由用户提供有效名字,主页、交流、首建与设置统一;参考名不能硬编码成真实身份,不额外新增改名流程。
边界:其余已确认要求继续有效。
来源评论:`01a15f42-2781-700e-b75a-3ae4b023f46c`。
## UX-001 · 手机对话可用
手机对话不被人物、导航、底部区域遮挡,长句和键盘弹出时仍能阅读与输入;等效视口测试不能冒充真机验证。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-002 · 对话文字动效
文字有柔和的进入和退场,流式更新不重复闪烁;动态效果必须实际演示,不以 CSS 存在代替验收。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-003 · 滚动条主题一致
记录、详情、管理、首建等滚动区域与两套主题协调,保留清晰可操作性。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-004 · 出生资料与底色深度
创建与资料显示公历、农历、时辰及四柱说明;性格不能只用话多、直接、焦虑等浅标签概括。深度不能变成固定模板人设,仍须遵守 CG-013/014/015 的诠释自由、初始个性、成长分离。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-005 · 交流身份清楚
用户与实际起名的伴侣身份明确,当前对话与完整记录一致,不只依靠位置或颜色猜。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## UX-006 · 完整界面功能保留
按已认可的完整前端设计补齐功能和页面结构,未接通能力说明真实边界,不删成空壳也不伪造已运行。
边界:用户尚未认可本轮对话效果与整体沉浸界面,必须回到完整需求返工。
来源评论:`01a162eb-1eab-7dad-8eff-66e0954f5e0b`、`01a16353-c93a-7745-8efa-2586f0ea3eee`。
## GOV-001 · 完整需求跨任务继承
所有新卡、续办卡、设计卡、施工卡、审核卡从同一完整需求入口开始,不依赖会话记忆或最近一条反馈;现有未完成卡补齐对照项。
边界:不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。
来源评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
关联全文:`sources/governance-proposal-approved.md`。
## GOV-002 · 只有用户能改需求
只有白栖知明确的新决定可以覆盖原要求,保留原话来源、旧结论、新结论及影响;不能把技术困难、阶段减项、智能体建议或演示通过当成需求修改。
边界:不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。
来源评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
关联全文:`sources/governance-proposal-approved.md`。
## GOV-003 · 对照和验收
每卡列基准版本及提交位置、关联要求编号、交付验收、未完成承接、需求变更和验证证据;自动检查缺项,人工核对含义,用户验收体验。
边界:不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。
来源评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
关联全文:`sources/governance-proposal-approved.md`。
## GOV-004 · 本轮返工范围
先落实需求继承约束,再制作对话显示和整体沉浸界面的电脑/手机样图与可操作动态小样;小样认可前不替换正式界面,不合并当前产品 PR,不改现有伴侣和聊天数据,不切换队员全局模型。
边界:不重新表决已经认可的需求;不声称自动检查能证明审美或理解正确。
来源评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
关联全文:`sources/governance-proposal-approved.md`。
## DEL-001 · 演示与真实接入边界
前端演示通过只代表该阶段通过;主卡保留追踪完整真实功能,后续接入按已批准 R1–R7 依赖顺序推进。阶段和技术方案不能降低完整需求。
边界:接入方案中的候选服务、声音、摄像头和硬件操作仍须满足各自前置与授权,不能从批次批准推断全部候选已经批准。
来源评论:`01a161a4-842f-7c26-9be0-1812b720976a`、`01a1629c-732f-7559-8417-6f2ea05c7151`。
关联全文:`sources/integration-plan-v1.md`。
## GOV-005 · 视觉总监接手整体视觉及必要代码
CyberGril整体视觉由视觉总监接手设计与实际落地,职责不止审核把关;施工员无法写出对应代码时,直接由视觉总监编写。本轮对话与沉浸界面交由视觉总监亲自负责。
边界:总管负责协调、完整需求核对与交付;不再用总管制作替代视觉总监接手。分工限本项目,不修改队员其他项目的全局模型、职责或认证;原产品需求与小样先验收再替换正式界面的边界继续有效。
来源评论:`01a163a5-e2a7-7ac8-afdb-991868e721bc`。
## 尚未确定或尚未验证
### T-01 · 八字映射细则
具体规则表、权重、命理解释覆盖深度与验证样例尚未定稿。用户没有直接确认‘只保留明确量化项、排除所有抽象描述’;后续已明确保留性格诠释空间。
### T-03 · 素材包标准与具体数量
导入模板、尺寸、帧率、背景处理、动作节点、口型预处理、预览与回退方式等需结合方案、小样定稿。未承诺固定服装/动作数量或一张参考图自动生成整套合格视频。
### T-04 · 模型与速度
Qwen3-VL-4B、2B和此前提到的视频/口型方案都是候选;4080部署、真实摄像头、语音与人物同时运行尚未实测。既有接口单图测试的1.5秒/7秒不代表4080速度或端到端语音延迟。
### T-05 · 外接服务接入
4080视觉服务地址、具体模型、输入输出能力,以及现有ASR/TTS的流式和打断能力,需后续核实或由用户提供。部署视觉小模型由用户负责。
### T-06 · 运行与恢复细节
单伴侣重新创建/清空、服务器停机后的状态恢复、备份保留份数和空间策略尚未定稿;不能用多伴侣旧结论补全。
### T-07 · 天气和访问细节
具体城市、天气数据来源、缺失天气素材时的呈现方式、内网可信HTTPS方案等留待工程设计。
### T-08 · 实现完成情况
本记录是需求确认台账,不证明功能已实现。旧演示的部署和测试结果不能作为新写实动态路线的验收结果。
### T-09 · 真人呈现路线与交谈动态边界
CG-029已选定成品日常视频配本地实时半身交谈,CG-030确定首批由小队制作。待验证具体驱动器、同脸/服装/光线连续、音频/口型/头部表情实际能力、打断、日常与交谈往返、素材制作成本及本机并发/恢复;真人转二维的条件仍待明确。云端收费服务未选定。
### T-02 · 关系界面余项
初始关系由用户选择已由 AM-002 确定,旧待定结论被覆盖;自由提示词不能被用于修改出生底色。
### T-10 · 当前一两句的长回复呈现
分句、停留和前后查看细节在独立动态小样中验证,不冒充用户已选具体方案。
## 历史覆盖记录
原始 v1.6 的 `superseded` 字段保留全部早期路线变化。另有以下后续覆盖:
- 私人手账视觉初稿未获认可,按完整视觉访谈和后续认可稿重做。
- 初始关系“尚未定”由 AM-002 覆盖;生活“可先显示并标注未分享”由 AM-001 否决;名字由 AM-003 确定。
- 最新用户对对话与整体沉浸界面不满意,不得沿用此前阶段通过作为本轮通过。
- 以上不授权重置出生底色、伴侣身份、聊天、记忆或直接合并产品分支。
+35
View File
@@ -0,0 +1,35 @@
{
"baseline_version": "1.8",
"read_commit": "4242f8377e350e5f17e836d275c15b0f099bf109",
"requirement_ids": [
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005"
],
"deliverable": "按用户明确指令记录视觉总监接手设计与相应代码职责,并交接本轮返工",
"acceptance": [
"58项原意、边界、来源完整;初轮及视觉对话取齐",
"版本变更、来源、覆盖、交接缺项检查可运行",
"项目资源与说明及旧卡回读核对;独立审核后仅治理分支允许合入"
],
"deferred": [
"用户界面小样及其体验验收另卡",
"R1修正与R2–R7真实接入继续保留",
"CI自动合并门禁尚未配置,当前检查由交付与审核执行"
],
"requirement_changes": [
{
"approval_comment_id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"requirement_id": "GOV-005"
}
],
"verification": [
"59项需求及交接检查通过;原始来源和变更历史保留",
"项目规则、主卡与返工卡已明确视觉总监亲自接手设计与相应代码",
"用户手动恢复原运行时后,HEL-793运行01a163bc-9134-75e4-aa28-71c6371ff692真实开始;已读旧稿与样图并检查浏览器;没有重复派工",
"追溯确认此前2026-10-22T09:31:24Z的运行时变更来自总管执行01a162f2-61a5-7c6a-9704-44c8e15219ff,未经用户事先批准;已向用户如实说明",
"主卡恢复in_progress;用户设定的运行时、模型与认证未再更改,正式界面及用户数据保持原状"
]
}
@@ -0,0 +1,12 @@
# 本次约束落地与验证
需求基准v1.7由白栖知主卡评论 `01a1635b-b47c-79ed-ac66-9408c7905af9` 批准。
- 已归档58项完整有效要求和125条来源评论;旧原始记录、获批设计及接入方案保留原样。
- 项目说明已加入必读路径、只有用户可改需求、每卡对照、阶段与全项目验收边界。项目仓库资源已绑定,回读URL正确;新卡将实际验证收到的项目上下文。
- 原39张尚未关闭子卡逐张补充入口及交接约束,回读确认状态和负责人不变;主卡同样补充且仍in_progress。迁移清单见 `task-migration.json`。旧试验未因迁移重新启动。
- 正常基准和交接通过;对覆盖漏项、来源改动、无升版/新批准的需求修改、过期交接共4种反向输入均失败,详见 `verification.json`。验证在隔离临时副本进行,未修改服务或用户库。
- `git diff --check` 通过。未改产品行为,未重跑无关后端与浏览器测试;本次没有新增界面画面可验收。
- 仓库治理请求为PR#5,仅含规则、需求、检查与说明;独立审核和合入main尚未完成。此前产品PR#4保持open未合并,8300/8310及用户数据保持原状。
此记录证明本轮写入与有限验证,不证明后续每位智能体都会理解正确。结构检查须配合原话审核与实际体验验收;当前没有配置CI强制合并门禁。
+11
View File
@@ -0,0 +1,11 @@
{
"governance-proposal-approved.md": "ff356076d682edad3df54e8257799000127bdbb478d1fbc43d37886a28d65c09",
"design-acceptance-v1.md": "21f76eb69da11e8b2f3a5addb2f7303c1d9f766953595924232582b428013b12",
"approved-frontend-spec-R2.md": "d95edfe36dd08d4282176f5e7615750ddb5baa75d3a542b83ed3f9d8b24e02c8",
"comment-evidence.json": "d08ca2359e16d048fdc46b4b5ceee536d3937bbdb7b16e42598b831e508f2370",
"frontend-architecture-v1.md": "eb05f1707a932014d613072e8c5d95bc8d6a0281390dc93c000d63851ecbbc86",
"product-decisions-v1.6.json": "ccc5c50c5604385208eb054caac01e27fc63367dac7731e1be9775a1e5cf46ab",
"visual-confirmation-v2.md": "6e0229aaf693f215cc9dcc647be694aeafb3770683b21bb7e3e30291af60255a",
"integration-plan-v1.md": "454af1d4a3b0360d33aea9c0e97f738b320968ba7be68e012a5fcec6f6ed0169",
"comment-evidence-visual-role-20261022.json": "ecf30eb323129dca2a0d9c2ff2e0163884ee94d3fc7828b0540cd45f03103797"
}
@@ -0,0 +1,267 @@
## CyberGril 完整产品设计 · 可施工规范(HEL-746 / R2)
延续 HEL-744 R1 已认可的方向「夜色里一扇亮着的窗」。本轮没有改首页的比例、字号和人物窗位置,也没有改交流时放大人物窗的方式。在这个基础上补齐了生活与共同回忆、首建、管理、交流的完整状态、离开后返回、主动消息,以及手机上的完整路径。
依据:需求记录 v1.6(CG-001~031、T-01~09),HEL-738 总工方案(F1~F9 流程、组件边界、需要视觉总监覆盖的状态清单)。本稿只是设计原型,不是正式产品。
---
### 1. 交付物与打开方式
| 文件 | 说明 |
|---|---|
| `index.html` | 完整原型,包含两套主题和电脑/手机两种布局。不需要联网,也不需要服务 |
| `phone.html` | 在 390×844 的手机框里打开同一份 index.html |
| `assets/` | 沿用 R1 的两张参考静帧(房间、近景) |
| `shots/` | 55 张实际截图(42 张电脑 1440×900,13 张手机 390×844@2x) |
| 本文件 | 可施工规范 |
- **演示控制**:电脑在左下角,手机在顶部中间。可以切换数据(正常/空/很长),以及「未讲述的事」的两种呈现方式,还能模拟两天后回来、她主动说话、断线(恢复/失败)、发送失败、麦克风被拒、语音识别出错、视觉服务失败、重新演示首建、创建失败和保存失败。**这个面板只属于原型,不是产品界面。**
- 截图参数:`?shot=<场景名>&theme=moon|fog`,场景名见第 14 节。自检参数:`?selftest=1`,结果写在 body 的 `data-selftest` 属性里。
---
### 2. 已认可、本轮不改动的部分
- 靛蓝月光和蓝紫雾光两套主题整体切换,DOM 和布局完全相同。用户没有指定默认主题,原型以月光打开只是临时设定,不代表用户已经决定。
- 首页:左侧一句话(30px/300)、心情与关系、她的留言、「你不在时」入口和两个按钮;右侧拱形人物窗(`clamp(300px,27vw,430px)`,比例 1:1.3,距右边缘 14vw)。
- 交流:人物窗展开为宽幅圆角窗,画面只留当前一两句;输入框常驻,麦克风是唯一实心的强调按钮,完整记录从右侧滑入。
- 底部导航保持小巧、透光:此刻 / 交谈 / 日子 / 设置。
- 人物和卧室仍用静帧占位,标注「示意画面 · 参考静帧」。没有新做嘴型实验。
---
### 3. 信息架构与导航、返回关系
```mermaid
title 区域与返回关系
flowchart LR
NOW[此刻 首页] -->|和她说话/麦克风/导航交谈| TALK[交谈]
NOW -->|你不在时/导航日子| DAYS[日子: 她的日子 / 我们的回忆]
NOW -->|导航设置| MANAGE[设置: 她/关系/城市/外观/备份/外接服务/显示]
NOW -->|她留给你的| TALK
DAYS -->|事件详情→看那段聊天| TALK
TALK -->|返回日子 原滚动位置与选中项| DAYS
TALK -->|回到此刻/Esc| NOW
DAYS -->|回忆↔事件 互链| DAYS
DAYS -->|× / Esc / 回到她身边| NOW
MANAGE -->|× / Esc| NOW
FIRST[首建 仅无伴侣时] -->|完成→去看看她| NOW
BACK[离开后返回] -->|你不在的这两天| NOW
REACH[主动消息] -->|回她: 记录定位到该条| TALK
```
- **离开日子以后还能回到原来的位置**:从事件详情打开聊天时,关闭按钮会改成「返回日子」。日子列表的 DOM 不重建,所以滚动位置和选中的条目都保留。自检里有专门的验证项。
- **Esc 逐层退出**:弹层 → 演示面板 → 返回欢迎层 → 聊天记录 → 交谈 → 手机详情 → 区域。首建过程中按 Esc 不会退出。
- **手机**:列表和详情、设置分区列表和分区内容都改成「推入」。左上角的「‹ 她的日子」「‹ 设置」负责返回。
- 设置里有没保存的修改时,关闭网页会触发浏览器离开提示(`beforeunload`);对应分区导航上会显示一个暖色小点。
---
### 4. 需求 → 页面 / 状态映射
| 需求 | 落点(原型中可以操作) |
|---|---|
| CG-007 单伴侣 | 首建只出现一次;设置→她,注明「首版只有一位,不提供新建、切换或清空」;恢复别人的备份会被阻止 |
| CG-008 后台生活 | 日子时间线;「你不在的这两天」;文案注明「网页关着她照常生活(服务器停机期间除外)」 |
| CG-009 备份恢复 | 设置→备份:每日自动备份列表、完整导出(进度→完成)、恢复(三项检查→要求输入「恢复」两个字的双重确认→恢复前先自动导出) |
| CG-010 连续语音 | 语音条:计时、状态(倾听/思考/她在说/停下来了/没听清),「打断她」和常驻的「结束语音」;输入框不会消失 |
| CG-011 主动联系 | 首页上从人物窗里浮出的轻提醒,注明「由她今天 15:30 的生活经历引起」;交谈导航上的未读点;聊天记录里标「她主动说的」;语音开着时她会直接开口 |
| CG-012/013/014 生辰与底色 | 首建 4 步:出生(随机生成/手动修改,未满 18 岁会被拦下)→ 底色(四柱、倾向词和白话说明,说明是「倾向不是台词」)→ 关系 → 确认(必须勾选「我明白」)。设置→她:底色只读,带锁标识 |
| CG-015 成长分层 | 回忆→她的成长;设置→她:「最初就有的」和「后来慢慢有的」分两栏 |
| CG-016 关系可调 | 设置→关系:关系、称呼、相处边界;改关系类型需要二次确认,确认框说明「记忆和信任都保留,底色不变」 |
| CG-005/006 换外观 | 设置→外观素材:选择素材包→五项检查(通过/不合格)→新旧对比→确认「她还是她」 |
| CG-022 视觉观察 | 交谈里的「窗」按钮:首次开启要说明并确认;状态分为正在看、刚刚看到 N 秒前、已过期(不再当作眼前)、失败;设置→外接服务里有总开关 |
| CG-023 城市 | 设置→城市:搜索、选择、保存(成功或失败),顶栏城市同步变化 |
| CG-018 电脑优先、兼顾手机 | 全部区域在手机上都能走完关键路径,见第 8 节 |
| CG-031 设计依据 | 布局以使用流程组织;人物窗只是一个区域,不铺满全屏 |
| HEL-738 F6 断线 | 顶部横幅「信号不太好,正在重新连上…」,人物窗变灰,发送暂停但草稿保留,语音安全结束;重连成功会提示补了几条;重连失败显示「再试一次」 |
| HEL-738 §5.5 φ 文案 | 所有错误都用她的话或生活化的话表达(「我没听清」「看不清」),不出现技术报错 |
| SPEC §8.5 不画仪表盘 | 关系、心情都用一句话描述,不出现数值、进度条或雷达图 |
---
### 5. 各区域规范
#### 5.1 日子(她的生活与共同回忆)
- **空间关系**:打开后,首页内容向左后退并模糊;人物窗缩小到右侧(`--mini-pw: clamp(170px,14vw,230px)`),留在原来的空间里;窗下写「她此刻 · …」,并注明「来自她的生活记录 · 画面为示意静帧」,把生活状态和素材播放区分开;背景推近到 1.03 倍并压暗。
- **面板**:`left 5vw / top 84px / bottom 104px / width min(1000px,66vw)`,圆角 28,使用阅读底 `--glass-read`。电脑上分两栏:列表 400px,详情占剩余宽度。
- **三种事件**:已发生(实心点,`--text-2`)、正在(强调色点,带呼吸光)、打算(虚线空心点),分组为「之后的打算」和按天分组。打算类注明「计划可能会变」。
- **标签**:「还没跟你说过」(强调色)、「和你有关」(有关联回忆)。已经说过的事标题会弱化成 `--text-2`。
- **详情**:类型、时间、地点、是否说过、她的话(引用块)、和这件事有关的回忆(可以点)、那时候的聊天(可以点,看完返回)。底部固定说明「这是模拟生活里的一条记录,不是真实发生的事;画面只按记录选片,不会反过来改写生活」。
- **回忆**分为四组:我们一起的 / 你们的小习惯 / 她知道的你 / 她的成长。每条显示时间与来源(如「你 10 月 8 日说的」)、她怎么记得,以及来自哪些日子(反向链接)。
- **空记录**:「她今晚刚住进来,还没有发生什么」;回忆为「聊着聊着就会有了」。
- **长记录**:先显示前 18 条,再按「看更早的日子」每次加载 12 条,加载时保持滚动位置。施工时按天分页。
- **不做**:情绪或信任的数值仪表盘;任何「你好久没来」之类带负罪感的叙述。
#### 5.2 首建
- 全屏,人物窗和导航隐藏,左侧是叙事一句话(跟随步骤变化),右侧是向导卡 `minmax(460px,560px)`。
- 步骤条只覆盖 4 个步骤;进行中和完成不计入步骤数。
- 校验:名字必填;出生日期必须满 18 年;关系必须选(原型不预选,T-02);确认页没勾选时「创建」按钮不可用。
- 创建过程分三段:排好底色 → 形成最初的小习惯 → 布置房间。失败时停在失败的那一段,并提示「资料都还在」,可以选「返回修改」(回到第 1 步,数据保留)或「再试一次」。
- 完成页写「她住进来了」,按「去看看她」进入首页的「刚搬进来」状态:生活为空,首页一句话换成首晚的文案。
- 创建之后,界面上任何地方都没有重建、清空或切换伴侣的入口。
#### 5.3 设置(创建与管理)
- 面板几何与日子相同;左侧是 220px 的分区导航,右侧是内容。
- **表单通用规则**:没有修改时「保存」不可用,状态写「尚未修改」;有修改后,文字变成暖色「有未保存的修改」,导航上出现暖色点;「取消」把内容恢复到上次保存的值。保存时显示进行中(转圈和说明,按钮锁定)→ 成功(`--ok`,一句话说明影响)或失败(`--danger`,「你的修改还在」,可以重试)。操作栏吸附在底部。
- **重要确认**:改关系类型、应用新外观用普通确认;从备份恢复用危险样式,并要求输入「恢复」两个字。
- **外接服务**:对话模型、ASR、TTS、视觉观察服务四项,各有地址输入框和「测试连接」。原型不会发出任何请求;测试结果明确写「界面演示 / 未真实检测」。地址为空或测试失败时显示「相关功能会显示为不可用,其余照常」。
#### 5.4 交流的完整状态
| 状态 | 人物窗左上标签 | 语音条 | 台词区 / 提示 |
|---|---|---|---|
| 文字待机 | 在你面前 | — | 上一句和她当前这一句 |
| 她在想 | 在想 | 她在想 | 三点跳动 |
| 她在打字或说话 | 在说话(声波) | 她在说 · 可以打断,并出现「打断她」 | 逐字显示加光标 |
| 发送失败 | — | — | 「没发出去 · 重试」,记录里虚线边框;重试不会重复发送 |
| 麦克风授权 | — | — | 说明弹层:允许 / 不允许(模拟) |
| 倾听 | 在听你说 | 红点计时、在听你说、结束语音 | 你说的话实时显示(灰色) |
| 被打断 | 停下来了 | 她停下来了,在等你 | 她的那句话半透明,后面加省略号 |
| 识别出错 | 没听清 | 刚才没听清 | 「刚才没听清,你再说一遍?」,然后自动继续听 |
| 麦克风被拒 | — | —(会话不开始) | 麦克风变灰并加斜线;φ 文案;再点一次会给出处理指引 |
| 断线 | 画面变灰 | 语音自动结束 | 顶部横幅;发送暂停,草稿保留 |
| 视觉观察 | 绿点:刚刚看到… N 秒前 | — | 过期后变黄并标「已过期,不再当作眼前」;失败为红点 |
- 语音会话期间,「结束语音」一直显示在语音条上,麦克风按钮也同时变成停止键,两处都能结束。
- 语音开着时,人物窗高度从 `100vh-360` 降到 `100vh-412`,给语音条留出位置,三者不会重叠。
#### 5.5 返回与主动消息
- **离开后返回**:首页左侧换成欢迎层「你不在的这两天」,列出时间范围、3 件小事和留言提示,用中性语气,按钮是「看她留的话 / 看她的日子 / 先待一会儿」。
- **主动消息**:在首页时,她的话从人物窗下部浮出(`width = 窗宽-32`);在日子或设置里时,提示出现在右下角,不打断当前操作。提示上注明这句话来自哪段生活经历。点「回她」会打开聊天记录并定位、高亮这一条,关闭后回到原处。点「稍后再看」会留在「交谈」的未读里。
---
### 6. 设计 Token(R1 的值照旧,以下只列 R2 新增)
| Token | 月光 | 雾光 | 用途 |
|---|---|---|---|
| `--glass-read` | rgba(10,18,46,.62) | rgba(34,20,66,.60) | 日子和设置这类长内容的阅读底 |
| `--surface` | #0b1330 | #24154a | 手机上推入的详情层和吸底操作栏(不透明,避免下层透字) |
| `--field` / `--field-line` | rgba(255,255,255,.05) / rgba(205,222,255,.16) | rgba(255,240,255,.06) / rgba(236,218,255,.16) | 输入框 |
| `--ok` | #9fe8cf | #a8f0d8 | 成功、视觉观察「新鲜」 |
| `--warn` | #ffd79c | #ffdcae | 未保存、待定、观察已过期 |
| `--danger` | #ff9fb3 | #ffa8c8 | 失败、危险操作、录音红点 |
| `--radius-field` | 14px | | 输入框、链接卡片 |
| `--pane-l / -w / -t / -b` | 5vw / min(1000px,66vw) / 84px / 104px | | 区域面板几何 |
| `--mini-pw` | clamp(170px,14vw,230px) | | 区域页里缩小后的人物窗 |
- 字体、字号层级(30/20/15/13/11.5)、间距(4/8/12/16/24/32/48)、圆角(999/20/28)、曲线、时长与 R1 一致。新增:详情标题 24px/300,分区标题 22px/300,表单标签 13px,分组标题 12px,字间距 .14em。
- 状态色只用在小点、细边框和短文字上,不做大面积色块,以免破坏夜色的整体气氛。
---
### 7. 组件与状态
| 组件 | 状态 |
|---|---|
| `.btn-primary` | 默认(微光)/ hover(光晕变大)/ 按下(缩到 .97)/ 不可用(.42 透明,去掉光) |
| `.btn-ghost` / `.btn-danger` / `.btn-text` | 同上;危险按钮只用描边加文字色 |
| `.input/.select/.textarea` | 默认 / 获得焦点(强调色描边并加 4px 柔光圈)/ 错误(`--danger` 描边加下方说明)/ 只读(虚线描边) |
| `.seg` | 选中项为强调色实底 |
| `.status` | busy / ok / fail |
| `.checklist` | 待检查 / 检查中(转圈)/ 通过 ✓ / 失败 ! |
| `.ev` 事件行 | 默认 / hover / 当前选中(强调色浅底)/ 已讲述(弱化)/ 打算 |
| `.tag` | 中性 / accent / ok / warn / danger;`.tag-demo` 用虚线表示「演示/模拟」 |
| `.modal` | 普通 / 危险;需要输入确认字时,主按钮会先锁住,输入正确后才能点 |
| `.netbar` | 重连中(转圈)/ 失败(红点,加「再试一次」) |
| `.observe` | asking / fresh / stale / fail |
| `.nav-item` | 默认 / 当前项 / 未读(小点) |
---
### 8. 响应式
| 断点 | 布局 |
|---|---|
| ≥1101px | 如上;区域页右侧显示缩小的人物窗和说明 |
| 721–1100px | 区域面板宽 90vw,隐藏缩小的人物窗,在面板标题上改用「她此刻在…」一行字;日子列表 340px,设置导航 200px |
| ≤720px | 首页和交流沿用 R1 已认可的手机布局。区域面板改为全屏层(`top 54 / bottom 84`),底部导航始终可见,不被遮挡。列表和详情、设置分区都改为推入方式,详情层和吸底操作栏使用不透明的 `--surface`。首建为纵向可滚动布局,「上一步 / 下一步」吸在底部。语音时人物窗高 `min(42vh, 1.1w)`,自上而下依次是台词、语音条(底部 162px 处)、输入框(98px 处)、导航(16px 处),互不重叠。弹层宽度为 `100vw-32px` |
---
### 9. 动效
| 动效 | 触发 | 时长 / 曲线 | 能否打断 |
|---|---|---|---|
| 进入日子或设置:首页后退并模糊,人物窗缩小移到右侧,面板上升 24px 并淡入,背景缩放到 1.03 | 导航、入口 | 人物窗 720ms、面板 420ms(延迟 80ms),ease-out `(.22,1,.36,1)` | 可以,全部用 transition,中途反向会从当前位置回去 |
| 列表 → 详情(手机推入) | 点条目 | 420ms ease-out | 可以 |
| 首建换步 | 下一步 / 上一步 | 420ms,前进时从右侧淡入 16px,后退时从左侧 | — |
| 弹层 | 确认、授权 | 280ms,缩放 .97→1 并淡入 | Esc 或点遮罩取消 |
| 主动消息浮出 | 推送 | 420ms,上移 10px 并淡入 | 可以 |
| 断线横幅 | 断开 | 280ms | — |
| 语音条 | 开始语音 | 280ms | — |
| 「正在」事件的点、录音红点、倾听外圈、声波 | 常驻或状态 | 1–2.4s 循环 | — |
| 主题切换 | 开关 | 1100ms `(.65,0,.35,1)` | 可以 |
- **减少动效**(系统设置或设置→显示):所有循环停止;空间不再推近;过渡统一为 200ms、无延迟;粒子只画一帧。
- **页面隐藏时**:粒子停止绘制,CSS 动画暂停(`html.paused`)。
- 施工建议(与 R1 相同):人物窗缩放改用 FLIP(transform),雾层模糊预先烘焙。**本轮没有做性能实测,所以不给帧率数字。**
---
### 10. 模拟与真实能力的边界
- 原型里所有数据、回复、语音、摄像头、生活、备份、导出、导入、服务测试都是模拟,没有连接任何服务,也不会写入或下载文件。界面上用「演示数据 / 模拟生活 / 模拟 / 原型 · 不连接服务、不写数据」等标签逐处标明。
- 施工时按 HEL-738 §7 的能力标签处理:`mock` 打「演示数据」角标;`unavailable` 用 φ 占位文案;不能为了显示「在线」而伪装。
- 人物画面始终标注「示意画面 · 参考静帧」。正式版通过 Presenter 替换成视频,无论有没有口型驱动,页面结构都不变。
- 文案不暗示「关网页会停机」,也不承诺「服务器停机时她还在生活」。
---
### 11. 施工验收清单(与 S1 切片对齐)
1. 两套主题下,每个区域的 DOM 和布局完全一致;切换主题不改变滚动位置,也不改变当前视图。
2. 首页比例、字号、人物窗位置和 R1 一致(拿 D01 与 R1 截图对照)。
3. 从日子打开聊天再返回,列表的滚动位置和选中项都不变。
4. 事件和回忆可以互相跳转;空记录和长记录(分页)都正常。
5. 首建:未成年、未选关系、未勾选确认三种情况都会被拦下;创建失败时资料保留并能返回修改;完成后进入首页的空生活状态;之后任何地方都没有重建入口。
6. 设置:有修改才能保存,取消能恢复,保存过程中按钮锁定,失败时修改保留,改关系要确认,恢复要输入两个字确认,并且会拦下别人的备份。
7. 交流:发送失败能重试且不重复;语音的倾听、思考、她在说、打断、出错、结束都能走通;语音中「结束语音」一直可见;麦克风被拒后文字照常可用;断线时草稿保留、语音安全结束,重连后补齐消息。
8. 视觉观察:标注时效;30 秒后显示为过期(演示用的阈值,真实值以小视觉服务的实测为准);失败时不影响交谈。
9. 主动消息注明依据;点开后定位到那条,关闭后回到原处;不展示负罪叙事。
10. 手机 390×844:输入框、语音条、导航、吸底操作栏和弹层互不遮挡;手机上的详情层不透出下面的文字。
11. 减少动效、隐藏页面暂停都生效;Esc 能逐层退出。
12. 所有模拟能力都带标签。
---
### 12. 待定项(不阻塞其他设计,都已做成可替换的呈现)
| 编号 | 事项 | 本稿处理 |
|---|---|---|
| A | 她没亲口分享过的事对你是否可见(用户未拍板) | 默认显示并标注「还没跟你说过」;演示控制里可以切到「讲之前不显示」。详情页用黄色虚线框标为待定 |
| B | 默认主题 | 两套都保留,原型以月光打开只是临时设定 |
| C | 关系的默认值和编辑形式(T-02) | 首建时不预选;设置里用表单呈现,可以换成引导式对话 |
| D | 单伴侣下的重新创建或清空(T-06) | 不设入口 |
| E | 城市清单和天气来源(T-07) | 搜索列表为演示 |
| F | 素材包规范(T-03) | 五项检查只是示意 |
| G | 自动备份的保留份数(T-06) | 列表只显示 3 份并注明待定 |
| H | 内网 HTTPS(T-07) | 麦克风授权弹层里注明;不可用时按「麦克风被拒或不可用」的状态显示 |
| I | 观察过期的阈值、刷新间隔(CG-022) | 原型用 30 秒演示,真实值以实测为准 |
| J | 她的名字是否在首建时由用户填写 | 原型放了名字输入框;如果要改成别的方式,只影响第 1 步 |
---
### 13. 原型检查记录
- **自动自检**(`?selftest=1`,用真实点击走关键流程,共 43 项):在 Edge 无头模式下,1440×900、1280×800、窄窗(手机布局生效)三种尺寸全部通过。覆盖:主题、日子的打开/详情/互链/看聊天后返回原位、空记录与长记录、未讲述切换、设置的修改/取消/确认/保存成功/保存失败、恢复拦截、素材检查、文字失败重试、麦克风拒绝与授权、语音的思考/说话/打断/结束、视觉观察开关、断线与重连、Esc、欢迎层、主动消息定位、首建的三项拦截/失败返回/成功进入首页。
- **截图检查**:电脑截图是 1440×900 下实际 DOM 的截图;手机截图在 390×844 的 iframe 里按 2 倍像素密度截取并裁切(无头窗口有最小宽度,所以不直接截窗口)。
- **本轮发现并已修正的问题**:手机详情层半透明,下面的列表文字透出来(改为不透明的 `--surface`);手机语音时人物窗没有铺满宽度;语音条文字换行(手机上隐藏声波图标,文字过长时截断);静帧截图里声波条高度为 0;吸底操作栏的偏移。
- **限制**:
- 没有录屏工具,动效请直接打开原型查看。
- 没有做性能实测。
- 没有在真实手机或 Safari 上测试,只用了 Edge(Chromium)。
- 截图时关闭了过渡,所以截图里看不到「你不在的这两天」卡片的闪亮反馈。
- 本运行环境里没有可用的设计或动效类技能(只有平台类技能),动效由本规范和自检把关,没有使用其他技能。
---
### 14. 截图索引
D01 首页 · D02/D03 事件详情(两套主题)· D04 你不在时摘要 · D05 打算中的事 · D06 还没跟你说过(待定呈现)· D07 长记录 · D08 空记录 · D09 回忆详情与关联日子 · D10 成长 · D11 返回欢迎层 · D12 主动消息(首页)· D13 主动消息定位 · D14~D21 首建(出生、校验错误、底色、关系、确认、失败、完成、首建后首页)· D22~D30 设置(她、关系确认、城市成功/失败、外观通过/失败、备份、恢复双重确认、外接服务)· D31~D41 交流(发送失败、授权、倾听、她在说、打断、麦克风被拒、断线、重连失败、观察新鲜/过期/失败)· D42 演示控制台 · P01~P13 手机(日子列表、详情、回忆、首建出生/确认/失败、设置列表、关系表单、恢复确认、语音、断线、返回、主动消息)。
@@ -0,0 +1,10 @@
[
{
"id": "01a163a5-e2a7-7ac8-afdb-991868e721bc",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": "整体视觉交给视觉总监接手,而不是把关,甚至若施工员无法写出相应代码,直接让视觉总监写代码",
"created_at": "2026-10-22T12:46:18Z",
"parent_id": "01a1639b-8416-7b0b-8dbf-0de16c257cb5"
}
]
File diff suppressed because one or more lines are too long
@@ -0,0 +1,22 @@
主卡 HEL-697 的整套界面设计已经白栖知验收。批准评论:01a15f42-2781-700e-b75a-3ae4b023f46c,原文:“整套认可,1选等她讲过才显示,2创建时由我选择,3首次创建时由我起名”。对应验收说明评论01a15f3b-ec7e-7bfb-bf86-9005b81b095c。
## 本次定稿
1. 她未亲口分享的生活经历,等她讲过才显示。覆盖生活列表、详情、离开摘要、关联回忆与消息中的生活引用;不能通过旧链接、搜索结果或摘要绕开。未确认已分享的记录不能当已分享展示。已有原型默认“显示并标注还没跟你说过”的分支被本条覆盖,正式前端不保留切回该分支的用户开关。
2. 首次创建时,由白栖知选择关系,不预选、不替用户决定;未选择不能完成。以后设置中修改关系按已认可设计处理:改变关系类型再次确认,保留已有记忆与信任,底色不变。
3. 首次创建时,由白栖知给她起名字,要求有效输入;主页、聊天、首建确认和设置统一使用该名字,不把“晚晚”等参考名硬编码为真实身份。不额外新增改名流程。
4. HEL-746 R2整套页面和流程认可;HEL-744定稿视觉延续。两套主题均保留;首页人物窗大小位置、首页文字分量和交流放大效果均沿定稿。不再请用户确认同一设计。
## 执行范围
启动HEL-738方案的S1:在项目仓库落地整套可构建前端、结构化状态/数据适配边界和完整演示数据,覆盖相处、交流、生活回忆、首建管理及电脑/手机。完成写码、构建、自测后交总工统一审核;审核合格部署独立测试版,再由白栖知验收。
本轮不是重做设计,也不是补齐全部后端:生活循环、真实记忆/备份、ASR/TTS、视觉观察与口型等依HEL-738后续切片接入,不以这些能力作为前端交付前置。真实能力与演示能力必须区分,尚未接通不能假称在运行。演示不能自动连接现有服务、申请麦克风或摄像头。
## 依据顺序
本次用户裁定优先于R2规范中的相关待定项和旧原型分支;其余业务以需求确认记录v1.6、最新主卡决定、HEL-738流程边界为依据。视觉以获批HEL-746 R2为依据,HEL-737私人手账稿已经否决。原型的临时初始主题不视为用户指定默认主题,保留两主题切换和用户选择。
真实后端未来提供数据时,未分享生活信息必须在服务器响应前裁剪;前端过滤不能冒充服务器权限控制。本轮只实现本地演示和适配边界,并在接线说明中明确该前置约束。
其余待定的天气来源、素材格式、HTTPS和观察时效等使用明确演示或可替换适配边界,不擅自接服务、买接口、装模型或新增产品规则。原型内演示控制只放演示模式,不混入正常产品流程。
@@ -0,0 +1,486 @@
# CyberGril 完整前端结构与可实施边界方案(HEL-738 · v1)
> 依据:HEL-697 首版需求确认记录 v1.6(31 项 CG + 9 项未定稿 T)、原仓库 CyberGril(commit `1e9b08a`,2026-10-19)实际代码、P21 源码阅读包。
> 性质:**只读分析 + 设计方案**。不写产品代码、不部署、不开施工卡。
> 状态标记(大白话区分):
> - 【已实现·有测试】仓库里真有,且测试守着
> - 【已实现·未接线】代码在,但没接真实后端/没人调用
> - 【仅规格】SPEC.md 里写了,代码没有
> - 【完全缺失】规格都没写或明确没有
> - 【拟议】本方案新提的接口,需要后续评审定稿
---
## 0. 一页结论(大白话)
**现有代码是一个"地基",不是一个产品。** 仓库里有:数据契约(7 个契约文件)、情绪动力学、八字编译、投影 φ、记忆存储、关系账本、模型路由——全部带测试。仓库里**没有**:服务器(api/ 目录不存在)、前端(一行都没有)、四个后台循环(tick/生活/整合/主动联系)、记忆检索与整合、表达管道、备份。所以"完整前端"不能假设任何后台已经在线,方案必须让前端在**纯 mock** 下就能完整设计、完整演示,接真后台是逐片替换的事。
**本方案的核心判断(对应 CG-031):**
1. 前端 = 一个"观看 + 交流 + 管理"的界面层,**它是服务器生活的视图,不是生活本身**。关掉网页她的生活继续(CG-008),回来做增量同步。
2. 画面(视频)是**生活状态的下游**:后端发"她在读书(晚上/晴/家居服)",前端据此选片播放;**前端永远不因为"手头有哪段视频"而反编生活事件**。
3. 视频/口型只是**一个可替换的呈现组件**(Presenter)。缺口型驱动器时,其余全部页面照常设计、照常演示;也不允许用全屏播放器冒充产品(CG-031)。
4. 所有界面数据带来源标签(mock / 契约已有 / 拟议),演示模式打"演示数据"角标,**不伪装已接通语音或记忆后台**。
---
## 1. 现有代码事实盘点(证据位置)
### 1.1 已有(【已实现·有测试】)
| 模块 | 文件 | 内容 | 对前端的意义 |
|---|---|---|---|
| 数据契约 | `src/cybergril/contracts/`:`state.py`(State 四维 v/a/d/e、Presence 枚举、StateBlock)、`percept.py`(Percept:speech_start/end、asr_partial/final、para、vision、env、system、silence)、`render.py`(RenderCmd:speak/expression/posture/idle/presence;visemes)、`context.py`(ContextBundle 六块)、`utterance.py`(text/speech_tags/withheld)、`audio.py`(AudioChunk、采样率)、`capabilities.py`(Capabilities/Degradation,"能力必须诚实、降级必须显式") | 全项目共同语言 | **WS 协议可以直接投影这些契约**,前端事件模型不用发明新概念 |
| 情绪动力学 | `core/mood.py`(209 行) | 衰减方程、负向偏置、麻木 | 状态是慢变量,前端展示"行为层"(她的样子),不展示数值 |
| 八字编译 | `core/bazi.py`(332 行) | 排盘→十神→derived 数值向量 | 创建向导的"生辰→底色预览"数据源 |
| 投影 φ | `core/projection.py`(131 行) | 系统事件→世界内陈述("刚才走神了") | **前端错误提示直接复用这套叙事**,不弹技术报错 |
| 记忆存储 | `memory/store.py`(200 行)+ `schema.sql` | SQLite 全套表(bazi_core、life_event、mem_episodic/semantic/relational、dyad_*、projection_gap)写入/衰减/FTS 查询 | 生活页、回忆页的表结构已定;`mem_vec` 向量表在 schema 里是注释(`schema.sql:183`),**向量检索未实现** |
| 关系账本 | `dyad/ledger.py`(108 行) | trust/intimacy/debt、未修复事件→行为偏移 | 关系设定页数据源 |
| 模型路由 | `think/router.py`、`scenes.py`、`budget.py`、`wiring.py` | 四档(none/low/standard/high)、场景→档位、预算降级 | 前端不用管;能力接口可透出"当前哪些档位可用" |
| Provider 抽象 | `providers/`:llm(echo/openai_compat)、embedding(openai_compat)、asr(scripted)、tts(silence)、turn(heuristic)、vad(energy)、avatar(**只有基类**) | 离线可跑通接口形状 | avatar provider 是空壳——**浏览器形象端从未实现** |
测试:`tests/` 共 7 个测试文件(contracts/providers/mood/bazi/projection/store/router)。
### 1.2 没有(对前端最关键)
| 缺失 | 证据 | 影响 |
|---|---|---|
| **服务器 api/** | `src/cybergril/` 下无 api 目录;SPEC 附录 A 的 `api/ws.py`、`session.py` 【仅规格】 | **没有任何 WS/HTTP 接口存在**,本方案第 5 节全部接口均为【拟议】 |
| **前端 web/** | SPEC 附录 A 设想 `web/src/Avatar.tsx` 等 【仅规格】,仓库无一行前端代码 | 前端从零开始,技术选型自由(见 5.1) |
| **四个常驻循环** | `core/` 下无 tick.py/life.py/reachout.py(README"下一步"明列未做) | "她在生活"目前没有运行时——前端演示必须 mock |
| **记忆检索/整合** | 无 retrieval.py/consolidation.py;store.py 只有 FTS 搜索 | 回忆页接真是后期切片 |
| **表达管道 express/** | 不存在;TTS 只有 silence 占位 | 语音会话依赖已有 ASR/TTS 服务另行接线(T-05) |
| **城市/天气** | 无任何代码(T-07 未定稿) | 环境条数据源待定 |
| **备份** | SPEC §11.3 【仅规格】 | 管理页的导出/恢复是拟议接口 |
### 1.3 与旧仓库冲突处,按 v1.6 用户最新决定执行
- 旧 SPEC §8.2 / `config/models.yaml:130-134` 的 VRM+three.js 形象路线 → 由 CG-002/029 覆盖为"成品视频为主、呈现器可替换";`RenderCmd` 契约保留作为指令语言。
- 旧仓库"重大经历微调出生核心"(README 三层表里 derived 可被整合器 ±0.02 微调)→ 按 CG-015 收缩为"成长层另存,八字底色只读";前端身份卡不提供任何 derived 编辑入口。
- 旧样图/卧室构图 → 只作视觉参考(CG-031),布局、导航、信息层级围绕使用流程重新设计。
---
## 2. 产品形态与用户流程(CG → 流程映射)
### 2.1 产品一句话
单一伴侣(CG-007)、电脑优先兼顾手机(CG-018)、首版内网访问(CG-020);她有后台生活(CG-008),写实成品视频呈现日常(CG-029),交谈时切到同一人物的实时半身画面(CG-004/028);文字常驻、语音可开可打断(CG-010);网页内主动联系(CG-011);生活与共同回忆可见(CG-008/015);创建一次、八字锁定(CG-012/013);外观可换人不换(CG-005/006);每日自动备份 + 手动全量导出恢复(CG-009)。
### 2.2 九条关键流程(每条:触发 → 界面行为 → 数据来源)
**F1 首次创建(CG-012/013/007)**
空库 → 创建向导:① 输入她的出生年月日+时辰(可"自动生成一组"按钮,可手动改)→ ② 实时预览排盘结果与底色倾向(结构化指标+一句白话解释,CG-013/014:展示"倾向",不展示死板台词)→ ③ 设定初始关系(朋友/恋人、称呼;默认值待用户定,T-02)→ ④ 确认。确认即锁定:八字部分永久只读,界面上不出现任何"编辑底色"入口。已有伴侣时创建入口直接禁用(单伴侣守卫),不提供切换 UI。
数据:排盘预览来自 `core/bazi.py`【已实现】;"创建后进入她已在过自己的生活"首屏叙事由生活事件回填(mock 阶段用演示数据)。
**F2 正常打开(CG-008/029)**
进入 → 首次交互门(浏览器自动播放策略需要一次点击,正好用作"推门进屋"的仪式感)→ WS hello/sync → 显示:她当前的活动(视频舞台)、环境条(城市时段/天气)、在场状态、未读的她的话。视频按 scene_key 从素材清单选片,选不到就走降级链(见 6.2),**不黑屏**。
**F3 离开后返回(CG-008/011)**
网页关闭期间服务器继续生活(前提:服务器没停机,CG-008 边界)。返回时 sync 带 `since` 游标 → 增量拉取:期间的生活事件时间线、她留的离线消息("你不在的时候"列表)、关系/状态变化。**缺席可被提及、不可被武器化**(SPEC §10.2 红线)——离线消息展示语气遵循投影 φ,不显示"我等了你好久"式内容(后端约束,前端只展示收到的内容)。
**F4 主动消息(CG-011)**
在线时:页面内非侵入提醒(角落轻提示 + 舞台她转向镜头的呈现,不打断用户当前操作)。离线后回来:并入 F3 的"你不在的时候"。首版无推送(CG-011 边界)。
**F5 语音会话:开始/结束/打断(CG-010/028/022)**
- 开始:点麦克风 → 授权提示(需 HTTPS 安全上下文,T-07)→ 会话建立 → 舞台切到交谈近景(过渡素材)→ 连续收音。
- 进行中:字幕流(asr_partial 实时显示,contracts `percept.py` 的事件形状)→ 她回复(chat.delta + 音频流 + 可选口型驱动)。
- **打断**:用户开口 → 服务端/客户端 VAD 判定 → 她 150ms 内停(SPEC 目标值,实测待定)→ 界面给"她停下来了"的轻反馈。
- 结束:点停止或闲置超时 → 停止收音 → 舞台回到日常活动 → 回到文字模式。
- 全程文字输入框不消失(CG-010:语音与文字并存)。
**F6 断线恢复**
WS 断开 → 界面进入"连接中"(她的画面停在最后一帧或低动效待机,不显示技术错误,用 φ 文案如"信号不太好")→ 指数退避重连 → 重连后带 epoch+游标重同步,期间的消息与事件按序补齐;语音会话中断则安全收尾(停收音、释放麦克风)。**旧轮结果隔离见 5.4。**
**F7 城市改变(CG-023)**
管理页 → 选择城市 → 保存 → 环境条与作息节奏切换到新城市;下次选片按新天气/时段走。**无需重建伴侣、不动记忆**。城市清单与天气源【拟议,待 T-07】。
**F8 素材替换(CG-005/006/030)**
管理页 → 素材包导入:上传素材包(含 manifest)→ 校验(格式、时长、命名、人脸一致性标记)→ 预览对比 → 应用。**只换外观:manifest 的 appearance_id 变,身份/八字/记忆/关系全部不动**(CG-006)。素材包规范 v1【拟议】(T-03),首批素材由小队制作(CG-030),导入器首版只收本地文件,无云端付费接口(CG-019)。
**F9 备份恢复(CG-009)**
管理页 → 备份区:显示每日自动备份状态(份数/空间策略【T-06 待定】);"完整导出"=下载数据库+素材包引用+设定到用户电脑;"从备份恢复"=上传包 → 校验(完整性、版本兼容、八字指纹比对:恢复的必须是同一位她)→ 确认恢复(恢复前自动再做一次当前状态导出,可反悔)。
### 2.3 明确不做(首版界面不出现)
多伴侣切换/并列(CG-007);创建后编辑八字底色(CG-013);通用人物提示词编辑器(CG-016 边界:可改的只有关系设定);云端付费生成接口入口(CG-019);手机推送(CG-011);外出场景/多房间(CG-017);把视频全屏铺满当产品(CG-031)。
---
## 3. 页面/区域结构(四类要素 → 页面模型)
### 3.1 布局模型(电脑优先,CG-018)
```
┌────────────────────────────────────────────────┬──────────────┐
│ │ 侧栏(可收起) │
│ 舞台 StageArea │ · 对话流 │
│ (可替换呈现器:视频/交谈/待机/降级图) │ · 文字输入 │
│ │ · 语音控制 │
│ 底部:环境条(时段·天气·城市)+ 她的状态(行为层) │ · 快捷入口 │
└────────────────────────────────────────────────┴──────────────┘
导航(左上/顶栏):相处(主界面)|她的生活|回忆|管理
```
- 舞台**不是**全屏播放器:它是产品的一个区域组件,四周永远有产品界面(导航、状态、输入),CG-031 明确禁止"先铺满场景再叠界面"。
- 手机端:舞台置顶 + 对话全屏切换(标签式),保画面、文字聊天和连续语音(CG-018),管理/回忆功能折叠进"更多"。
### 3.2 四类要素的落位(CG-031)
**P1 相处主界面**(默认页):舞台 + 环境条 + 在场状态 + 对话侧栏。承担 F2/F3/F4/F5 的日常界面。
**P2 交流**(与 P1 融合,不单独成页):对话流(文字气泡,她的消息支持副语言标签的可视化由视觉总监定)、语音会话控制器、字幕条、打断反馈。交谈时舞台自动切近景(CG-004)。
**P3 她的生活与共同回忆**(一页两栏或两标签):
- 她的日子:生活事件时间线(按天分组,`life_event` 表已有 told 标记→"已经跟你说过的"弱化显示);
- 共同回忆:mem_episodic(记得的片段)、mem_relational(你们的习惯/暗语/仪式)、mem_semantic(她知道的事)分层展示;成长记录(CG-015:初始喜好 vs 后天成长分开两段)。
**P4 创建与管理**:创建向导(仅首次);日常为管理面板:关系设定(CG-016)、城市(CG-023)、素材包(F8)、备份恢复(F9)、视觉观察开关(CG-022:"一扇窗",开/关状态与最近观察摘要,时效性标注)。
### 3.3 状态流转(mermaid)
```mermaid
title 用户会话与舞台状态流转
stateDiagram-v2
[*] --> ColdStart: 打开页面
ColdStart --> Connecting: WS hello/sync
Connecting --> Syncing: 已连接
ColdStart --> FirstCreate: 无伴侣(仅一次)
FirstCreate --> Syncing: 创建完成
Syncing --> DailyLive: 同步完成
DailyLive --> Talking: 语音开始/用户发文字
Talking --> DailyLive: 会话结束/她答完
DailyLive --> Offline: 断线
Talking --> Offline: 断线
Offline --> Syncing: 重连(带游标)
DailyLive --> AwayView: 用户闲置(她继续活动)
AwayView --> DailyLive: 用户回来
```
舞台呈现器内部状态(与上正交):`daily_clip → transition → talk(可带口型驱动) → transition → daily_clip`;任一态可降级为 `static_image`。
---
## 4. 统一生活状态与数据来源表
### 4.1 生活状态 LifeScene【拟议——契约已有部分投影】
后端是唯一事实源。前端收到的是一个"场景描述",而不是"播放某视频"的指令:
```json
{
"epoch": 41,
"presence": "idle_daily", // 投影 contracts/state.py Presence + 日常态
"scene": {
"mode": "daily", // daily | talk | away | asleep
"activity": "reading", // 来自 life_event/schedule
"time_band": "evening", // 由城市时区计算
"weather": "clear", // 城市天气源(T-07)
"outfit": "home"
},
"visible": "平静", // StateBlock.visible:一个词,不展示 v/a/d/e 数值
"since": "2026-10-21T19:40:00+08:00"
}
```
**铁律三条:**
1. **媒体是下游**:`scene.change` 事件驱动前端选片;前端/选片器**不产生** life_event,也不因素材缺失请求后端改生活事件(素材缺失走降级链,见 6.2)。
2. **前端是视图**:关网页不等于停机(CG-008);前端本地不保存权威状态,重连必重新 sync。
3. **状态展示走行为层**:SPEC §8.5"不要把状态画成仪表盘"——展示 visible 词、姿态、回复快慢、活动内容;数值只进调试模式(工程开关,默认隐藏)。
### 4.2 各区域数据来源一览
| 界面区域 | 数据 | 来源现状 |
|---|---|---|
| 舞台-日常 | scene + 素材 manifest | manifest【拟议】;选片【拟议】;scene 数据 life_event 表【已实现·表结构】但生成循环【完全缺失】 |
| 舞台-交谈 | 音频流 + visemes(可选) | RenderCmd.speak(ts, audio_ref, visemes) 契约【已实现】;驱动器【HEL-739 独立研究,非门禁】 |
| 对话流 | 文字 + speech_tags | Utterance 契约【已实现】;生成管道(think/context+generate)【仅规格】 |
| 字幕条 | asr_partial/final | Percept 契约【已实现】;真实 ASR 接线【T-05】 |
| 她的日子 | life_event 列表 | 表【已实现】;每日生成循环【完全缺失】 |
| 共同回忆 | mem_episodic/relational/semantic | 表与写入【已实现】;检索/整合【完全缺失】(FTS 查询有,向量检索注释着) |
| 关系卡 | dyad_state | 表与账本【已实现】;循环推进【缺失】 |
| 身份卡(只读) | bazi_core + derived 白话化 | bazi.py【已实现·有测试】 |
| 环境条 | 城市/时段/天气 | 【完全缺失】拟议 GET /api/env |
| 能力徽章 | mock/real 标签 | 投影 contracts/capabilities.py 哲学【拟议端点】 |
---
## 5. 前端技术选型、组件边界与接口示例
### 5.1 技术选型(够用且不过度设计)
- **React 18 + TypeScript + Vite**。理由:SPEC 附录 A 原设想即为 React 组件;单一桌面+移动 Web 应用,无需 SSR/Next;Vite 开发热更新快,内网部署就是静态文件 + 反代。扩展:将来要 3D/二维呈现器时按 Presenter 插件加库,不预先引入。
- **状态管理:zustand(或 React 内置)**。单伴侣单用户应用,全局状态很小(连接、场景、会话、UI 偏好),不需要 redux 级别机械。
- **样式:视觉总监定的 Token 体系落地为 CSS Variables + 常规 CSS(或 CSS Modules)**。风格不由本方案锁定。
- **不引入**:Electron(Web 已够)、大型 UI 库全家桶(组件不多,可控成本内手写 + 少量无头组件)、测试框架之外的重型设施(Vitest + Testing Library 足够)。
- 部署形态:静态构建产物由部署服务器(192.168.200.12)反代承载,与后端 WS 同源,避免跨域与混合内容问题【拟议,部署细节后续定】。
### 5.2 组件清单与职责边界
```
web/src/
app/ 布局、路由、导航、首交互门(自动播放策略)
connection/ WSClient(重连/退避/epoch)、SyncStore、useLiveState
stage/ StageArea(容器,唯一决定"用哪个呈现器")
stage/presenters/
DailyVideoPresenter 成品视频选片+双缓冲切换+过渡素材(CG-029)
TalkPresenter 交谈呈现:音频播放+可插接口型驱动器(CG-028/031)
FallbackPresenter 静态图/低动效待机(降级链末端,永不黑屏)
chat/ ChatPanel、MessageList、Composer(文字常驻)、SubtitleBar
voice/ VoiceSessionController(开始/结束/打断/权限)、
BargeInIndicator、MicPermissionGate
life/ LifeTimeline(她的日子)、GrowthPanel(初始喜好vs成长)
memory/ MemoryView(三层回忆)
manage/
CreationWizard 生辰→排盘预览→关系初值→锁定(F1)
RelationSettings 关系设定(CG-016 范围内)
CityPicker 城市(F7)
MaterialImporter 素材包导入/校验/预览(F8)
BackupPanel 备份/导出/恢复(F9)
VisionWindowToggle 视觉观察开关+摘要(CG-022)
shared/ CapabilityBadge(mock/real 角标)、PhiToast(φ 叙事化提示)、
StatusStrip、EnvBar、Skeletons、EmptyStates
```
**Presenter 接口(TS,本方案关键边界):**
```ts
interface StagePresenter {
mount(el: HTMLElement): void;
applyScene(scene: SceneDesc): Promise<void>; // 幂等;内部处理过渡
degrade(reason: string): void; // 显式降级,触发 FallbackPresenter
destroy(): void;
}
interface TalkPresenter extends StagePresenter {
attachSession(s: VoiceSession): void;
feedAudio(chunk: AudioPcmChunk): void; // 播放她的话
feedVisemes(v: number[]): void; // 有驱动器才被调用;无则忽略
interrupt(): void; // 目标 <150ms 停声停像
}
// 口型驱动器是 TalkPresenter 的注入依赖,可替换:
createTalkPresenter({ lipsync: null | 'local-w4w' | 'future-x' })
// lipsync=null 时:交谈画面退为"预设交谈近景素材/静态图 + 音频",
// 全部其他页面与流程不受影响(CG-031:不等待、不伪装)。
```
### 5.3 接口事件示例【全部拟议——api/ 完全缺失,形状投影现有契约】
一条 WebSocket(JSON 控制消息 + 二进制音频帧交错),加少量 REST。
```jsonc
// C→S
{"t":"hello","proto":1,"epoch":40,"caps":{"audio":"opus","video":false}}
{"t":"chat.send","turn_id":"c7-12","text":"今天过得怎么样"}
{"t":"voice.begin","session_id":"v3"} // 请求开麦(授权在浏览器层)
{"t":"voice.end","session_id":"v3"}
{"t":"sync","since":"2026-10-21T08:00:00Z"} // 断线/返回后增量
// S→C
{"t":"sync.done","epoch":41,"companion":{...},"scene":{...},"unread":[{"kind":"reach_out",...}]}
{"t":"scene.change","scene":{"mode":"talk","activity":"talking","time_band":"evening","weather":"clear","outfit":"home"},"reason":"voice_session"}
{"t":"presence.update","presence":"speaking"}
{"t":"chat.delta","turn_id":"c7-12","delta":"今天"}
{"t":"chat.final","turn_id":"c7-12","utterance":{"text":"今天挺好的,下午看了一会儿书[laughter]","speech_tags":["laughter"],"emotion_intent":"warm"}}
{"t":"asr.partial","session_id":"v3","text":"那你还想"}
{"t":"asr.final","session_id":"v3","text":"那你还想不想看电影"}
{"t":"interrupt.ok","session_id":"v3"} // 她已停
{"t":"reach_out","message":{"id":"r9","text":"刚看到窗外的晚霞,想跟你说一声","based_on_life_event":1234}}
{"t":"degraded","cap":"tts.speech_tags","reason":"provider_silent","phi":"(这句她的声音少了点语气)"}
{"t":"error","scope":"chat","retryable":true,"phi":"我没听清,你再说一遍?"}
```
音频帧:二进制帧头 8 字节(session_id 哈希 + seq + 方向),opus/pcm16k;细节施工时定稿。
REST【拟议】:`GET /api/companion`(只读档案)、`POST /api/companion`(创建,已存在则 409)、`PATCH /api/relation`、`GET|PUT /api/env/city`、`GET /api/media/manifest`、`POST /api/media/import`(multipart 素材包)、`GET /api/capabilities`(各能力 mock/real 标签,前端据此打角标)、`POST /api/backup/export`(下载 zip)、`POST /api/backup/restore`(上传 zip + 校验报告)。
### 5.4 取消与旧轮结果隔离(防"过期回答覆盖新问题")
1. **turn_id 配对**:客户端每个 chat.send 生成 `客户端id-自增`;所有 chat.delta/final 必须回带同 turn_id。用户放弃输入/新问题时丢弃不匹配的流;渲染层只认"当前活跃 turn_id"。
2. **epoch 门槛**:sync.done 携带 epoch(后端重启/状态纪元变化时 +1);收到旧 epoch 消息一律丢弃并触发一次 resync。
3. **语音会话隔离**:一切 asr/tts/interrupt 事件带 session_id;会话结束后的迟到帧全部丢弃;interrupt 后同 session 内更早 seq 的音频块不再播放。
4. **HTTP**:管理页所有请求走 AbortController,页面切换/重复点击即取消;导出/导入等长任务用任务句柄轮询,不悬挂按钮。
5. **重连风暴保护**:指数退避 + 抖动;重连成功先 sync 再恢复输入框可用。
### 5.5 错误与降级展示(φ 叙事化,不用技术黑话)
- 原则来自 `contracts/capabilities.py`:**降级必须显式**;但显式给的是"世界内"表达,不是堆栈。展示分层:φ 文案("她好像走神了,稍等")为主,次要系统徽章("语音暂不可用")为辅,详细原因收进"工程信息"抽屉。
- 视频素材缺失:FallbackPresenter 静态图 + 轻动效,环境条照常,绝不黑屏、绝不停摆其他区域。
- 语音不可用(无 HTTPS/拒授权/服务离线):麦克风按钮置灰 + φ 文案,文字聊天完全可用。
- 后台不可达:连接态横幅(弱提示),本地保留未发出的文字草稿,重连后由用户重发(不自动重发,避免陈词滥调式重复)。
---
## 6. 媒体适配边界
### 6.1 素材清单 manifest【拟议,规范 T-03 待定稿】
```jsonc
{
"manifest_version": 1,
"appearance_id": "her-v1", // 换外观=换这个 id(CG-006)
"clips": [
{"id":"daily__reading__evening__clear__home",
"src":"clips/daily_reading_evening_clear.mp4",
"scene":{"mode":"daily","activity":"reading","time_band":"evening","weather":"clear","outfit":"home"},
"loop":true,"duration_s":12.5,"in_point":0.0,"out_point":12.5}
],
"transitions":[{"id":"daily_to_talk","from":"daily","to":"talk","src":"clips/tr_daily_talk.mp4"}],
"talk_base":{"src":"clips/talk_base.mp4","loop":true} // 无口型驱动器时的交谈底
}
```
- 键 =(mode, activity, time_band, weather, outfit)五元组;**逻辑状态与素材解耦**(CG-017:活动/天气是逻辑状态,不用选片篡改生活记录)。
- 首版范围:一间高层卧室(CG-026 只作视觉参考)、少量服装/动作(CG-017/030),素材数量随试片扩充。
### 6.2 选片与降级链(顺序固定)
`精确匹配 → 去掉 weather → 去掉 outfit → 换近似 activity → FallbackPresenter 静态图`。每级降级在工程抽屉记录(对齐 projection_gap 的"泄漏可统计"思想),**不向生活状态倒灌任何东西**。
### 6.3 口型/交谈驱动器(可替换,非门禁)
- 驱动器是 TalkPresenter 的注入实现,候选(LiveTalking/MuseTalk/Ditto 等)由 HEL-739 独立研究选型【候选·未测】,本方案不预设结论。
- 无驱动器时:交谈呈现 = talk_base 素材(预设少量身体动作,CG-028:口型/表情/目光优先,身体预设)+ 音频 + 字幕;**产品完整可用**。
- 驱动器上线时只改注入参数,不动任何页面结构(CG-031:视频/口型仅一种可替换呈现组件)。
- 真人→二维的条件判据未定(CG-027),Presenter 抽象同时兼容二维素材包,不需要结构变更。
### 6.4 播放工程
双 `<video>` 缓冲交替切素材实现无黑帧;transition 素材垫在模式切换处(日常↔交谈,CG-004 要求自然切镜、不突兀跳切);移动端降码率副本可选(不做首版承诺);自动播放策略由首交互门解决。
---
## 7. Mock 与真实:能力标签、最小演示数据
### 7.1 能力标签(诚实三档)
每个数据区域绑定标签,来自 `GET /api/capabilities`(mock 模式下由演示脚本静态给出):
- `mock`:演示数据,UI 打"演示数据"角标(如生活时间线右上角小字);
- `real:<tier>`:真实后端(标注来源档位,如 real:echo / real:high);
- `unavailable`:能力缺失,显示 φ 化占位("这部分还在准备"),**绝不假装在线**。
演示模式全局开关:`?demo=1`。演示脚本(一个定时器驱动的假 WS 层)按剧本推 scene.change / chat.final / reach_out,让**全部九条流程(F1–F9)在没有后端的情况下走通**——这是"界面和原型阶段与真实功能施工分开"的机制保障。
### 7.2 最小演示数据约定(demo pack)
```
demo/
capabilities.json # 全部能力标 mock
companion.json # 一位已创建的她(含八字只读档案、白话底色)
life_events.json # 3 天 × 每天 1–4 条(含 1 条高显著未讲述→主动消息素材)
chat_backlog.json # 6 轮对话(含 1 条带 [laughter])
reach_out.json # 1 条主动消息
manifest.json # 4 段占位素材(早/昼/晚/夜 reading)+ 1 段过渡 + talk_base
clips/*.mp4|*.png # 占位片可用静态图代替(降级链自动接管)
timeline_script.json # 演示剧本:事件发生顺序与时刻
```
### 7.3 阶段划分(设计/原型 ≠ 真实施工)
| 阶段 | 内容 | 用什么数据 |
|---|---|---|
| A 设计 | 视觉总监产出完整界面稿与交互原型(HEL-737) | demo pack(本方案 7.2) |
| B 原型验证 | 前端壳 + mock WS 跑通 F1–F9 | demo pack |
| C 真实施工 | 按第 8 节切片逐片接真 | 逐片换 real 标签 |
---
## 8. 施工切片与验收清单(供总管排期,不在此开卡)
> 前端能立的切片尽量先立;每片验收要点独立,对齐 CG-024 的"分阶段验收"。
| # | 切片 | 依赖的后端(现状) | 验收要点 |
|---|---|---|---|
| S1 | 前端壳+路由+demo pack | 无 | F1–F9 全流程在 ?demo=1 走通;降级链全程无黑屏;手机布局不崩 |
| B1 | API 骨架:WS hello/sync/chat + REST | api/【完全缺失】→ 新建 | echo 级应答;epoch/游标正确;断线重连补齐 |
| B2 | tick+life 循环 | core/tick.py【缺失】 | 离线数小时后回来,时间线有新事件且与状态一致 |
| S2 | 文字聊天接真 | think 上下文组装+生成【仅规格】 | chat 流式;turn_id 隔离(旧回答不串台);φ 化错误 |
| S3 | 语音会话接真 | ASR/TTS 接线【T-05 待核实】 | 连续对话;打断停得下来(目标值待实测);结束即停收音 |
| B3 | 记忆检索+整合 | retrieval/consolidation【缺失】 | 回忆页三层数据真实;"她记得"与对话一致 |
| S4 | 生活/回忆页接真 | B2/B3 | 时间线、told 标记、成长分层展示正确 |
| S5 | 管理:城市/素材/备份 | env、media、backup 端点【拟议】 | F7/F8/F9 全走通;换素材后身份卡不变 |
| S6 | 视觉窗 | 4080 小视觉服务【用户部署,T-04/05】 | 开关生效;摘要带时效;关闭显示"窗户关上了" |
---
## 9. 与视觉总监(HEL-737)的对齐输入
**我方提供(本方案已定,视觉总监可直接取用)**:第 3 节页面模型、3.3 状态图、5.2 组件清单、7.2 demo pack、第 4 节各区域数据字段。视觉风格、Token、构图**由视觉总监决定**,不受旧样图锁定(CG-031)。
**需要视觉总监设计时覆盖的状态清单**(每态都需要可识别的视觉表达):
- 在场/呈现:日常活动、交谈中、待机(away)、睡眠(asleep)、降级占位
- 连接:正常、连接中、离线(φ 语气)
- 语音会话:未开启、授权中、聆听中、她说话中、被打断、已结束、不可用
- 对话:她正在输入(流式)、已说完、发送失败(可重试)
- 场景维度:早/昼/晚/夜 × 晴/阴/雨 × 居家/外出服(素材命名的视觉对照)
- 流程态:创建向导各步、导入校验中/成功/失败、备份进行中、恢复确认(高危操作的双确认样式)
- 空态:无回忆、无生活记录(新生的她)、无未读
- 角标体系:mock"演示数据"、降级徽章、视觉观察"窗"状态
**需要视觉总监回传给我方**:Token(色/字/圆角/间距/动效曲线)、桌面与手机两版布局定稿、过渡动效规范(含打断的即时反馈)、素材安全框(视频区域内 UI 避让区)——这些直接决定 5.1 样式层的实现参数。
---
## 10. 风险与尚待决定项
**风险(前端相关):**
| 风险 | 影响 | 缓解 |
|---|---|---|
| 内网 HTTPS 方案未定(T-07) | 麦克风/摄像头不可用,语音/视觉窗全灭 | S1 阶段就出"HTTP 降级态"设计;证书方案尽早定(自签+设备安装或内网 CA) |
| 素材制作跟不上切片 | 演示/施工期素材缺口 | 降级链 + 占位图规范进 demo pack;素材数量按 CG-030 试片后再定 |
| 视频自动播放/移动端功耗 | 首屏黑屏、手机发烫 | 首交互门;移动端默认低动效可选 |
| 口型研究(HEL-739)失败或延期 | 交谈近观感打折 | Presenter 已解耦:talk_base+音频先行,不阻塞任何页面(CG-031) |
| WS 重连边界(epoch/游标设计错) | 状态错乱、串台 | 5.4 五条隔离规则 + B1 验收专测 |
| 素材 manifest 版本漂移 | 升级后选片失配 | manifest 带版本,导入校验含兼容性检查 |
| 长时间线数据量 | 生活页变慢 | 分页 + 按天聚合(对齐 state_snapshot 90 天聚合思想) |
**尚待用户/总管决定(不在本卡解决,汇总供决策):**
1. 关系初始值与编辑形态(T-02):默认初识还是情侣、表单还是引导式对话设定;
2. 城市清单与天气数据源(T-07);缺失天气素材时的默认呈现;
3. 内网可信访问(HTTPS)具体方案;
4. 素材包规范 v1 定稿(T-03):格式/时长/命名/人脸一致性检查项;
5. 单伴侣"重新创建/清空"规则(T-06)——影响创建守卫与高危操作设计;
6. 语音服务(ASR/TTS)流式与打断能力的实测参数(T-05),决定 F5 的实际体验目标;
7. 主动消息频率参数化取值(CG-011:原仓库 reach_out 参数可作起点:间隔≥6h、8–23 点)。
---
## 11. 附录:CG-001~031 → 界面/流程映射总表
| CG | 落点 |
|---|---|
| 001 需求边界 | 治理原则,无界面 |
| 002 写实风格 | 舞台呈现器路线(视觉风格归 HEL-737) |
| 003 混合路线 | manifest 选片器 + TalkPresenter |
| 004 交流镜头 | 日常↔交谈转场与过渡素材 |
| 005 工程外观/自备素材 | MaterialImporter + 校验预览 |
| 006 换外观不换人 | appearance_id 机制;身份卡不可变 |
| 007 单伴侣 | 创建守卫;无切换 UI |
| 008 后台生活 | LifeScene 唯一事实源 + 返回增量同步 |
| 009 备份恢复 | BackupPanel(F9) |
| 010 连续语音+文字 | VoiceSessionController + Composer 常驻 |
| 011 主动联系 | 页内轻提醒 + 离线消息列表(F3/F4) |
| 012 生辰创建 | CreationWizard 步骤①② |
| 013 底色只读 | 身份卡只读 + 无编辑入口 |
| 014 诠释空间 | 底色展示为倾向词+白话解释 |
| 015 初始习惯与成长 | GrowthPanel 两段分层 |
| 016 关系可调 | RelationSettings(仅关系范围) |
| 017 场景范围 | scene_key 维度约束(一间卧室) |
| 018 电脑优先 | 布局模型 + 手机适配 |
| 019 素材费用 | 仅本地素材包导入,无付费入口 |
| 020 内网访问 | 部署约束 + HTTPS 待定项 |
| 021 按任务选模型 | 能力接口透出,前端不感知 |
| 022 摄像头范围 | VisionWindowToggle + 时效摘要 |
| 023 城市作息 | CityPicker + EnvBar |
| 024 验收顺序 | 第 8 节切片验收对齐 |
| 025 人物样图 | 视觉参考(CG-031),不锁布局 |
| 026 卧室样图 | 同上 |
| 027 真人优先二维备用 | Presenter 抽象兼容两种素材包 |
| 028 交谈动作范围 | TalkPresenter 动作范围=预设;口型表情优先 |
| 029 成品视频主路线 | DailyVideoPresenter 默认实现 |
| 030 首批素材分工 | manifest v1 由小队产出 + 导入检查 |
| 031 设计依据 | 本方案组织原则:流程先行、样图仅参考、嘴型独立 |
---
*HEL-738 · 总工 · 2026-10-21 · 本卡只交方案,未改任何业务代码/配置/PR,未启动 GPU/模型/服务,未访问 4080,未建施工卡。*
@@ -0,0 +1,95 @@
本稿供白栖知确认执行办法;不重新表决已经确认的需求,不代表界面已经验收,也不把任何未完成能力写成已实现。
## 要解决的问题
完整需求已经经过多轮逐项确认,但项目说明目前只有简介,项目没有绑定资源。需求记录、后续裁定和设计规范散在旧卡、附件中。新任务卡容易只继承最近一条反馈,施工和审核也容易把局部完成误当成整体满足需求。
本次建立统一、可追溯的需求入口。只有白栖知明确修改的要求才能覆盖原要求;缩小某阶段施工范围、技术做不到、模型登录失效、演示通过,都不能成为改需求的理由。
## 一、把完整需求留在项目中
建议在正式仓库建立以下材料,并以同一个入口链接它们:
| 材料 | 保存什么 | 如何使用 |
| --- | --- | --- |
| `docs/requirements/README.md` | 当前有效版本、完整需求入口、来源、未定项、验收状态 | 所有任务开工前先读 |
| `docs/requirements/current.md` | 所有有效产品要求、视觉要求、后续明确裁定,每条有编号和来源 | 按主题完整阅读,不能只读本卡摘录 |
| `docs/requirements/sources/` | 原始确认记录、已认可设计和主卡裁定 | 核对含义和解决冲突,保留历史 |
| `docs/requirements/changes.md` | 用户原话、来源评论、旧要求、新要求、影响范围 | 只有用户明确改变要求时才更新结论 |
| `docs/requirements/coverage.md` | 每项要求对应的实现、验证证据和未完成部分 | 每次派工、审核、交付都更新 |
整理依据包括需求确认记录 v1.6 中的 31 项产品决定、后续完整视觉访谈与获批设计、整套设计验收的三项补充,以及真实功能接入批次。不能把 v1.6 的旧待定项原封不动当成当前状态,例如首次关系已明确由用户选择。
完整当前文档必须保留以下层次:已确认要求、用户明确覆盖的旧要求、未确认提议、技术待验证项、实现与验收状态。后两项不能冒充产品决定。
原始对话来源用主卡评论链接或评论编号逐条保留。整理发现意思不一致时先核对原话;只有原话仍无法解决的真实歧义才问白栖知,不重问已经回答的选择题。
## 二、让新卡也必须读到它
### 项目级说明草案
保留原项目简介、仓库和部署信息,补充以下约束,并把仓库绑定到项目资源:
> 本项目的产品约定以正式仓库 `docs/requirements/README.md` 指向的完整当前需求为准。每张新卡、续办卡、设计卡、施工卡和审核卡,开始前必须获取并阅读该入口及相关原始来源。只有白栖知明确的新决定可以修改需求,必须记录来源与变更;阶段范围不能覆盖完整需求。任务必须列出基准版本、对应需求编号、交付内容和暂未实现项。阶段或演示通过不代表项目完成。发现本卡与完整需求冲突,先指出冲突,不擅自降低要求。
项目说明随项目任务提供,仓库中的规则负责后续设计、施工与审核。两处同时保留入口,避免只依靠某个会话的记忆或旧卡附件。CyberGril 的后续任务均应归属这个项目;归属错误也应在派工前纠正。
### 仓库必读规则草案
在仓库自有的 `AGENTS.md` 中加入以下要求,不改运行环境自动管理的指令区:
1. 开始前阅读需求入口、完整当前需求与本次关联的原始来源,记录所用版本及提交位置。
2. 本卡的摘录是施工指引,完整需求仍有效。不得因本卡没写某条就把它删掉。
3. 需求、实现方案、阶段范围分别记录;施工者不能把自己的方案改写成用户决定。
4. 实现和审核都提交需求对照表。发现旧代码偏离要求,记为欠缺,不把现状倒推成需求。
5. 用户要求的体验不能用另一种较容易实现的体验替换,例如不能把“当前一两句、历史按需展开”换成长消息滚动窗。
6. 修改需求文档须附白栖知明确批准的来源和影响说明;只有智能体提议或认可不能通过审核。
### 每张卡的固定交接项
- 基准:当前需求版本与仓库提交位置。
- 本次关联要求:编号、原意、相关来源。
- 本次交付:做到什么程度、怎样验收。
- 暂未实现:对应要求仍有效,后续由哪张卡承接。
- 需求变更:没有则写“无”;有则附白栖知明确裁定,不能只写“方案调整”。
- 审核证据:功能验证、真实画面、必要的动态过程,以及实际未验证的设备与场景。
现有未完工卡也补上这些交接项,不只约束新卡。
## 三、把约束带进验收
在仓库检查中核对需求编号、版本、必要交接项和需求变更来源是否缺失;审核人再对照原文判断实现是否符合。自动检查只能发现缺项和失配,不能判断设计是否有沉浸感,也不能代替白栖知验收。
每次交付分别写明“做了什么”“验证了什么”“还差什么”。演示验收、某批接入验收、整项目验收分开记录。全部功能未完成前,不再建议把主卡当成已完成归档。
## 四、当前前端返工聚焦两件事
### 对话显示效果
| 已确认要求 | 小样与验收必须看见的效果 |
| --- | --- |
| 展开交流后以人物为主 | 人物自然放大,与相处状态之间有连续的空间关系,退出后回到原处 |
| 默认只呈现当前一两句 | 长回复也有可读完的呈现方案;不裁掉内容,不强迫主界面堆成长聊天记录;完整记录按需展开 |
| 双方身份清楚 | 实际创建名字和用户身份可辨,不能只靠左右位置猜;完整记录同样清楚 |
| 文字柔和出现和退场 | 展示短句、长句、连续来句与发送过程的真实动态,能读清,不因每次更新反复闪烁 |
| 手机能完整交流 | 小屏、长句、弹出键盘时文字和输入操作不被人物、导航或底部区域挡住 |
长回复如何分句、停留多久、能否提前切换等属于待验证的交互方案,先用可操作的小样展示,不在此稿冒充用户已选规则。
### 整体沉浸界面
继续沿用已确认的两套主题、轻盈文字、透光浮层、底部小巧导航和空间优先的层次。空间应有光雾、景深、若隐若现的景物与舒缓变化;操作时有连续的空间过渡。人物是其中的独立视窗,完整界面覆盖相处、交流、生活回忆、创建管理。
验收看整个使用过程:进入空间、安静停留、展开交流、查看记录、进入生活回忆或管理、返回空间,以及两主题切换。滚动条、弹层、加载、失败和未接通状态也要融入同一视觉。不能只交一张好看的首页或只改聊天框。
涉及未接通能力,小样明确使用演示状态;其真实功能继续按既定批次接入。不能为了沉浸效果伪造她已经发生的生活、记忆或服务运行。
先交电脑与手机的相处、交流样图,并附能操作的动态小样,展示两主题、长句、身份区分、历史展开、交流开合及空间过渡。由白栖知判断是否达到原先想要的体验,认可后才替换正式界面;不重新表决已经认可的视觉选择。
## 五、本次拟修改范围及影响
确认后先落地项目说明、项目仓库资源、仓库需求入口和必读规则、已有任务的需求对照项,再安排上述界面小样。当前 PR 保持未合并,未获认可的界面不作为最终定稿。
这次机制建设不修改已有伴侣、名字、生日、记忆和聊天记录,不重置服务器数据,不改变底色与成长规则,不切换队员的全局模型,不合并或覆盖正式服务。界面小样使用独立预览,真实功能接入工作仍受完整需求约束。
本稿是待确认的执行办法;上述项目与仓库约束尚未写入生效,动态小样尚未制作。
@@ -0,0 +1,208 @@
# CyberGril 真实服务核查与功能接入批次方案(HEL-778 · v1)
> 依据:main 合并提交 `498429399812a0776dadd4b56662d31b455a9ea5`(第二父提交为已审核前端 `6c3b09e6936881fb3e0681ce40bf6069d2c250ac`,已核对无误);HEL-738 结构方案(本卡附件,不重复其内容,冲突处以本文为准);《整套设计验收与施工裁定-v1》;需求确认记录 v1.6。
> 性质:**只读核查 + 实施方案**。本轮未写产品代码、未改任何服务、未访问 4080、未输出任何凭据。所有尚未实施的接口均标【拟议】。
> 核查手段:main 源码全量阅读;Python 测试复跑(**78 passed**);对 .11 LLM 网关只读 GET;经既有授权 SSH 对 .12 做 systemctl / ss / docker / 进程 / 本机 HTTP 只读探测;对 8300/8090 只读 HTTP 探测。
---
## 0. 一页结论
1. **第一批该干什么已经清楚了**:把 B1 与 S2 合并为一批(本方案称 **R1**),让白栖知用普通入口在服务器上创建唯一真实档案,并与 .11 的真实模型流式文字对话。服务器是唯一事实源,重开/换设备都是同一位她。不动语音、不做生活循环、不做口型。
2. **比 HEL-738 写方案时多出一个重大事实**:.12 上已有一套**端到端验证过的全双工语音底座** `/opt/voice-agent`(FunASR 流式 ASR + CosyVoice3 流式 TTS + LiveKit + silero VAD 打断,2026-10-16 实测可用,含可用 HTTPS 自签通话页 8090)。语音批次(R5)从"从零接线"变成"复用已验证底座换上她的人格与记忆",风险大幅下降。
3. **LLM 网关 .11:8731 实测可用**:OpenAI 兼容,无需鉴权,单模型 `halogen-qwen3.8-flash-next`(262K 上下文)。现有 `providers/llm/openai_compat.py` + `think/wiring.py` 只改 `config/models.yaml` 即可接上,代码零改动。
4. **S1 前端把接缝留对了**:`ChatAdapter` 接口 + `TurnGate`(turn_id/epoch/abort 三重门槛)就是为换真实传输准备的;真实模式只需要新增一个适配器和普通入口路由,演示模式代码路径完全不动。
---
## 1. 核查证据表
### 1.1 前端(S1,`6c3b09e`,已合并 main)
| 事实 | 证据 | 结论 |
|---|---|---|
| 聊天传输是可替换接口:`send(text, turnId, epoch, signal, emit, fail?)` | `web/src/adapters/demo.ts:2-3` | 【已有可复用】R1 新增 `RealChatAdapter` 实现同接口即可,`ChatPanel.tsx:26` 只改 `useRef(new DemoAdapter())` 一处注入 |
| 旧轮隔离门槛:事件必须同时匹配 turn_id + epoch 且未 abort,非协作适配器的迟到事件同样被拒 | `web/src/adapters/demo.ts:25-33`(TurnGate),`ChatPanel.tsx:57` 每事件过 `gate.accepts` | 【已有可复用】真实 SSE 流直接受益,无需重写 |
| 服务端事件形状 `chat.delta` / `chat.final`(带 epoch + turn_id) | `web/src/domain/model.ts:61-74` | 【已有可复用】R1 服务端 SSE 事件与它一字不差,前端模型层零改动 |
| 隐私 fail-closed 裁剪:未分享事件及其关联回忆、消息全部过滤,未知引用默认拒绝 | `web/src/domain/privacy.ts:4-11` | 【已有可复用·仅前端】按裁定,真实后端必须在响应前裁剪(R2 起服务端强制),前端过滤不构成权限控制 |
| 创建守卫:名字 1–20 字无尖括号/控制符、日期合法且满 18 岁、关系必选不预选 | `web/src/domain/model.ts:76-86`(validateCreation),`CreationWizard.tsx:43` 关系无预选 | 【已有可复用】R1 服务端必须同规则复验(不信任客户端) |
| 演示档案存 `localStorage` key `cybergril.demo.v1`,导入走严格 schema 重建 | `web/src/adapters/storage.ts:2,6-32` | 【已有可复用】真实模式使用独立数据源(服务器),key 不冲突 |
| 普通入口(非 `?demo=1`)显示"服务尚未接通"卡 + 演示入口链接,不读档案 | `web/src/app/App.tsx:13,99` | 【需改】R1 在此分岔:可达且有档案→主界面;可达无档案→创建向导;不可达→维持现状卡 |
| S1 全程零网络请求、不碰 getUserMedia | `docs/frontend/IMPLEMENTATION.md` + 源码通读(无 fetch/XHR/WS) | 【已验证】演示隔离的基线成立 |
| 演示模式全局开关 `?demo=1`,演示剧本与页面分离 | `App.tsx:13`、`web/src/demo/fixtures.ts` | 【已有可复用】真实模式上线后演示路径原样保留 |
### 1.2 Python 核心(自 `1e9b08a` 起未变:`git diff 1e9b08a..main -- src/ config/ tests/` 为空;78 测试通过)
| 模块 | 关键接口(证据) | 结论 |
|---|---|---|
| 八字编译 | `pillars_from_lunar()`、`compile_pillars()→Derived`、`Derived.traits_text()` 白话倾向(`core/bazi.py:271,310,159`) | 【已有可复用】R1 创建时服务端排盘 + 底色白话直接产出 |
| LLM 适配 | `OpenAICompatLLM.stream()`(SSE 流式)、`health()`(GET /models 探活)(`providers/llm/openai_compat.py:71-76,78-134`) | 【已有可复用】对 .11:8731 协议完全匹配 |
| 模型路由 | `build_llm()` / `build_router_from_config()`:配置→provider→router 唯一接缝(`think/wiring.py:30,100`;`config/models.yaml`) | 【已有可复用】换模型只改 yaml,业务代码不碰 |
| 记忆存储 | `Store`:`add_episodic`/`decay_accessibility`/`reinforce`/`search_fts`(FTS5 已建,向量表在 `schema.sql:183` 注释)(`memory/store.py`) | 表结构可用;R1 需**增量补一张 `chat_message` 表**(schema 无聊天记录表);检索/整合【需补实现】(R3) |
| 关系账本 | `dyad/ledger.py`:`Dyad.apply`/`tick_daily_regression`/`behavior_hints` | 【已有可复用】R4 起用 |
| 情绪/投影 | `core/mood.py` 全套衰减方程;`core/projection.py` Projector | 【已有可复用】R2/R4 起用 |
| 服务器 api/ | 不存在(`src/cybergril/` 下无 api 目录) | 【需补实现】R1 新建 |
| tick/生活/reach_out 循环、检索/整合、表达管道、天气、备份 | 无任何代码(HEL-738 §1.2 结论仍成立,本轮复核确认) | 【需补实现】R2 起分批 |
### 1.3 授权服务实测结果(本轮只读探测)
| 服务 | 地址/端口 | 实测结果 | 证据 |
|---|---|---|---|
| LLM 网关(halogen) | `http://192.168.200.11:8731` | `GET /v1/models` → 200,**无鉴权头也 200**;单模型 `halogen-qwen3.8-flash-next`,上下文 262144,max_tokens 上限 65536/默认 32768 | 本轮 curl 实测;且 `/opt/voice-agent/README.md` 记载语音栈已于 2026-10-16 用它端到端验证(OpenAI 兼容) |
| ASR(FunASR 2pass 流式) | .12 `:10095`(docker `voice-funasr`,funasr-runtime-sdk-online-cpu-0.1.13,0.0.0.0) | WS 协议,流式 ~600ms + 离线纠错;已知问题:长句偶发被切成两次 final(两次触发回复),可切 mode=online 或调 VAD 尾点 | `docker ps`、`ss -tlnp`、`/opt/voice-agent/agent/stt_funasr.py:29`、README"已知限制" |
| TTS(CosyVoice3 流式) | .12 `:9880`(`/opt/voice-agent/cosyvoice/server.py`,GPU ~5.6GB 显存) | FastAPI:`/health` + `POST /tts` → 24k int16 PCM 字节流;首包 1.7–2.5s(官方 150ms 需 vLLM 后端,未开);`voices.json` 零样本音色注册 + 热加载 + 网页音色管理 | `curl /openapi.json` → `['/health','/tts']`、`tts_cosyvoice.py:22`、README |
| LiveKit SFU | .12 `:7880/:7881`(docker `voice-livekit`,host 网络) | 运行中;配套 livekit-agents 1.8.8 worker(.12:8081)内置 silero VAD,打断判定 min_silence 0.55s 已含在链路 | `docker ps`、`ps`(agent.py start)、README |
| HTTPS 通话页(先例) | `https://192.168.200.12:8090`(/opt/voice-agent/web) | 自签证书,TLS 200;浏览器"高级→继续"后麦克风可用;token 由服务端签发,密钥不进浏览器 | `ss`、`curl -k` 实测、README"浏览器通话" |
| S1 演示站 | .12 `:8300`(systemd `cybergril-s1-web.service`,`/opt/cybergril-s1/current` 指向 6c3b09e 构建,releases/current 结构) | 200 正常;静态 http.server;回滚=切符号链接 | `systemctl cat`、curl 实测。**本轮未做任何变更** |
| 口型试验(遗产) | .12 `:8010`(systemd `cybergril-musetalk-test.service`:MuseTalk + WebRTC + cosyvoice TTS) | 运行中,为口型独立线的既有验证件 | `systemctl list-units`。按边界不动 |
| 旧原型页 | .12 `:18080/:18081`(含 HEL-711 卧室分层校准等) | 存在且运行 | 按边界保留不动 |
| 摄像头/视觉 4080 | 未访问 | 按本轮边界继续禁止访问,仅列 R7 独立决策 | — |
### 1.4 普通入口如何进入真实模式;演示如何隔离;演示档案不会被当成真实伴侣
- **普通入口(R1 后)**:非 `?demo=1` 打开 → 前端 `GET /api/health`:可达 → 按 `GET /api/companion` 结果分岔(404→创建向导;200→主界面+服务器历史消息);不可达 → 维持现在的"服务尚未接通"卡。真实档案创建只写服务器 SQLite,浏览器不再持有权威状态。
- **演示隔离**:`?demo=1` 路径、localStorage key `cybergril.demo.v1`、演示剧本、演示角标全部原样保留;演示模式继续零网络请求。
- **不自动上传**:真实模式代码从不读取 `cybergril.demo.v1`,也不提供"演示档案转真实"入口;R1 验收含 Network 面板核验。浏览器里的演示数据与服务器上的她是两个世界,互不可见。
---
## 2. 第一批施工卡正文(R1:真实档案 + 真实文字对话,可直接交施工员)
> 即 HEL-738 切片 B1+S2 的合并最小版。目标一句话:**白栖知用普通入口创建唯一真实档案,和真实模型流式文字聊天,关掉浏览器再开(换设备也一样)还是同一位她。**
### 2.1 做(范围)
**A. 后端 `src/cybergril/api/`(拟议,FastAPI + uvicorn,单进程 systemd 服务)**
| 端点【拟议】 | 行为 | 错误语义 |
|---|---|---|
| `GET /api/health` | `{ok, version, epoch, companion_created, llm:{ok, model}}`;llm 探活用 `OpenAICompatLLM.health()`(GET /models,5s 超时,结果缓存 30s) | 永远 200(状态在体里) |
| `GET /api/companion` | 档案:名字、生辰+时辰、关系、称呼、八字只读白话(`compile_pillars().traits_text()` 输出);不含内部数值向量 | 404 未创建 |
| `POST /api/companion` | body `{name, birth, hour, relation, nickname?}`。服务端复验(规则与前端 `validateCreation` 一致:名字 1–20 字不含尖括号/控制符、日期合法且已满 18 岁、≥1900、relation ∈ {朋友,恋人} 且必选——不设默认值);排盘入 `bazi_core`(单行表);写档案 | 409 已有一位(单伴侣守卫);422 字段级中文错误;birth/derived **不提供任何修改端点**(八字只读守卫) |
| `PATCH /api/relation` | body `{relation}`;只改关系字段,其余不动;"再次确认"交互由前端承担 | 404 / 422 |
| `POST /api/chat` | body `{text, turn_id}` → **SSE**(`text/event-stream`)。事件复用前端 `ServerEvent` 形状:`chat.delta {epoch, turn_id, delta}`、`chat.final {epoch, turn_id, utterance{text, speech_tags}}`,另加 `chat.error {epoch, turn_id, phi, retryable}`。客户端断开(AbortController)即关闭上游 LLM 流 | 503 映射为 chat.error(phi:"她这会儿没接上话,稍后再试试",retryable:true) |
| `GET /api/messages?limit=200` | 重开/换设备恢复历史(升序返回) | — |
**B. 服务端状态与持久化**
- SQLite WAL:`/opt/cybergril-data/cybergril.db`【拟议路径】。复用 `Store`;**增量新增 `chat_message` 表**(id, ts, role, text, turn_id, status;`CREATE TABLE IF NOT EXISTS` 迁移,不动既有表)。`bazi_core` 存排盘;`meta` 表存 `epoch`(服务每次启动 +1 并持久)。
- chat 上下文组装(最小版):系统提示 = 她的名字 + 关系 + 称呼 + 八字白话 traits_text + 世界红线(不出戏、不假装有物理身体、不把"等了你很久"当武器)+ 最近 20 轮消息 + 用户文本。经 `ModelRouter` 的 `dialogue_main` 场景(high 档)。
- `config/models.yaml`【拟议改法】:三档先全指 `http://192.168.200.11:8731/v1` + `halogen-qwen3.8-flash-next`(api_key_env: null;high temperature 沿 0.85,standard 0.7,low 0.4,timeout 沿用);budget 沿用(200 万 token/天,0.7 警告,0.95 硬停)。
**C. 前端(真实模式)**
- 新增 `web/src/adapters/real.ts`【拟议】:`RealChatAdapter implements ChatAdapter`——`fetch POST /api/chat` + ReadableStream 解析 SSE,`signal` 直通 TurnGate 的 AbortController;`fail` 参数忽略。
- 普通入口路由按 1.4 节分岔;创建向导复用四步流程,去掉"本机演示"文案,第 2 步"底色"展示服务端真实排盘白话(替换现在的"示例底色"),确认步的三段动画对应真实排盘/建档。
- 关系修改接 `PATCH /api/relation`;管理页其余(城市/素材/备份)保持演示/占位态不变。
### 2.2 不做(R1 边界)
语音、生活循环、记忆检索、主动消息、天气、素材、备份、口型、视觉、多用户、鉴权体系(内网 + 单伴侣 409 守卫先行)、WebSocket(文字轮次 SSE 足够,语音批次再上 WS/RTC)。不以口型或生活引擎作为前置。
### 2.3 验收清单(总工复核用)
1. 全新浏览器普通入口 → 四步创建(关系不选无法下一步)→ 服务器 DB 出现 `bazi_core` + 档案;重复创建 → 409。
2. 发送文字:**真实流式回复**(非演示固定句"我听见了…");中途"取消本轮回复"立即停止;马上提新问题,旧流残句不串台。
3. 关浏览器重开 / 换另一台设备打开:同一位她、历史消息完整恢复(服务器为唯一事实源)。
4. `systemctl restart cybergril-api` → epoch +1,前端后续请求自动重同步,无旧事件混入。
5. LLM 不可达(验收时用 health 面板状态 + 一次真实 503 路径验证)→ φ 文案、草稿保留、重试可成功。
6. `?demo=1` 演示功能与现在完全一致;Network 面板确认演示模式零外发请求、真实模式从不读取 `cybergril.demo.v1`。
7. `curl :8310/api/health` 返回可读服务状态(含 llm.ok 与模型名)。
8. web build 通过;Python 78 测试不回归;新增端点有对应测试。
### 2.4 停止条件
若 halogen 模型对话质量明显不可用(连续乱码/系统性拒答/超时 >30s 成为常态):停止验收,记录请求与响应证据,回报总管,**不自行换模型、不开调参循环**。
### 2.5 部署与回滚(总工执行)
- 部署:`/opt/cybergril-api/releases/<ts>/` + `current` 符号链接;systemd `cybergril-api.service`;端口 **8310**(当前空闲,部署时复核);前端构建由同一服务以 StaticFiles 同源挂载(API 走 `/api` 前缀)——同源避免 CORS 与混合内容;SSE 需关闭代理缓冲(uvicorn 直连即可)。
- 回滚:`systemctl stop cybergril-api` + 符号链接切回上一 release + start;数据目录 `/opt/cybergril-data/` 永不随回滚删除。**8300 演示服务与浏览器演示档案全程不动。**
- 用户验收通过后,是否把 8300 切到新构建或以 8310 为正式入口,由总管与白栖知确认后执行。
---
## 3. 后续有限批次
依赖关系:`R1 → {R2, R3}`;`R2+R3 → R4`;`R5 依赖 R1(可并行于 R2–R4)`;`R6 依赖 R1`;`R7 依赖 R5 的 HTTPS 路径 + 用户明确授权`。
### R2 生活循环(最小版)+ 未分享裁剪 + 日子页接真
- **输入**:R1 档案与 DB;`character.yaml`(day_start_hour=7、每天 1–4 条、reach_out 参数)。
- **做**:API 进程内 asyncio 定时任务(或独立 worker 服务)每日生成 `life_event`(low 档生成、过投影 φ 成世界内陈述、记 salience、told=0);"她讲过"最小机制:对话中引用该事件即置 told=1;`GET /api/life?since=` 分页,**只返回 told=1 的事件,且响应前裁剪其一切关联引用**(回忆 lifeIds、消息 lifeIds 一并剥离,覆盖列表/详情/摘要/搜索路径——服务器侧强制,对应前端 `publicView` 的服务端版);日子页数据源换 real 标签。
- **不做**:记忆整合、主动消息、素材视频(仍静帧+文字)、生活事件编辑/删除。
- **验收**:连续 3 天每天 1–4 条;curl 逐端点核验未 told 事件及引用零泄漏;**网页关闭期间照常生成**(服务端循环,非浏览器假数据);LLM 故障时循环降级跳过或模板句,不伪造生活。
- **停止条件**:事件质量持续荒谬或预算异常 → 停循环回报。
### R3 记忆最小版 + 对话记忆
- **做**:每轮对话后写 `mem_episodic`(raw + low 档 gist 压缩 + affect 粗估);chat 上下文加 FTS top-k 检索("她记得");`reinforce`/`decay_accessibility` 定期跑批;回忆页 episodic 层接真。
- **不做**:向量检索(mem_vec 仍注释)、语义/关系层整合、identity promotion。
- **验收**:隔天提及前日话题,回复体现记忆;回忆页展示与对话一致;FTS 命中有日志可查。
- **停止条件**:检索噪声导致人设漂移 → 关检索开关回报(可配置降级为纯近轮上下文)。
### R4 主动消息 + 返回同步
- **做**:reach_out 规则引擎(间隔≥6h、8–23 点、salience≥0.6,参数照 `character.yaml`);`GET /api/sync?since=` 统一游标(life + messages + reach_out 增量);页内轻提醒 + "你不在的时候"列表;措辞过 φ(缺席可被提及、不可被武器化)。
- **不做**:手机推送、危机通道。
- **验收**:构造高显著已分享事件后,规则窗口内收到主动消息且来源可点回;离线消息语气符合红线。
### R5 语音连续对话(复用 /opt/voice-agent 底座)
- **前置**:HTTPS 方案落地(见第 5 节)+ 用户选音色(见第 4 节 D1)。
- **做**:新增一个 CyberGril 专属 agent worker(参照现有 agent.py 组装:FunASR STT + halogen LLM + CosyVoice TTS + silero VAD),system prompt 换成她的人格 + R1/R3 的档案与记忆上下文;前端聊天面板语音模式接 LiveKit client(HTTPS 页面内);字幕走 asr partial;打断沿用 VAD(0.55s 判定 + 清流);结束即停麦释放;文字模式全程并存。
- **不做**:口型(MuseTalk 8010 试验件为独立可替换线,不阻塞)、手机推送、副语言标签。
- **已知限制内置缓解**:TTS 首包 1.7–2.5s 期间显示"在想"+字幕先行;FunASR 双 final 出现频率记录,影响体验时切 mode=online。
- **验收**:连续对话 ≥3 轮;用户开口她 ~1s 内停声;结束即停收音;打断后无旧音频残留。
- **停止条件**:打断常态 >2s 或首包常态 >5s → 记录参数与日志回报总管,**不重开旧无限调参流程**。
### R6 城市/天气 + 素材包 + 真备份
- **做**:城市存档 + `GET /api/env`(时段/天气);天气源按 D2 决定(推荐 open-meteo,先实测 .12 出网连通);素材包 manifest v1 导入校验(结构/时长/命名),只换 `appearance_id`,身份卡不变;备份:`POST /api/backup/export`(zip:DB+设定+素材引用)与 restore(上传→版本兼容+**八字指纹比对=同一位她**→恢复前自动先导出当前→确认执行);systemd timer 每日自动备份(7 份滚动,拟议)。
- **验收**:F7/F8/F9 全走通;恢复后记忆与关系保留、身份唯一;换素材档案不变;备份空间受控。
### R7 视觉观察(独立决策,默认关)
- **前置**:R5 的 HTTPS 路径已跑通 + 白栖知明确授权摄像头 + 4080 使用决策(D4)。
- **做**:观察摘要带时效(前端 30s 过期演示逻辑的真实版);关闭显示"窗户关上了";隐私边界写入服务端。
- **验收**:开关生效、摘要时效、关闭后无任何采集。
---
## 4. 必须白栖知拍板的问题(均已给推荐答案,其余技术取舍由小队自行决定不升级)
| # | 问题 | 影响批次 | 推荐 |
|---|---|---|---|
| D1 | 她的声音用哪支音色 | R5 前 | 在 8090 音色管理页试听现有中文女声;都不满意就录一段 5–20s 中文参考音频做零样本克隆(页面已支持,这是"像不像"的关键) |
| D2 | 天气数据源 | R6 前 | open-meteo(免费、无 key);若 .12 出网不可达则暂用固定显示,不买付费接口 |
| D3 | 手机/电脑麦克风的可信访问方式 | R5 前 | 自签证书 + 每台设备首次"高级→继续"信任一次(8090 页已验证此路可行);不建内网 CA、不买域名公网证书、不改防火墙 |
| D4 | 是否开启视觉观察(摄像头"一扇窗"),及 4080 小视觉服务的使用方式 | R7 前 | 默认关闭;确认开启后再单独决策时段/帧率 |
---
## 5. 可信访问(HTTPS)核查结论与最少影响方案
- 事实:浏览器麦克风/摄像头要求安全上下文;**8090 通话页已用自签证书在局域网内验证过麦克风可用**(浏览器手动信任一次)。
- 推荐:R1 纯文字阶段无需 HTTPS;R5 前为正式入口(8310 或正式端口)按同一流程签一张覆盖 `192.168.200.12` 的自签证书即可。**不改证书体系、不动防火墙、不影响任何现有服务**;若未来要消除手动信任,再评估内网 CA(首版不做)。
---
## 6. 风险表
| 风险 | 影响 | 缓解 |
|---|---|---|
| halogen 单模型、无 SLA、质量未实测 | R1 回复质量不达标 | R1 验收实测 + 明确停止条件;路由/预算框架已支持随时换模型(只改 yaml) |
| .11:8731 无鉴权、内网明文 | 同网段任何人可调用 | 保持内网边界;产品侧不内置 key;后续可加访问口令(建议项,不阻塞) |
| 单用户 API 无鉴权,LAN 内他人可创建/聊天 | 档案被抢先创建 | 单伴侣 409 守卫 + 内网边界;访问口令列为 R2 建议项 |
| .12 GPU 显存共享(CosyVoice ~5.6G + musetalk 试验 + 未来视觉) | R5/R7 显存不足 | R5/R7 开工前 `nvidia-smi` 核算;口型试验件可停(有启停记录) |
| FunASR 双 final、TTS 首包 1.7–2.5s | 语音体验打折 | 已知限制,R5 方案内置缓解;vLLM TTS 后端列为后续优化 |
| SSE 被代理缓冲 | 流式变一次性 | uvicorn 直连;如加反代显式关缓冲 |
| epoch/游标边界设计错误 | 状态串台 | TurnGate 已守客户端;R1 验收专测重启/换设备场景 |
| 演示与真实模式混淆 | 用户误以为已接通 | 存储 key 分离、无自动上传、普通入口显式状态、演示角标保留 |
| 生活循环用假数据冒充 | 违反"不伪装运行"红线 | R2 验收明确服务端生成证据(DB 记录 + LLM 调用日志) |
---
## 7. 测试部署与回滚总安排
- 每批均走 HEL-749 已验证的模式:`/opt/cybergril-*/releases/<ts> + current` 符号链接 + 独立 systemd 服务 + 独立端口 + 数据目录与代码分离;8300 演示站在白栖知明确验收前不动。
- 回滚通式:`systemctl stop <svc> && ln -sfn releases/<prev> current && systemctl start <svc>`;数据目录永不删除;Git 侧对应回退 + 重新部署。
- 各批验收含"服务状态可验证"(/api/health + 日志位置 + 启停命令写入部署记录)。
---
*HEL-778 · 总工 · 2026-10-22 · 本轮只读核查 + 方案;未写产品代码、未改 .11/.12 任何服务与配置、未访问 4080、未输出凭据、未创建施工卡。*
File diff suppressed because one or more lines are too long
@@ -0,0 +1,48 @@
本稿汇总 HEL-697 主卡本轮逐项确认的视觉方向,供确认重做范围。确认后交视觉总监用于重新出样图;本稿不代表页面样图已验收。
## 产品与范围
CyberGril 是有自己生活的单伴侣。完整前端覆盖相处、文字和语音交流、她的生活与共同回忆、创建与管理。人物视频是独立展示模块,整体界面需要自己的空间、氛围与交互体系。
既有产品需求继续有效;本轮只调整视觉方向和呈现方式,不重定业务规则。HEL-738 的页面流程和实现边界可继续作为输入;HEL-737 的私人手账视觉初稿未获认可,需要按本稿重做。
## 已确认的视觉决定
1. 整体像只属于两人的数字空间,人物、交流、回忆融在其中。
2. 气质静谧梦幻、安静亲密,有空间深度和柔和微光。
3. 支持多主题。主题切换同时改变配色、背景、光影、材质和动效;页面结构与操作位置一致。
4. 首轮主题为「靛蓝月光」与「蓝紫雾光」。墨绿萤光与浅雾晨光未选入首轮;最终主题数量和默认主题尚未确定。
5. 首页空间优先,只留少量当前状态与入口,其余内容按需展开。
6. 背景由光雾、景深和若隐若现的景物融合构成。具体景物由设计师提出,并在样图中验证。
7. 平时能看到舒缓流动与景物微动;操作时有明显的景深变化和空间过渡。
8. 主要内容采用透光浮层,背后保留空间光影;文字区域保证清晰可读。
9. 文字现代轻盈、字形简洁、标题克制,正文易读。字体和字号由样图验证。
10. 强调色使用少量微光,集中于关键操作和选中状态;每套主题有自己的强调色。
11. 人物平时作为空间中的独立视窗,能看清她正在做什么;交流时自然放大。具体位置、比例与形状由样图验证。
12. 使用底部小巧透光的悬浮导航,图标配简短文字,选中时亮起微光。
13. 交流展开后以人物为主,只呈现当前一两句对话,完整聊天记录按需展开;文字输入和语音操作均保留。
## 建议的重做交付顺序(待本次确认)
先验证视觉最关键的部分,再展开整套设计:
第一步:提交同一布局下两套主题的电脑首页与交流展开样图,共四张;补手机首页与交流展开样图,共两张。样图必须展示完整界面、真实可读文字与操作入口,能比较人物分量、空间留白、浮层材质和两套主题的区别。
同时提交一份轻量动效原型,展示背景的舒缓变化、交流开合和主题切换。模拟数据与未接通能力须说明,不用真实口型或后台接入作为交付前置。
第二步:首轮样图经白栖知认可后,沿定稿方向补齐生活与回忆、创建与管理、完整聊天记录等页面及其状态、响应式与可施工规范。继续覆盖完整产品,不停留在人物展示页。
正式产品施工另在完整设计确认后启动。
## 样图验收要点
- 第一眼能感到静谧、梦幻、有空间深度;布局、文字与入口符合本稿已确认方向。
- 两主题沿用相同布局,背景、光影和材质有可见差异;两套都保持清晰可读。
- 首页能看到她的生活,仍有充足空间留白;展开交流后人物成为重心。
- 默认与展开状态之间有连续的空间关系,关闭后回到原来的位置。
- 电脑和手机的关键入口容易找到,人物和文字不会互相遮挡。
- 动效有实际可看的原型;具体速度、曲线、效果强度由设计师提出,允许在样图与原型验收时调整。
## 本轮边界
只重做设计样图与设计原型;不修改正式产品代码、不部署、不合并分支、不更改现有服务,也不重新启动嘴型研究。现有功能流程、已确认的产品规则和服务保持原有边界。
@@ -0,0 +1,26 @@
# 任务需求交接模板
- 基准版本:从当前入口读取,当前为1.7;记录读取时的仓库提交。
- 完整需求阅读:`docs/requirements/README.md`、`current.md`、`coverage.md` 与相关原始来源。
- 本次关联要求:编号、原意、出处;不能只写“参见需求”。
- 交付与验收:具体行为、可观察结果、实际验证方法。
- 未完成项及承接:本次暂不做的要求仍有效,说明下一批或卡片;未知写明,不能假装已完成。
- 需求变更:无;若有,附白栖知明确原话、评论编号及影响,不以方案调整代替。
- 审核证据:功能验证、电脑/手机画面、必要动态过程、未验证设备和场景。
结构化交接JSON必填键:
```json
{
"baseline_version": "1.7",
"read_commit": "实际读到的40位仓库提交",
"requirement_ids": ["VIS-013", "CG-018"],
"deliverable": "本卡实际交付",
"acceptance": ["可观察的验收项"],
"deferred": ["本卡不做但仍须后续承接的内容"],
"requirement_changes": [],
"verification": ["实际证据或清楚写明待验证"]
}
```
交付前运行 `python3 scripts/check_requirements.py --handoff <文件>`。填写占位文字不算真正交接;审核人须核对原话和实际证据。
+275
View File
@@ -0,0 +1,275 @@
[
{
"id": "01a1548a-1744-780f-9972-48b0fa078eb9",
"identifier": "HEL-698",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a1548a-55be-77eb-8b01-71ed0a37424d",
"identifier": "HEL-699",
"status": "in_review",
"assignee_id": "cb739dfc-ef6a-467e-bcc9-96ae8914e0ee",
"verified": true
},
{
"id": "01a1548a-55f4-726d-8624-154ce928f319",
"identifier": "HEL-700",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a1548a-5631-7321-94d1-a88053ffcda9",
"identifier": "HEL-701",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a156e0-2ac3-7274-a9d2-d39f10d19581",
"identifier": "HEL-702",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a156e0-2f1d-7c55-b2e5-b169b8f6f00d",
"identifier": "HEL-703",
"status": "in_review",
"assignee_id": "cb739dfc-ef6a-467e-bcc9-96ae8914e0ee",
"verified": true
},
{
"id": "01a15700-8422-785a-93c3-fff3351a2aa1",
"identifier": "HEL-706",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15700-add2-7bf6-a488-0e64062139f8",
"identifier": "HEL-707",
"status": "in_review",
"assignee_id": "cb739dfc-ef6a-467e-bcc9-96ae8914e0ee",
"verified": true
},
{
"id": "01a156e0-2f57-7286-8721-7a9371ff3474",
"identifier": "HEL-704",
"status": "backlog",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a156e0-2fa0-7589-a62e-b9aa985fba95",
"identifier": "HEL-705",
"status": "backlog",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a157b9-33d0-7324-98f3-44c23ecaa171",
"identifier": "HEL-708",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a157b9-3839-733a-b363-82d7716bc7dc",
"identifier": "HEL-709",
"status": "in_review",
"assignee_id": "cb739dfc-ef6a-467e-bcc9-96ae8914e0ee",
"verified": true
},
{
"id": "01a15830-293e-79c7-a284-20f0c526f0dd",
"identifier": "HEL-711",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15851-3750-762e-a0c3-3de0d4d50f34",
"identifier": "HEL-712",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15830-1c80-7326-8951-2176f08ba180",
"identifier": "HEL-710",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15863-1869-725f-8dae-4c8170e4efe5",
"identifier": "HEL-713",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a159a1-030a-793b-8c30-4ef799ee9941",
"identifier": "HEL-715",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a159a0-f64c-780b-8bf4-85fec219dcf0",
"identifier": "HEL-714",
"status": "blocked",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15a22-6fc6-7c94-b662-40599cbf73a9",
"identifier": "HEL-717",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15a43-e2c2-76c7-8e4b-18330ad0fa3e",
"identifier": "HEL-718",
"status": "blocked",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15a57-0de5-7eaa-aecc-bdc21efb5f56",
"identifier": "HEL-719",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15a69-07f6-786d-a51e-d2057b7a9a3a",
"identifier": "HEL-720",
"status": "blocked",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15a7f-3292-7d81-8de1-0d9628ca6c1b",
"identifier": "HEL-721",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15a97-342a-7e91-b65e-8e4b7b415765",
"identifier": "HEL-722",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15aa7-f766-71a8-ad17-7da10dcd4bf7",
"identifier": "HEL-723",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15abf-566c-770a-a0a4-c1db4941c12b",
"identifier": "HEL-724",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15ad2-7eae-7b48-b4e6-e11597d4fe3e",
"identifier": "HEL-725",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15e9b-e35f-72f3-926f-23997c8e4e42",
"identifier": "HEL-737",
"status": "in_review",
"assignee_id": "d6da7655-1462-43b7-9b9c-227dcdf21bba",
"verified": true
},
{
"id": "01a15e9b-e3c5-7dfe-98b8-5acf067075cb",
"identifier": "HEL-738",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15f0b-6915-7d4b-bd2f-788ff1ebd063",
"identifier": "HEL-744",
"status": "in_review",
"assignee_id": "d6da7655-1462-43b7-9b9c-227dcdf21bba",
"verified": true
},
{
"id": "01a15f21-25c6-7e95-a55f-bd24a3128838",
"identifier": "HEL-746",
"status": "in_review",
"assignee_id": "d6da7655-1462-43b7-9b9c-227dcdf21bba",
"verified": true
},
{
"id": "01a15f44-ac39-7b90-a4af-328fbdbfd973",
"identifier": "HEL-748",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a15f45-40cf-7479-a9af-fb4199b82928",
"identifier": "HEL-749",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a161a9-13b3-7af2-8694-a28ced583f85",
"identifier": "HEL-778",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a1629e-4eff-780f-94b1-d126027b3136",
"identifier": "HEL-780",
"status": "in_review",
"assignee_id": "4846a27a-ba87-49c9-939d-7275f63efcc7",
"verified": true
},
{
"id": "01a1629e-f348-7aff-80e5-ef64b5067501",
"identifier": "HEL-781",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a162f1-0942-7b0d-b1ba-af821ddc1594",
"identifier": "HEL-783",
"status": "in_review",
"assignee_id": "bbcc1179-3a55-48e7-a72e-c84cf79f41cb",
"verified": true
},
{
"id": "01a162f2-3fc7-7e01-9744-ef54cae2591e",
"identifier": "HEL-784",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
},
{
"id": "01a15e9b-e42b-7c92-a925-b84802c25ab3",
"identifier": "HEL-739",
"status": "in_review",
"assignee_id": "e2c1ccbf-d43c-47c9-bdfb-7deb21e9e5c5",
"verified": true
}
]
+37
View File
@@ -0,0 +1,37 @@
[
{
"case": "valid",
"passed": true,
"observed": []
},
{
"case": "missing-coverage",
"passed": true,
"observed": [
"对照表遗漏、重复或包含不存在的要求"
]
},
{
"case": "changed-source",
"passed": true,
"observed": [
"历史来源已改变:visual-confirmation-v2.md"
]
},
{
"case": "unversioned-change",
"passed": true,
"observed": [
"需求有变但版本未更新",
"需求有变但没有追加用户授权记录",
"新需求变更不能重复使用旧批准评论"
]
},
{
"case": "stale-handoff",
"passed": true,
"observed": [
"交接使用过期基准"
]
}
]
-72
View File
@@ -1,72 +0,0 @@
# HEL-715 素材包规范与施工方案(可交施工员)
依据 CG-029(日常用成品视频、交谈用同一人物实时口型)、CG-005/006(换外观保留人格/记忆/关系)、CG-030(首批素材小队制作)。原则:单图不自动生成整套;每份素材可独立替换、可校验、可回滚。
## 1. 素材包目录规范
```
assets/personas/<persona>/ # persona=外观方案,如 wanwan-v2;换外观=新目录
manifest.json # 见下
daily/ # 日常成品视频(CG-029 主路线)
<scene-id>.mp4 # H.264 25fps ≥6s 可无缝循环;按时段/天气/活动命名
talk/ # 交谈底板(近景,同房间同脸同服装)
<talk-id>.mp4 # H.264 25fps 5~15s,人物目视镜头、口部微闭或自然微动
voice/ # CosyVoice 音色登记名(.12 voices.json 中的 voice 名)
ref/ # 定妆照/场景照 PNG(身份对照用)
```
`manifest.json` 必填字段:`persona_id`、`identity_ref`(ref/ 内主定妆图)、`daily_clips[{id, timeofday, weather, activity, loop_points}]`、`talk_clips[{id, avatar_id, framing}]`、`created_by`、`created_at`、`pipeline_version`(如 hel715-v1)。
## 2. 身份/服装/光线一致性标记(导入校验项)
- 同一 persona 内所有 daily/talk 片段必须与 `identity_ref` 同人物、同发型、同套服装;换服装=新 persona 目录。
- 每段导入时人工对照 ref 图打勾(首版人工,不做自动人脸embedding校验——留待后续)。
- 光线:同一 persona 的交谈底板光线需与常用日常场景一致(近景底板从日常成品裁切放大可天然保证,如本轮 talk-close = loop 1.75× 裁切)。
## 3. 可切节点与缺素材回退
- 日常片循环点:`loop_points` 记录首尾衔接帧(硬循环跳变处需美术确认或选首尾近同帧的段)。
- 交谈进入点:任意时刻可切(预连接流,实测切换 0~3ms);退出点同。
- 缺素材回退顺序:请求场景无 daily 片 → 用同 persona 默认 daily 片;无 talk 底板 → 禁用交谈入口并提示,不得用其他人物素材顶替;TTS 失败 → 显示重试按钮,不静默。
## 4. 导入与回滚流程(本轮实测命令)
```
# 1) 预处理:任意源 → H.264 25fps(示例见 scripts/,PyAV,秒级)
# 2) 生成交谈 avatar(47~60s GPU/份,独立目录,无硬编码):
cd /opt/hel715-verify/LiveTalking
venv/bin/python -m avatars.musetalk.genavatar --file <talk-clip.mp4> --avatar_id <persona>-<talk-id>
# 3) 校验:data/avatars/<id>/ 含 avator_info.json/coords.pkl/latents.pt/mask;avator_info.json 的 video_path 指向素材包内文件
# 4) 服务挂载:app.py --avatar_id <id>(或 custom_config 多 avatar 预载)
```
回滚:素材包按目录整体保留旧版本(`<persona>.bak-<date>`),avatar 目录同理;manifest 记录版本,恢复=改回引用。模型权重不入 Git,assets 二进制走附件/对象存储,仓库只放 manifest 与脚本。
## 5. 换一套外观的实测成本(CG-005/006 输入)
| 项 | 实测耗时 | 说明 |
| --- | --- | --- |
| 交谈底板由既有成品裁切 | <1min(PyAV 转码)+ 47~60s genavatar | 本轮 talk-close 路线,推荐 |
| 交谈底板由 Wan2.2 全新生成 | ~57s/3s 片段 + 挑选重试(3~5 倍余量) | HEL-712 实测单次推理 |
| 日常成品视频生成 | 同上量级/段 | 小队批量制作 |
| 照片需求 | 定妆/场景各 ≥1 张 | 用于身份对照与 i2v 起点 |
| 预处理 | 自动,无人工参数 | bbox_shift=0 默认可用 |
## 6. 首版最小验收矩阵(交谈链路)
| 项 | 通过标准 |
| --- | --- |
| 日常循环播放 | ≥3 周期无肉眼跳变(或美术确认衔接) |
| 进交谈 | 切换 ≤200ms,同人物同服装目视确认 |
| 口型 | 说话段口部动作肉眼可辨,静息段无明显异常 |
| 打断 | interrupt 后 ≤1s 静音,不等播完 |
| 再开口 | 打断后可立即再次发话 |
| 回日常 | 切回 ≤200ms,日常从暂停点续播 |
| 持续 | 单次会话 ≥10 分钟不崩、显存不涨 |
| 失败路径 | TTS 断连时有可见错误提示,可恢复 |
## 7. 职责划分(衔接 HEL-714 施工)
- 客户端(web):日常选片/循环、交谈视图挂载(预连接 WebRTC)、打断信号(先按钮/快捷键,VAD 后置)、失败回退 UI。
- 服务端:LiveTalking(交谈渲染 + TTS 适配,本分支 patch);日常视频静态文件由现有 web 服务出。
- 素材:美术/施工按第 1 节规范产出,总工验收导入。
-42
View File
@@ -1,42 +0,0 @@
# HEL-715 实验复现说明
实验环境(.12,全部隔离在 `/opt/hel715-verify/`,未改系统 Python/CUDA/ASR/TTS):
- LiveTalking c726943(官方 GitHub)+ MuseTalk v1.5 权重 3.8GB(`LiveTalking/models/`)
- venv:`/opt/hel715-verify/venv`(torch 2.8.0+cu128、aiortc 1.15.0、PyAV 17.1.0)
- 既有服务复用:CosyVoice TTS `http://127.0.0.1:9880`(音色 xiaoyun)、LLM `http://192.168.200.11:8731`(halogen-qwen3.8-flash-next)
- 对上游的两处改动:`livetalking-patch/cosyvoice.py`(新增,对接 .12 CosyVoice)、`livetalking-patch/enable-cosyvoice-registration.diff`(base_avatar.py 启用注册)
## 启动
```bash
cd /opt/hel715-verify/LiveTalking
nohup /opt/hel715-verify/venv/bin/python app.py --model musetalk \
--avatar_id hel715-talk-close --transport webrtc \
--tts cosyvoice --REF_FILE xiaoyun \
--TTS_SERVER http://127.0.0.1:9880 --listenport 8010 \
> /opt/hel715-verify/livetalking.log 2>&1 &
```
注意:仓库内 `config.yaml` 的 `TTS_SERVER: ''` 会覆盖 argparse 默认值,必须显式传 `--TTS_SERVER`(本轮曾因此空跑过一次)。
已生成 avatar(`LiveTalking/data/avatars/`):`hel715-loop-wide`(loop-25fps.mp4,196 帧全景)、`hel715-talk-close`(talk-close.mp4,196 帧近景=loop 1.75× 裁切)、`hel715-return`(return-25fps.mp4,77 帧独立源)。
## 测试脚本
- `scripts/e2e_client.py`:aiortc 收流客户端,四句时间线(含打断),产出 mp4+stats.json+events.json+contact-sheet.png。在 .12 上运行:`E2E_OUT=... venv/bin/python e2e_client.py`。
- `scripts/llm_chain2.py`:LLM→TTS→数字人单链路采样。
- `scripts/hel715-chain.html`:日常→交谈→打断→回日常→再交谈演示页(部署于 LiveTalking `web/`,`?auto=1` 自动演示;依赖 `web/hel715-daily.webm`)。
- `scripts/record_chain.py`:本机(192.168.200.13)Playwright+Chromium 录制上述页面,需 LD_LIBRARY_PATH 指向解包的运行库(脚本内已写死 /tmp 路径,迁移时改)。
## 清理
```bash
# 停实验服务(先对 PID:pgrep -af 'app.py --model musetalk')
pkill -f 'app.py --model musetalk'
# 删除整个实验目录(含 venv/权重/素材,约 12GB)
rm -rf /opt/hel715-verify
# 演示页随目录一起消失;18080/18081 旧演示与 ASR/TTS 未动
```
本轮收尾状态:LiveTalking 已停止;目录与权重保留供 HEL-714 复用。
-73
View File
@@ -1,73 +0,0 @@
# HEL-715 技术验证报告:本地真人交谈与日常视频衔接
日期:2026-10-20 | 环境:192.168.200.12(RTX 5090D 32GB,torch 2.8.0+cu128)
候选:LiveTalking @ c726943(官方 GitHub)+ MuseTalk v1.5 + .12 既有 CosyVoice(9880) + .11 LLM(halogen-qwen3.8-flash-next)
## 一句话结论
**有条件可用。** 日常↔交谈切换、打断、25fps 连续口型视频、素材流水线复用均实测通过;条件是接受 ①发话到出声约 4.5~5.5s 的起步延迟,②TTS 流式按标点分句带来的句内 0.4~1.1s 停顿,③交谈驱动只改口型(表情/目光来自底板视频)。三项都需要在首版验收时向用户如实展示,其中 ①② 可在施工阶段继续压。
## 四个问题的实测回答
### 1. 主候选 LiveTalking+MuseTalk 实际跑通了吗?——跑通
- 安装:官方仓库 clone 至 `/opt/hel715-verify/LiveTalking`,独立 venv(不改系统 Python/CUDA/ASR/TTS);权重 musetalkV15/face-parse/sd-vae/whisper 共约 3.8GB,已就位。
- 适配:仅两处改动(见 `livetalking-patch/`):`tts/cosyvoice.py` 新增(对接 .12 既有 CosyVoice 的 POST /tts 24kHz PCM 流)+ `avatars/base_avatar.py` 启用该 TTS 注册。其余代码未动。
- 能力边界(CG-028 相关,明确来源):
- **口型**:实时音频驱动(whisper 特征→unet 逐帧生成下半脸)——实测(口部区差分:静息中位 0.74 vs 说话峰值 13~14)。
- **表情/目光/头姿/身体**:全部来自底板视频循环(预处理时逐帧 landmark+face-parse 固化),**不是**实时生成——来源:代码结构+录像复核。交谈底板选片时必须挑"目视镜头、微动作"段。
- 不要求也不提供逐句新肩部手势;与 CG-027/028 的预期一致,未发现必须换 Ditto 等替代的实证。
### 2. 同一人物实时交谈可否成立?——可以,起步延迟与句内停顿需接受
实测(aiortc 客户端走真实 WebRTC 通道,含网络传输;e2e-session.mp4 / stats.json):
| 指标 | 实测值 |
| --- | --- |
| 连接→首帧视频 | 3.6s(含 ICE 协商 1.7s) |
| 文本→首次出声 | 4.6~5.5s(CosyVoice 首包 3.4s + LiveTalking 音频缓冲约 1.2s) |
| 连续帧率 | 25.05fps 恒定(27s 全程,说话与静息一致) |
| 音画同步 | 容器内音视频齐备;口型动作较出声晚约 0.5~1.3s(MuseTalk 音频前瞻缓冲所致)。自动互相关度量噪声大,最终以录像人工复核为准 |
| 句内停顿 | TTS 按标点分句合成,句内间隙 0.45~1.05s("抱歉"与"你刚想说什么"之间 0.66s)——CosyVoice 流式模式固有,非口型管线问题 |
| 打断→静音 | **0.35s**(interrupt_talk 后队列即清) |
| 打断后再开口 | 正常(S3/S4 均正常播出) |
| 峰值资源 | 总显存 18.9GB(基线 5.8GB + CosyVoice 4.2GB + LiveTalking 空载 11.2GB/说话峰值约 13.1GB);GPU 利用率瞬时 100% 但帧率不跌 |
| 失败行为 | 本轮未触发服务崩溃;语音名不存在时 TTS 适配层回退 default 音色 |
LLM 链路(llm-chain.mp4):.11 真实模型 halogen-qwen3.8-flash-next 回复 1.33s → 递给数字人出声共 4.63s,全程录音正常。
### 3. 日常→交谈→打断→回日常衔接?——信号级链路实测通过
两份证据:
- **浏览器真实链路**(chain/page@*.webm,Playwright+Chromium 走 LAN WebRTC):日常成品视频循环 → 切交谈视图(预连接流,切换 0~3ms)→ 发文本 → 语音进行中注入 interrupt_talk(HTTP 2~5ms)→ 立即切回日常(不等她说完、不等日常片尾)→ 再次进入交谈并发第二句 → 回日常。全程 33.5s,页面零 JS 错误。注:Playwright 录屏不含音轨,音频证据见下条。
- **媒体级连续音画**(e2e-session.mp4):同一 WebRTC 通道收到的连续音视频原样落盘,覆盖 静息→说话→打断→静默→再说话 全程,有声有画。
边界说明:麦克风/VAD 未接入,打断由信号注入触发,**只是信号级验证**,不冒充自然语音抢话已通过。转场为视图级切换(同人物同房间同服装的近景底板 vs 日常全景),自然转场(如淡入淡出)属施工项,不计入本轮结论。
### 4. 素材能否持续制作/替换/维护?——流水线已验证可复用
- 同一流水线已跑通 **3 份素材、3 个 avatar**:loop 全景(196 帧)、面部近景(196 帧,经核实为 loop 素材 1.75× 放大裁切,同人物同房间同服装)、回程片段(77 帧,独立源文件)。
- 新素材仅需:转码 25fps mp4 → `python -m avatars.musetalk.genavatar --file X --avatar_id Y`,47~60s/份(GPU),零代码改动、目录隔离、无硬编码人脸。
- 成本实测:交谈底板若由 Wan2.2 生成约 57s/3s 片段(HEL-712 实测);由既有成品裁切仅需秒级(PyAV)。详见 MATERIAL-SPEC.md。
- 限制:本轮三份素材同属一个人物;跨人物泛化未实测(离线环境无第二真人素材可用),算法层面为通用 landmark/face-parse 管线,无按脸硬编码逻辑。
## CG-029/028 能力矩阵(来源/实测/未测)
| 能力 | 状态 | 依据 |
| --- | --- | --- |
| 成品日常视频按时段/活动选片播放 | 实测(浏览器循环播放) | chain webm |
| 同人物近景实时口型交谈 | 实测 | e2e-session.mp4 / llm-chain.mp4 |
| 口型由音频实时驱动 | 实测 | 口部差分 15~19 倍 |
| 表情/目光实时生成 | 未测(来源=底板视频) | 代码结构 |
| 语音打断(信号级) | 实测 0.35s 停止 | events.json |
| 自然语音抢话(VAD) | 未测 | 麦克风未接 |
| 日常↔交谈视图切换 | 实测 0~3ms | chain webm + 日志 |
| LLM→TTS→口型全链 | 实测 | llm-chain.mp4 |
| 素材替换保留人格记忆 | 设计已验证路径(素材目录隔离) | genavatar 复用 |
## 风险与建议(给总管的决策输入)
1. **起步延迟 4.5~5.5s** 是当前体验最大短板:可在施工时用"思考中"微动作/状态提示掩盖,或后续换更低首包延迟的 TTS 配置(CosyVoice 首包 3.4s 占大头)。无已确认延迟门槛,不编造 SLA;建议向用户展示实测录像后再定验收线。
2. **句内停顿**可通过 TTS 侧整句缓冲或标点预处理缓解,代价是首包更慢——二者权衡留施工阶段。
3. LiveTalking 空载即占 11.2GB 显存,与 ComfyUI 重生成任务并存时需调度(先交谈后生成的队列约定)。
@@ -1,82 +0,0 @@
import time
import numpy as np
import resampy
import requests
from io import BytesIO
from typing import Iterator
from utils.logger import logger
from .base_tts import BaseTTS, State
from registry import register
@register("tts", "cosyvoice")
class CosyVoiceTTS(BaseTTS):
"""适配本机既有 CosyVoice3 流式 TTS (POST /tts -> 24kHz int16 PCM 流)."""
def txt_to_audio(self, msg: tuple[str, dict]):
text, textevent = msg
voice = textevent.get('tts', {}).get('voice', self.opt.REF_FILE or 'default')
self.stream_tts(self.cosyvoice(text, voice), msg)
def cosyvoice(self, text: str, voice: str) -> Iterator[bytes]:
start = time.perf_counter()
try:
res = requests.post(
f"{self.opt.TTS_SERVER}/tts",
json={"text": text, "voice": voice},
stream=True,
timeout=(5, 300),
)
if res.status_code != 200:
logger.error("cosyvoice http %s: %s", res.status_code, res.text[:200])
return
first = True
for chunk in res.iter_content(chunk_size=4800): # 100ms @24k int16
if first:
logger.info("cosyvoice time to first chunk: %.3fs", time.perf_counter() - start)
first = False
if chunk and self.state == State.RUNNING:
yield chunk
elif self.state != State.RUNNING:
logger.info("cosyvoice interrupted, stop consuming")
res.close()
return
except Exception:
logger.exception('cosyvoice tts error')
def stream_tts(self, audio_stream: Iterator[bytes], msg: tuple[str, dict]):
text, textevent = msg
first = True
for chunk in audio_stream:
if not chunk:
continue
pcm24 = np.frombuffer(chunk, dtype=np.int16).astype(np.float32) / 32767.0
stream = resampy.resample(pcm24, sr_orig=24000, sr_new=self.sample_rate)
streamlen = stream.shape[0]
idx = 0
while streamlen >= self.chunk:
eventpoint = {}
if first:
eventpoint = {'status': 'start', 'text': text}
first = False
eventpoint.update(**textevent)
self.parent.put_audio_frame(stream[idx:idx + self.chunk], eventpoint)
streamlen -= self.chunk
idx += self.chunk
eventpoint = {'status': 'end', 'text': text}
eventpoint.update(**textevent)
self.parent.put_audio_frame(np.zeros(self.chunk, np.float32), eventpoint)
diff --git a/avatars/base_avatar.py b/avatars/base_avatar.py
index 28c826a..a9744e6 100644
--- a/avatars/base_avatar.py
+++ b/avatars/base_avatar.py
@@ -90,7 +90,7 @@ class BaseAvatar:
'edgetts': 'tts.edge',
'gpt-sovits': 'tts.sovits',
'xtts': 'tts.xtts',
- # 'cosyvoice': 'tts.cosyvoice',
+ 'cosyvoice': 'tts.cosyvoice',
# 'fishtts': 'tts.fish',
'tencent': 'tts.tencent',
'doubao': 'tts.doubao',
@@ -1,69 +0,0 @@
import time
import numpy as np
import resampy
import requests
from io import BytesIO
from typing import Iterator
from utils.logger import logger
from .base_tts import BaseTTS, State
from registry import register
@register("tts", "cosyvoice")
class CosyVoiceTTS(BaseTTS):
"""适配本机既有 CosyVoice3 流式 TTS (POST /tts -> 24kHz int16 PCM 流)."""
def txt_to_audio(self, msg: tuple[str, dict]):
text, textevent = msg
voice = textevent.get('tts', {}).get('voice', self.opt.REF_FILE or 'default')
self.stream_tts(self.cosyvoice(text, voice), msg)
def cosyvoice(self, text: str, voice: str) -> Iterator[bytes]:
start = time.perf_counter()
try:
res = requests.post(
f"{self.opt.TTS_SERVER}/tts",
json={"text": text, "voice": voice},
stream=True,
timeout=(5, 300),
)
if res.status_code != 200:
logger.error("cosyvoice http %s: %s", res.status_code, res.text[:200])
return
first = True
for chunk in res.iter_content(chunk_size=4800): # 100ms @24k int16
if first:
logger.info("cosyvoice time to first chunk: %.3fs", time.perf_counter() - start)
first = False
if chunk and self.state == State.RUNNING:
yield chunk
elif self.state != State.RUNNING:
logger.info("cosyvoice interrupted, stop consuming")
res.close()
return
except Exception:
logger.exception('cosyvoice tts error')
def stream_tts(self, audio_stream: Iterator[bytes], msg: tuple[str, dict]):
text, textevent = msg
first = True
for chunk in audio_stream:
if not chunk:
continue
pcm24 = np.frombuffer(chunk, dtype=np.int16).astype(np.float32) / 32767.0
stream = resampy.resample(pcm24, sr_orig=24000, sr_new=self.sample_rate)
streamlen = stream.shape[0]
idx = 0
while streamlen >= self.chunk:
eventpoint = {}
if first:
eventpoint = {'status': 'start', 'text': text}
first = False
eventpoint.update(**textevent)
self.parent.put_audio_frame(stream[idx:idx + self.chunk], eventpoint)
streamlen -= self.chunk
idx += self.chunk
eventpoint = {'status': 'end', 'text': text}
eventpoint.update(**textevent)
self.parent.put_audio_frame(np.zeros(self.chunk, np.float32), eventpoint)
@@ -1,13 +0,0 @@
diff --git a/avatars/base_avatar.py b/avatars/base_avatar.py
index 28c826a..a9744e6 100644
--- a/avatars/base_avatar.py
+++ b/avatars/base_avatar.py
@@ -90,7 +90,7 @@ class BaseAvatar:
'edgetts': 'tts.edge',
'gpt-sovits': 'tts.sovits',
'xtts': 'tts.xtts',
- # 'cosyvoice': 'tts.cosyvoice',
+ 'cosyvoice': 'tts.cosyvoice',
# 'fishtts': 'tts.fish',
'tencent': 'tts.tencent',
'doubao': 'tts.doubao',
-375
View File
@@ -1,375 +0,0 @@
#!/usr/bin/env python
"""HEL-715 end-to-end verification client for LiveTalking(MuseTalk) @ 127.0.0.1:8010.
Connects as a WebRTC receiver (same path a browser takes), drives the avatar via
/human + /interrupt_talk, records the received A/V to mp4 and logs timing stats.
"""
import asyncio
import json
import os
import time
import aiohttp
import av
import numpy as np
from aiortc import RTCPeerConnection, RTCSessionDescription
BASE = os.environ.get("LT_BASE", "http://127.0.0.1:8010")
OUT = os.environ.get("E2E_OUT", "/opt/hel715-verify/e2e")
os.makedirs(OUT, exist_ok=True)
S1 = "在呢。你回来啦,今天在外面累不累?"
S2 = "还有,我跟你说,今天下午我在窗边看了好久的天空,云跑得特别快,有一朵特别像小兔子,我还给它拍了张照片,等下拿给你看。"
S3 = "抱歉,你刚想说什么?我在听。"
S4 = "嗯,我在。"
# approx mouth region (lower third of face bbox 218,203,324,333 from avatar prep)
MOUTH = (222, 268, 320, 331)
log_lines = []
def log(msg):
line = f"[{time.strftime('%H:%M:%S')}] {msg}"
print(line, flush=True)
log_lines.append(line)
class Recorder:
def __init__(self, path, w, h):
self.container = av.open(path, "w")
self.vstream = self.container.add_stream("libx264", rate=25)
self.vstream.width = w
self.vstream.height = h
self.vstream.pix_fmt = "yuv420p"
self.vstream.options = {"crf": "20", "preset": "veryfast"}
self.astream = self.container.add_stream("aac", rate=48000)
self.astream.layout = "stereo"
def write_video(self, img_bgr):
frame = av.VideoFrame.from_ndarray(img_bgr, format="bgr24")
for p in self.vstream.encode(frame):
self.container.mux(p)
def write_audio(self, pcm_i16, sr, ch):
arr = pcm_i16.astype(np.int16)
if ch == 1:
arr = np.repeat(arr[:, None], 2, axis=1).reshape(-1)
elif ch == 2:
arr = arr.reshape(-1)
else:
arr = arr[: len(arr) // ch * ch].reshape(-1, ch)[:, :2].reshape(-1)
if sr != 48000:
n = len(arr) // 2
idx = (np.arange(n) * (sr / 48000.0)).astype(int)
idx = idx[idx < len(arr) // 2]
arr = arr.reshape(-1, 2)[idx].reshape(-1)
frame = av.AudioFrame.from_ndarray(
np.ascontiguousarray(arr.reshape(1, -1)), format="s16", layout="stereo"
)
frame.sample_rate = 48000
for p in self.astream.encode(frame):
self.container.mux(p)
def close(self):
for p in self.vstream.encode():
self.container.mux(p)
for p in self.astream.encode():
self.container.mux(p)
self.container.close()
class Session:
def __init__(self):
self.events = {"video": [], "audio": [], "speak": [], "actions": []}
self.imgs_sample = []
self.sid = None
self.http = None
async def post(self, path, payload):
t0 = time.perf_counter()
async with self.http.post(BASE + path, json=payload) as r:
body = await r.json()
return time.perf_counter() - t0, body
async def poll_speaking(self, stop_evt):
while not stop_evt.is_set():
try:
dt, body = await self.post("/is_speaking", {"sessionid": self.sid})
self.events["speak"].append(
[round(time.perf_counter(), 3), body.get("data", False)]
)
except Exception:
pass
await asyncio.sleep(0.25)
def mouth_motion(prev, cur):
if prev is None or cur is None:
return 0.0
x0, y0, x1, y1 = MOUTH
d = np.abs(
cur[y0:y1, x0:x1].astype(np.int16) - prev[y0:y1, x0:x1].astype(np.int16)
).mean()
return float(d)
async def main():
stats = {"sentences": {}, "meta": {}}
rec = None
pc = RTCPeerConnection()
sess = Session()
t_start = time.perf_counter()
done = asyncio.Event()
@pc.on("track")
def on_track(track):
log(f"track: {track.kind}")
if track.kind == "video":
asyncio.ensure_future(video_loop(track))
else:
asyncio.ensure_future(audio_loop(track))
async def video_loop(track):
nonlocal rec
prev = None
while True:
try:
frame = await track.recv()
except Exception:
break
img = frame.to_ndarray(format="bgr24")
if rec is None:
rec = Recorder(os.path.join(OUT, "e2e-session.mp4"), img.shape[1], img.shape[0])
log(f"recorder ready {img.shape[1]}x{img.shape[0]}")
t = time.perf_counter()
m = mouth_motion(prev, img)
sess.events["video"].append([round(t, 3), round(m, 2)])
prev = img
rec.write_video(img)
if len(sess.imgs_sample) < 400:
sess.imgs_sample.append((round(t - t_start, 2), img))
async def audio_loop(track):
while True:
try:
frame = await track.recv()
except Exception:
break
t = time.perf_counter()
arr = frame.to_ndarray()
if arr.dtype != np.int16:
if arr.dtype in (np.float32, np.float64):
arr = (np.clip(arr, -1, 1) * 32767).astype(np.int16)
else:
arr = arr.astype(np.int16)
ch = len(frame.layout.channels)
if arr.ndim == 2 and arr.shape[0] == 1:
pcm = arr.reshape(-1) # packed s16
elif arr.ndim == 2 and arr.shape[0] == ch:
pcm = arr.T.reshape(-1) # planar
else:
pcm = arr.reshape(-1)
ch = 1
rms = float(np.sqrt((pcm.astype(np.float64) ** 2).mean()) / 32767.0)
sess.events["audio"].append([round(t, 3), round(rms, 5)])
if rec is not None:
rec.write_audio(pcm, frame.sample_rate, ch)
async with aiohttp.ClientSession() as http:
sess.http = http
pc.addTransceiver("video", direction="recvonly")
pc.addTransceiver("audio", direction="recvonly")
offer = await pc.createOffer()
await pc.setLocalDescription(offer)
while pc.iceGatheringState != "complete":
await asyncio.sleep(0.1)
t0 = time.perf_counter()
async with http.post(
BASE + "/offer", json={"sdp": pc.localDescription.sdp, "type": "offer"}
) as r:
answer = await r.json()
log(f"offer round-trip {time.perf_counter()-t0:.3f}s sessionid={answer.get('sessionid')}")
sess.sid = answer["sessionid"]
await pc.setRemoteDescription(
RTCSessionDescription(sdp=answer["sdp"], type=answer["type"])
)
stats["meta"]["sessionid"] = sess.sid
stop_evt = asyncio.Event()
poller = asyncio.ensure_future(sess.poll_speaking(stop_evt))
def mark(name):
t = time.perf_counter()
sess.events["actions"].append([round(t, 3), name])
log(f"ACTION {name}")
return t
# wait for first video frame / idle
for _ in range(200):
if sess.events["video"]:
break
await asyncio.sleep(0.1)
first_v = sess.events["video"][0][0] if sess.events["video"] else -1
first_a = sess.events["audio"][0][0] if sess.events["audio"] else -1
stats["meta"]["first_video_s"] = round(first_v - t_start, 3)
stats["meta"]["first_audio_s"] = round(first_a - t_start, 3)
log(f"first video {stats['meta']['first_video_s']}s audio {stats['meta']['first_audio_s']}s")
await asyncio.sleep(3.0) # idle baseline
async def speak(name, text, wait_end=True, interrupt_after=None, next_line=None):
t_send = mark(f"send:{name}")
dt, body = await sess.post(
"/human", {"sessionid": sess.sid, "type": "echo", "text": text}
)
log(f"/human {name} ack {dt:.3f}s {body}")
t_interrupt = None
if interrupt_after is not None:
await asyncio.sleep(interrupt_after)
t_interrupt = mark(f"interrupt:{name}")
dt2, b2 = await sess.post("/interrupt_talk", {"sessionid": sess.sid})
log(f"/interrupt_talk ack {dt2:.3f}s {b2}")
if wait_end:
# wait until speaking False and audio silent
silent = 0.0
last_speak_true = time.perf_counter()
while True:
await asyncio.sleep(0.1)
now = time.perf_counter()
sp = sess.events["speak"][-1][1] if sess.events["speak"] else None
if sp:
last_speak_true = now
recent = [a for a in sess.events["audio"] if a[0] > now - 0.6]
loud = any(a[1] > 0.02 for a in recent)
if not sp and not loud:
silent = now - last_speak_true
if silent > 1.2:
break
else:
last_speak_true = max(last_speak_true, now)
return t_send, t_interrupt
# S1 short
s, _ = await speak("S1", S1)
stats["sentences"]["S1"] = {"text": S1, "sent_at": round(s - t_start, 2)}
await asyncio.sleep(1.5)
# S2 long with interrupt 3s after send
s2, ti = await speak("S2", S2, wait_end=True, interrupt_after=3.0)
stats["sentences"]["S2"] = {
"text": S2, "sent_at": round(s2 - t_start, 2),
"interrupt_at": round(ti - t_start, 2),
}
await asyncio.sleep(2.0)
# S3 re-open after interrupt
s3, _ = await speak("S3", S3)
stats["sentences"]["S3"] = {"text": S3, "sent_at": round(s3 - t_start, 2)}
await asyncio.sleep(3.0) # pause test
s4, _ = await speak("S4", S4)
stats["sentences"]["S4"] = {"text": S4, "sent_at": round(s4 - t_start, 2)}
mark("end")
await asyncio.sleep(2.0)
stop_evt.set()
poller.cancel()
await pc.close()
if rec:
rec.close()
log("recording closed")
# analysis
def rel(ts):
return round(ts - t_start, 3)
ev_a = sess.events["audio"]
ev_v = sess.events["video"]
for name, info in stats["sentences"].items():
sent = info.get("sent_at", 0) + t_start
aud = [a for a in ev_a if a[0] >= sent and a[1] > 0.02]
info["first_audible_rel_s"] = rel(aud[0][0]) if aud else None
if aud:
last = sent
for a in aud:
if a[0] - last < 1.5:
last = a[0]
info["last_audible_rel_s"] = rel(last)
if "interrupt_at" in info:
it = info["interrupt_at"] + t_start
after = [a for a in ev_a if a[0] >= it and a[1] > 0.02]
info["audible_after_interrupt_s"] = rel(after[0][0]) if after else None
tail = [a for a in ev_a if a[0] >= it]
tl = [a for a in tail if a[1] > 0.02]
info["last_audible_after_interrupt_rel_s"] = rel(tl[-1][0]) if tl else None
# video motion around each sentence
for name, info in stats["sentences"].items():
sent = info.get("sent_at", 0) + t_start
window = [v for v in ev_v if sent - 1.0 <= v[0] <= sent + 12]
base = [v for v in ev_v if v[0] < sent - 1.0][-60:]
if window and base:
b = np.median([v[1] for v in base])
info["mouth_motion_idle"] = round(float(b), 2)
peak = max(v[1] for v in window)
info["mouth_motion_peak"] = round(float(peak), 2)
# fps over speech windows
if ev_v:
dur = ev_v[-1][0] - ev_v[0][0]
stats["meta"]["video_frames"] = len(ev_v)
stats["meta"]["video_span_s"] = round(dur, 2)
stats["meta"]["avg_recv_fps"] = round(len(ev_v) / dur, 2) if dur > 0 else None
if ev_a:
dur = ev_a[-1][0] - ev_a[0][0]
stats["meta"]["audio_frames"] = len(ev_a)
stats["meta"]["audio_span_s"] = round(dur, 2)
stats["meta"]["wall_total_s"] = round(time.perf_counter() - t_start, 2)
with open(os.path.join(OUT, "stats.json"), "w") as f:
json.dump(stats, f, ensure_ascii=False, indent=1)
with open(os.path.join(OUT, "events.json"), "w") as f:
json.dump(
{
"t_start": t_start,
"video": sess.events["video"],
"audio": sess.events["audio"],
"speak": sess.events["speak"],
"actions": sess.events["actions"],
},
f,
)
with open(os.path.join(OUT, "run.log"), "w") as f:
f.write("\n".join(log_lines))
# contact sheet: pick frames around S1 onset, S2 mid, interrupt, S3
try:
from PIL import Image
picks = []
s1 = stats["sentences"]["S1"].get("first_audible_rel_s") or 5
s2i = stats["sentences"]["S2"].get("interrupt_at")
picks += [max(0, s1 - 1.5), s1 + 0.5, s1 + 2.5]
if s2i:
picks += [s2i - 1.0, s2i + 0.3, s2i + 1.5]
picks += [stats["sentences"]["S3"]["sent_at"] + 2.0]
tiles = []
for p in picks:
cand = min(sess.imgs_sample, key=lambda x: abs(x[0] - p))
im = Image.fromarray(cand[1][:, :, ::-1]).resize((276, 276))
tiles.append((cand[0], im))
sheet = Image.new("RGB", (276 * len(tiles), 276 + 18), "black")
from PIL import ImageDraw
d = ImageDraw.Draw(sheet)
for i, (ts, im) in enumerate(tiles):
sheet.paste(im, (i * 276, 18))
d.text((i * 276 + 4, 2), f"t={ts:.1f}s", fill="white")
sheet.save(os.path.join(OUT, "contact-sheet.png"))
except Exception as e:
log(f"contact sheet failed: {e}")
log("DONE")
if __name__ == "__main__":
asyncio.run(main())
@@ -1,129 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8"/>
<title>HEL-715 日常→交谈→打断→日常 链路演示</title>
<style>
html,body{margin:0;height:100%;background:#000;color:#eee;font-family:"Noto Sans CJK SC",sans-serif;overflow:hidden}
#stage{position:relative;height:100%;display:flex;align-items:center;justify-content:center}
video{position:absolute;height:92%;top:2%;left:50%;transform:translateX(-50%);background:#000}
#daily{display:block}
#talk{display:none}
#hud{position:absolute;left:0;right:0;bottom:0;padding:10px 14px;background:rgba(0,0,0,.72);font-size:15px;line-height:1.5}
#phase{font-size:19px;font-weight:700;color:#ffd166}
#timer{position:absolute;top:8px;right:14px;font-family:monospace;font-size:18px;background:rgba(0,0,0,.6);padding:2px 8px;border-radius:4px}
#log{position:absolute;top:8px;left:14px;font-family:monospace;font-size:12px;background:rgba(0,0,0,.6);padding:6px 10px;border-radius:4px;max-height:46%;overflow:hidden;white-space:pre}
</style>
</head>
<body>
<div id="stage">
<video id="daily" autoplay loop muted playsinline>
<source src="/hel715-daily.webm" type="video/webm"/>
<source src="/hel715-daily.mp4" type="video/mp4"/>
</video>
<video id="talk" autoplay playsinline></video>
<div id="timer">0.0s</div>
<div id="log"></div>
<div id="hud"><div id="phase">初始化…</div><div id="note">成品日常视频循环播放 · 交谈流后台预连接</div></div>
</div>
<script>
const BASE = location.origin;
const $ = id => document.getElementById(id);
const t0 = performance.now();
const logs = [];
function now(){ return (performance.now()-t0)/1000; }
function log(msg){
const line = `${now().toFixed(2)}s ${msg}`;
logs.push(line);
$('log').textContent = logs.slice(-14).join('\n');
console.log('[chain]', line);
}
function phase(p, note){ $('phase').textContent = p; $('note').textContent = note||''; }
setInterval(()=>{ $('timer').textContent = now().toFixed(1)+'s'; }, 100);
/* ---------- WebRTC pre-connect (same path as LiveTalking client.js) ---------- */
let pc = null, sessionid = null, connected = false, firstTalkFrameAt = null;
async function connectTalk(){
pc = new RTCPeerConnection({sdpSemantics:'unified-plan'});
pc.addTransceiver('video',{direction:'recvonly'});
pc.addTransceiver('audio',{direction:'recvonly'});
pc.addEventListener('track', ev=>{
if(ev.track.kind==='video'){
firstTalkFrameAt = firstTalkFrameAt || now();
$('talk').srcObject = ev.streams[0];
log('交谈视频轨到达');
} else {
$('talk').srcObject = $('talk').srcObject || ev.streams[0];
}
});
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
while(pc.iceGatheringState!=='complete') await new Promise(r=>setTimeout(r,50));
const res = await fetch(BASE+'/offer',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({sdp:pc.localDescription.sdp,type:pc.localDescription.type})});
const answer = await res.json();
sessionid = answer.sessionid;
await pc.setRemoteDescription(answer);
log(`交谈流已连接 session=${sessionid.slice(0,8)}(预连接,用户尚未进入)`);
connected = true;
}
async function say(text){
const t=now();
const r = await fetch(BASE+'/human',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({sessionid, type:'echo', text})});
log(`发送对话文本(${text.slice(0,10)}…)HTTP ${(now()-t)*1000|0}ms`);
}
async function interrupt(){
const t=now();
await fetch(BASE+'/interrupt_talk',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({sessionid})});
log(`打断信号 interrupt_talk HTTP ${(now()-t)*1000|0}ms`);
}
/* ---------- view switching ---------- */
function showDaily(){ $('daily').style.display='block'; $('talk').style.display='none'; }
function showTalk(){ $('daily').style.display='none'; $('talk').style.display='block'; }
window.__chainLog = logs;
/* ---------- auto timeline ---------- */
const auto = new URLSearchParams(location.search).has('auto');
if(auto){
(async ()=>{
await connectTalk().catch(e=>log('连接失败: '+e));
phase('① 日常播放','成品日常原片(HEL-712 同人物素材)循环播放');
await sleep(7000);
const sw = now(); showTalk();
phase('② 进入交谈','同一人物·同一房间·同一套衣服 · 实时口型(MuseTalk)');
log(`切换到交谈视图(预连接流,切换耗时 ${(now()-sw)*1000|0}ms)`);
await say('你回来啦!我在窗边等你呢,快过来坐,我跟你说今天下午的云。');
await sleep(6500); // speech ongoing (~1s in voice)
phase('③ 用户开口→打断','interrupt_talk 信号注入 · 不等她说完、不等日常片尾');
const ti = now();
await interrupt();
showDaily(); // 回日常/倾听,不等待
log(`打断后立即切回日常视图(+${((now()-ti)*1000)|0}ms)`);
await sleep(4000);
showTalk();
phase('④ 再次交谈','打断后可立即重新开口(清空待播队列后)');
log('再次切换到交谈视图');
await say('嗯,你说,我在听。');
await sleep(8000);
showDaily();
phase('⑤ 回到日常','交谈结束 → 成品日常继续');
log('回到日常播放');
await sleep(4000);
phase('演示结束','HEL-715 链路取样:日常→交谈→打断→回日常→再交谈');
log('AUTO-DEMO-END');
})();
} else {
phase('手动模式','D=日常 T=进入交谈并发送 I=打断并回日常');
addEventListener('keydown', async ev=>{
if(ev.key==='d'){ showDaily(); log('手动: 日常'); }
if(ev.key==='t'){ showTalk(); log('手动: 交谈'); if(connected) await say('你回来啦!我在窗边等你呢。'); }
if(ev.key==='i'){ await interrupt(); showDaily(); log('手动: 打断→日常'); }
});
connectTalk().catch(e=>log('连接失败: '+e));
}
function sleep(ms){ return new Promise(r=>setTimeout(r,ms)); }
</script>
</body>
</html>
-124
View File
@@ -1,124 +0,0 @@
#!/usr/bin/env python
"""LLM-chained conversation sample: .11 LLM -> .12 CosyVoice -> MuseTalk avatar.
Same receiver architecture as e2e_client.py (proven to capture A/V)."""
import asyncio, json, os, time
import aiohttp, av, numpy as np
from aiortc import RTCPeerConnection, RTCSessionDescription
BASE = "http://127.0.0.1:8010"
LLM = "http://192.168.200.11:8731/v1/chat/completions"
OUT = "/opt/hel715-verify/e2e"
os.makedirs(OUT, exist_ok=True)
class Recorder:
def __init__(self, path, w, h):
self.container = av.open(path, "w")
self.v = self.container.add_stream("libx264", rate=25)
self.v.width, self.v.height, self.v.pix_fmt = w, h, "yuv420p"
self.v.options = {"crf": "20", "preset": "veryfast"}
self.a = self.container.add_stream("aac", rate=48000)
self.a.layout = "stereo"
def video(self, img):
for p in self.v.encode(av.VideoFrame.from_ndarray(img, format="bgr24")):
self.container.mux(p)
def audio(self, frame):
arr = frame.to_ndarray()
if arr.dtype != np.int16:
arr = (np.clip(arr, -1, 1) * 32767).astype(np.int16)
ch = len(frame.layout.channels)
if arr.ndim == 2 and arr.shape[0] == 1:
pcm = arr.reshape(-1)
elif arr.ndim == 2 and arr.shape[0] == ch:
pcm = arr.T.reshape(-1)
else:
pcm = arr.reshape(-1)
fr = av.AudioFrame.from_ndarray(np.ascontiguousarray(pcm.reshape(1, -1)), format="s16", layout="stereo")
fr.sample_rate = 48000
for p in self.a.encode(fr):
self.container.mux(p)
def close(self):
for p in self.v.encode(): self.container.mux(p)
for p in self.a.encode(): self.container.mux(p)
self.container.close()
async def main():
t0 = time.perf_counter()
result = {}
async with aiohttp.ClientSession() as http:
async with http.post(LLM, json={
"model": "halogen-qwen3.8-flash-next",
"messages": [{"role": "user", "content": "你是住在高层公寓卧室里的伴侣晚晚,用一两句自然中文回应:我回来了,今天好累啊。"}],
"max_tokens": 80,
}) as r:
body = await r.json()
reply = body["choices"][0]["message"]["content"]
result["llm_model"] = body["model"]
result["llm_reply"] = reply
result["llm_latency_s"] = round(time.perf_counter() - t0, 2)
print(f"[{result['llm_latency_s']}s] LLM: {reply}", flush=True)
pc = RTCPeerConnection()
rec = {"r": None}
audio_marks = []
@pc.on("track")
def on_track(track):
async def drain():
while True:
try:
f = await track.recv()
except Exception:
break
t = time.perf_counter()
if track.kind == "video":
img = f.to_ndarray(format="bgr24")
if rec["r"] is None:
rec["r"] = Recorder(f"{OUT}/llm-chain.mp4", img.shape[1], img.shape[0])
rec["r"].video(img)
else:
arr = f.to_ndarray()
if arr.dtype != np.int16:
arr = (np.clip(arr, -1, 1) * 32767).astype(np.int16)
rms = float(np.sqrt((arr.astype(np.float64) ** 2).mean()) / 32767)
audio_marks.append([round(t - t0, 3), round(rms, 5)])
if rec["r"] is not None:
rec["r"].audio(f)
asyncio.ensure_future(drain())
pc.addTransceiver("video", direction="recvonly")
pc.addTransceiver("audio", direction="recvonly")
offer = await pc.createOffer()
await pc.setLocalDescription(offer)
while pc.iceGatheringState != "complete":
await asyncio.sleep(0.1)
async with http.post(BASE + "/offer", json={"sdp": pc.localDescription.sdp, "type": "offer"}) as r:
answer = await r.json()
await pc.setRemoteDescription(RTCSessionDescription(sdp=answer["sdp"], type=answer["type"]))
sid = answer["sessionid"]
await asyncio.sleep(3)
t_send = time.perf_counter() - t0
async with http.post(BASE + "/human", json={"sessionid": sid, "type": "echo", "text": reply}) as r:
await r.json()
print(f"[{t_send:.2f}s] sent to avatar", flush=True)
await asyncio.sleep(15)
await pc.close()
if rec["r"]:
rec["r"].close()
loud = [m for _, m in audio_marks if _ and m > 0.02]
first_loud = next((t for t, m in audio_marks if m > 0.02), None)
result["sent_at_s"] = round(t_send, 2)
result["first_audible_s"] = round(first_loud, 2) if first_loud else None
result["send_to_audible_s"] = round(first_loud - t_send, 2) if first_loud else None
with open(f"{OUT}/llm-chain-stats.json", "w") as f:
json.dump(result, f, ensure_ascii=False, indent=1)
print(json.dumps(result, ensure_ascii=False), flush=True)
print("DONE", flush=True)
asyncio.run(main())
@@ -1,59 +0,0 @@
import asyncio
import json
import os
import pathlib
os.environ["LD_LIBRARY_PATH"] = (
"/tmp/multica-task-4070005680/debs/root/usr/lib/x86_64-linux-gnu"
+ (":" + os.environ["LD_LIBRARY_PATH"] if os.environ.get("LD_LIBRARY_PATH") else "")
)
from playwright.async_api import async_playwright
OUT = pathlib.Path("/tmp/multica-task-4070005680/opencode/chain")
OUT.mkdir(exist_ok=True)
URL = "http://192.168.200.12:8010/hel715-chain.html?auto=1"
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(
args=["--autoplay-policy=no-user-gesture-required", "--no-sandbox"]
)
ctx = await browser.new_context(
viewport={"width": 1280, "height": 800},
record_video_dir=str(OUT),
record_video_size={"width": 1280, "height": 800},
)
page = await ctx.new_page()
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
await page.goto(URL, wait_until="domcontentloaded")
# wait for demo end marker (auto demo ~37s)
try:
await page.wait_for_function(
"() => window.__chainLog && window.__chainLog.some(l => l.includes('AUTO-DEMO-END'))",
timeout=60000,
)
except Exception as e:
errors.append(f"wait_for_function: {e}")
await asyncio.sleep(1.5)
logs = await page.evaluate("() => window.__chainLog")
metrics = {
"url": URL,
"chain_log": logs,
"pageerrors": errors,
}
(OUT / "chain-browser-log.json").write_text(
json.dumps(metrics, ensure_ascii=False, indent=1)
)
video = page.video
await ctx.close()
path = await video.path()
await browser.close()
print("video:", path)
print("errors:", errors)
print(json.dumps(logs, ensure_ascii=False, indent=1))
asyncio.run(main())
-12
View File
@@ -1,12 +0,0 @@
node_modules/
dist/
runtime/*.log
runtime/*.pid
__pycache__/
evidence/
packs/
public/assets/
assets/personas/**/*.mp4
assets/personas/**/*.png
-185
View File
@@ -1,185 +0,0 @@
HEL-714 日常成片+本地半身交谈样机,基于远端 main `1e9b08a` 和 P10 `6a5e155`。独立交付,不合并、不部署。PR #1 仍是旧分层小样,目标 main;本目录不依赖其尚未合并代码。
## 模块边界
- `src/main.ts`:日常选片、可切点、真实进退素材、WebRTC、麦克风/能量 VAD、原生 PCM AudioWorklet、取消、恢复和浏览器音画录制。
- `runtime/server.py`:aiohttp 同源网关;既有 FunASR WebSocket、LLM HTTP、CosyVoice HTTP、LiveTalking WebRTC 信令与音频上传。只读素材 manifest。不写人格、八字、记忆、关系或生活数据。
- `src/tokens.css` 原样复用 HEL-711 的唯一 token 表;`controls.css` 从已确认雨夜界面的基础控件抽取,未另造主题。页面布局在 `style.css`,无追加覆盖旧样式。没有引入动画库;Emil animate 模块的目的为避免场景跳变,主体过渡依赖真实正向素材,减少动态偏好下 UI 无按压位移。
- `tools/patch_driver.py` 仅修改独立 LiveTalking 代码副本,给 peer 记录 session id,并增加精确关闭该 peer 的端点。模型权重复用,不改 P10 安装、18080/18081、ASR/TTS 或系统环境。
## 运行候选
先解压本卡复核包,恢复 `assets/personas/wanwan-v2-cream-night`(整个外观包)。素材二进制不入 Git。依赖锁来自既有小样,没有新增 npm 依赖。
```bash
npm ci --ignore-scripts --no-audit --no-fund
python3 tools/pack_assets.py
npm run build
```
在 .12 复用 `/opt/hel715-verify/venv`(aiohttp 3.14.5、aiortc 1.15.0、PyAV 17.1.0、torch 2.8.0+cu128、numpy 2.5.4、resampy 0.4.3),不安装或升级系统依赖。先检查 8010、8014 没有其他监听者。
```bash
python3 tools/prepare_driver.py --destination /opt/hel714-review/LiveTalking
python3 tools/patch_driver.py /opt/hel714-review/LiveTalking
```
把构建结果、runtime 和素材放在独立样机目录后,在独立 LiveTalking 目录生成本包 avatar(仓库中没有权重或预处理张量):
```bash
cd /opt/hel714-review/LiveTalking
/opt/hel715-verify/venv/bin/python -m avatars.musetalk.genavatar \
--file /path/to/sample/dist/assets/talk/gaze.mp4 --avatar_id hel714-gaze-v1
/opt/hel715-verify/venv/bin/python app.py --model musetalk \
--avatar_id hel714-gaze-v1 --batch_size 8 --transport webrtc --tts cosyvoice \
--REF_FILE xiaoyun --TTS_SERVER http://127.0.0.1:9880 \
--listenport 8010 --stun ''
```
另一个前台终端在本目录启动网关;Ctrl-C 后确认两个自测进程都退出。禁止 pkill、fuser -k 或停止共享服务。`runtime/config.json` 是本样机唯一模型/端点配置。
```bash
/opt/hel715-verify/venv/bin/python runtime/server.py --port 8014
```
浏览器在 .12 本机可打开 `http://localhost:8014`。远程麦克风需要 HTTPS;本次自测只读复用已有 `/opt/voice-agent/web/certs/cert.pem`、`key.pem`,给网关传 `--host 0.0.0.0 --cert ... --key ...`。未改系统证书信任、防火墙或开机启动。自动化使用 Chromium 的忽略证书检查,仅作用于该测试浏览器;普通浏览器是否能授权须实际确认。
## 行为和取消契约
日常片目前只有一份雨夜阅读。活动、手动天气、时段全部匹配才选中;无匹配时明确回退默认片。没有接真实天气,没有批量换装或跨人物验证。
进入时等阅读第 96 帧,播真实正向进场,再交谈;返回用独立正向生成回程,续播原日常切点。v2 新底板来自已确认的 `04-交谈近景-v2.png`,有微动作和目视镜头候选;表情/身体/目光来自底板,只有口型实时驱动。全部仍待总工人工观感审核,不能据“可播放”判断自然。
每轮回复拥有独立 peer。取消首先断开浏览器音频图、静音/暂停旧视频、关闭 peer、递增轮次;后端取消 LLM/TTS HTTP 任务,关闭上游响应,再精确销毁该 driver session(含其推理/输出队列)。旧 peer 的 RTP 和旧轮异步错误不进入新轮。这个选择增加建连成本,换来不复用难以完整清理的上游队列。共享 CosyVoice 服务不由本样机管理:客户端断开不等于已证明共享模型内部 GPU 生成即时停止。
LLM 和整句 TTS 生成与 ICE/转场并行。网关等浏览器 `/api/ready` 才把音频送入驱动器,防止转场期间提前说话。整句缓冲减少标点分句空隙,代价是长回复要等整句生成;未声称首包 SLA 或流式生成性能通过。前端的“等待首声”是真实等待。
麦克风请求 AEC/降噪,原生 Worklet 输出 16k PCM;150ms 连续能量启动会立即停止旧回复,650ms 静音发送 ASR 端点。FunASR `2pass-online` 仅展示,`2pass-offline` 收集终稿后提交。失败不伪造文字。长停顿、轻声、扬声器回声仍需真人校准;请用耳机。按钮和合成 WAV 结果均不冒充真人自然抢话通过。
## 素材版本和回滚
`assets/personas/wanwan-v2-cream-night/manifest.json`:稳定 `companion_id=wanwan`、外观 `persona_id`、服装/光线/天气标签、avatar id、帧级切点、v2 版本、v1 前版和逐文件 SHA256。换包不创建伴侣或写入身份状态。
先把候选完整包解压到新目录,用 `tools/pack_assets.py --assets <目录>` 校验,再整体替换 `assets/personas/<persona_id>`,在 `assets/active.json` 选择它,再构建、重启独立测试服务。禁止把不同版本的单文件混放。avatar 按 manifest 的 id 重新生成或复用对应缓存;网关从 manifest 指定 avatar,不使用别的脸兜底。构建先校验再把所选整包挂载到生成目录 `public/assets`,该目录可由构建重建,勿作为用户素材唯一副本。回滚对旧完整包执行同样步骤。复核包另附 `packs/hel714-v1`,仓库只保留它的 manifest 元数据;当前包为 hel714-v2。
## 检查
```bash
npm run build
python3 -m ruff check samples/companion/runtime samples/companion/tools # 仓库根目录
python3 -m pytest -q # 仓库根目录,原78项
/opt/hel715-verify/venv/bin/python tools/check_gateway.py # .12,5项取消/音色/模型故障检查
LD_LIBRARY_PATH=/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu python3 tools/check_browser.py
LD_LIBRARY_PATH=/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu python3 tools/check_races.py
LD_LIBRARY_PATH=/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu python3 tools/check_microphone.py
LD_LIBRARY_PATH=/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu python3 tools/soak.py
```
浏览器脚本依赖运行机已安装 Playwright/Chromium;麦克风脚本需复核包内的 `evidence/synthetic-microphone.wav`。它是 CosyVoice 合成的测试句,经 Chromium 假麦克风送入实际 Worklet→ASR→LLM→TTS→口型链路,绝不是人现场说话。录制用页面实际绘制画面和实际播放的 WebAudio 输出、同一个 MediaRecorder 时钟,不按帧数重建时间戳;静音时钟从开头持续输出零样本,保留进入前的日常画面。连续音画是浏览器播放图的合成采集,不是物理屏幕/扬声器校准。
实测结果和未通过项见 `REPORT.md`。生成工作流、history、原始候选片、浏览器证据和资源采样放复核附件,不在主仓库存大文件。
## HEL-718 驱动器补丁(候选,尚未通过同步门槛)
`tools/patch_driver.py` 追加类型透传:`AudioFrameData.type` 经
`BaseAvatar.process_frames → WebRTCOutput → HumanPlayer` 到未赋 PTS 的
`PendingAudio`。仅合成空闲 `type=1` 且无事件的块限至3块(60ms);
真实语音(包括零值 PCM)、自定义音频和事件保留,100块队列提供背压。
首次音频随真实首帧建立时间原点,此后轨道每20ms从队列取最新待播块,
饥饿时补320样本静音;所有已输出包保持320步长,不在 RTP 队列跳时间戳。
时钟改用 monotonic,`warm_up` 排空左右全部上下文,不移动 Whisper 特征窗。
取消仍销毁本轮 peer,补丁不改变前后端取消协议。
先用 `prepare_driver.py` 新建私有副本,再运行补丁;源为已选 c726943。
已验证从 P10 干净副本首次及重复应用,拒绝直接修改 P10 复用目录。
`driver_audio.py` 必须与 `patch_driver.py` 一起携带。回滚时停止自己的候选
服务,重建全新 c726943 副本,使用 `git show ae4fd5c:samples/companion/tools/patch_driver.py`
导出的旧版补丁恢复 HEL-714 endpoint,再指向原 avatar/权重;不要原地混用新旧文件。
新增有效检查(使用原有 P10 venv,无新增依赖):
```bash
/opt/hel715-verify/venv/bin/python tools/check_driver_audio.py /path/to/private/LiveTalking
# 按真实 RTP PTS 录制刚进入、空闲15秒、连续多轮、打断后再次送入相同音频
/opt/hel715-verify/venv/bin/python tools/record_driver.py \
--wav fixed.wav --out server-rounds.mp4 --rounds 0,15,1,1 --post 9
```
`trace_driver.py` 仅用于隔离测试,以 `PYTHONPATH=私有驱动器目录` 从该目录运行,
首参为 JSONL 路径,其余为原 `app.py` 参数。记录逐块生产/接收 RMS、类型、深度、
丢弃合成静音计数、PTS 和 monotonic 墙钟,不随产品默认开启。
`record_graph.html/graph_server.py/record_graph.py` 复现浏览器产品音频图;
`record_product.py` 记录三轮日常→进场→交谈→说话中取消→正向回程→续读。
`measure_sync.py` 固定 ROI 和阈值并保存检测器 SHA256、完整命令、offset、窗口、
idle-until、输入/底板哈希、检测误差及逐 lag 相关性。相关性很低时不能把最佳 lag 当作可靠对齐。
本轮仍未达到服务端绝对偏差≤0.15秒/浏览器≤0.25秒,详见 `REPORT-HEL718.md`。
本补丁解决的是可确认的空闲队列/时钟问题,不能据此宣称音画同步已修好。
## HEL-720 头部闭唇候选(阈值待真人确认,仍未达同步门槛)
`patch_driver.py` 携带 `driver_head.py`,安装到私有驱动的
`avatars/hel720_head.py`。在 `BaseASR.put_audio_frame` 的原始16k PCM入口逐块
标记头部闭唇;20ms块不修改、不改类型或事件,仍进入原特征及RTP路径。
首个RMS超过候选值的块结束本话语的门控,其后弱音保留口型。
`start`/`end`事件和`flush_talk`重置状态,新peer各有独立状态;合成空闲不重新
开启头部规则。标记随原`AudioFrameData`传递,旧帧的决定不会因新话语重置改变。
每个视频帧关联两块PCM;仅当对应两块均不需口型时走既有闭唇底板。
混合批次仍推理全部8帧,再逐帧选择底板/预测结果,不以首块决定整批画面。
自定义音频的类型与底板选择、真实语音的`is_speaking`及事件语义保留。
Whisper的`[2,3]`特征窗、队列、时钟、转场及前后端取消协议不变。
候选进程启动环境变量 `HEL720_HEAD_RMS=0.001` 为默认试验值;`0`关闭规则。
负值或非有限值拒绝启动会话。不要把该值写入共享服务环境。
固定音频0.16秒处RMS约0.01333,规则已在这里结束,不能继续压制其后弱段。
阈值未经真人听看批准,本轮未继续调参。详见 `REPORT-HEL720.md`。
```bash
# 原有P10 venv;不装新依赖,PRIVATE_DRIVER必须为新建私有副本
python3 tools/prepare_driver.py --source /path/to/clean-c726943 --destination "$PRIVATE_DRIVER"
python3 tools/patch_driver.py "$PRIVATE_DRIVER"
python3 tools/patch_driver.py "$PRIVATE_DRIVER" # 幂等重复应用
/opt/hel715-verify/venv/bin/python tools/check_driver_head.py "$PRIVATE_DRIVER"
/opt/hel715-verify/venv/bin/python tools/check_driver_audio.py "$PRIVATE_DRIVER"
```
`trace_head.py`仅在隔离诊断时显式调用,参数与`trace_driver.py`一致;记录原始
PCM哈希/RMS/门控、两块对应的渲染帧及RTP输出哈希/PTS,不由产品默认加载。
`check_races.py [证据目录]`在失败时也保留页面状态、事件和截图,仍不把一次通过
视为已定位历史超时。
回滚:停止自己的候选进程,新建同一c726943私有副本,使用
`git show 415f837:samples/companion/tools/patch_driver.py`和同提交的
`driver_audio.py`重装HEL-718补丁,复用原avatar/权重。也可在候选启动前设
`HEL720_HEAD_RMS=0`关闭策略;该开关回滚保留代码但不闭唇。不要原地覆盖共享安装。
## HEL-722 启动准备(局部通过,整体同步仍未通过)
`driver_ready.py` 与补丁一起携带。MuseTalk 在监听 HTTP 前依次执行真实文件
解码/24k→16k 重采样、Whisper 特征提取/既有切窗、真实 avatar latent 的
`inference_batch`。替换原固定张量的 `warm_up` 调用,不重复加载模型。
探针仅引用已加载模型与 avatar,不构造 session/ASR/HeadGate/输出队列,不调用
`put_audio_file`、TTS 或网络。预测结果丢弃,不生成真实话语的 start/end。
日志明确报告 preparing/ready/failed、各阶段和总耗时;失败抛异常并退出,HTTP
不会开始监听。成功后 `GET /hel722/ready` 返回本次准备状态与时间。这个状态只
表示启动路径已准备,不表示音画同步验收通过。首次工作线程仍有约0.18秒模型
调用,暖轮约0.08秒;已有批处理/排队/补静音的残差未在本轮修改。
```bash
python3 tools/prepare_driver.py --source /path/to/clean-c726943 --destination "$PRIVATE_DRIVER"
python3 tools/patch_driver.py "$PRIVATE_DRIVER"
/opt/hel715-verify/venv/bin/python tools/check_driver_ready.py "$PRIVATE_DRIVER"
/opt/hel715-verify/venv/bin/python tools/check_driver_head.py "$PRIVATE_DRIVER"
/opt/hel715-verify/venv/bin/python tools/check_driver_audio.py "$PRIVATE_DRIVER"
```
`trace_head.py` 诊断额外把原 PCM key/type/event 放在编码前 AVFrame 的 opaque
元数据中,区分真实全零 PCM 与合成静音;不改 PCM、事件、类型、队列策略或 RTP。
仅显式诊断时加载,元数据不会进入网络编码。阶段计时包装和本轮完整命令随证据包。
回滚:停止自己的候选进程,在新建的 c726943 私有副本应用
`77b7608` 的 `patch_driver.py`、`driver_audio.py`、`driver_head.py`,复用同 avatar
及权重,即恢复 HEL-720。不要原地去掉 readiness 而留其他混合版本。
详见 `REPORT-HEL722.md`,不部署、不发布、不合并。
-64
View File
@@ -1,64 +0,0 @@
HEL-718 返工记录,2026-10-21。候选沿草稿 PR #2 / `hel-714-daily-talk`,base `main@1e9b08a`,施工基线 `ae4fd5c`。结论:**blocked,同步未修好,不发布、不合并、不转正式 PR。**
## 实现与实测结论
- `tools/patch_driver.py`、`tools/driver_audio.py`:在私有 c726943 驱动器透传 `AudioFrameData.type`,未赋 PTS 的合成空闲块最多3块;真实语音(包括零 PCM)、自定义音频和事件不丢,100块总队列背压。音频饥饿补零保持320样本/20ms时钟;时钟使用 monotonic;warm_up 排空左右全部上下文。
- 追加4项有效回归:2000次空闲/语音混入顺序与事件保留、真实音频满队列背压、warm_up 后首块关联、饥饿期间 PTS/节奏与首块 PCM 字节哈希。P10 干净副本首次及重复应用通过,旧取消 endpoint 保留。
- 不改前端播放延迟、不移动 Whisper 特征窗、不改转场、素材、接口或结束检测。其他输出 transport 保持原来的 `push_audio_frame`,新增类型入口仅作用于 WebRTC。
- 自测与分析工具纳入 `tools/`;复现和回滚步骤见 README 的 HEL-718 节。
静音积压与时钟问题已得到有效限制,**残余起点仍超门槛**。独立单会话约66秒:最大音频队列27块(含语音),删除合成静音1206块;四个完整轮次各338块真实音频均按顺序匹配到输出 RMS,首块排队19/37/9/75ms。RMS顺序匹配不是全 PCM 哈希证明,单元检查另核对首块原始字节;没有靠裁掉弱音/首音节改善数值。
## 固定口径与原始指标
固定音频“八百标兵奔北坡,炮兵并排北边跑。宝宝抱抱爸爸,妈妈慢慢说。”,xiaoyun,24kHz,6.76秒,SHA256 `8c0c1ec943882d4aafbb4f8cda488f138a19231dabe2a58335777c0c42fff3dc`。本轮直接复制审核留存文件,未重新合成。强度门槛0.015下,第一个20ms RMS达标块在源音频0.56秒,首次样本幅度达标0.169秒;审核所述11ms并非同一强度检测口径,不应直接与当前起点混用。真实语音开头的低强度部分仍全部保留。
所有主比较使用 `measure_sync.py` 的 `hel718-v2-mask-fix`,修正旧 source mask 的 x1 索引错误,并用新版对修前录像重新计算。ROI固定为512尺度的 `(271,341,180,220)`;服务端不裁切,浏览器/产品canvas裁切 `offset=(160,0), render-size=640` 后缩放512。服务端运动/残差门槛固定 `3.1718642664 / 7.693739183`;浏览器与产品固定 `4.294028549194336 / 10.492400400638578`。音频RMS门槛0.015。每个窗口 JSON 持久化完整命令、输入/底板哈希、检测器脚本哈希、ROI/offset/idle-until/start/stop、门槛、逐lag相关性;每个窗口各自保留CSV,避免被下一轮覆盖。
数值为口部起点减声音起点,**负号表示嘴提前**。合格意图为绝对值≤0.15秒(服务端)、≤0.25秒(浏览器)。视频帧量化约40ms,音频包约22ms,起点阈值/弱音/口型残差另有未定标误差,数值不替代真人听看。
| 实验 | 运动起点差(s) | 残差起点差(s) | 稳态最佳有符号lag(s) / 相关系数 |
| --- | ---: | ---: | ---: |
| 修前真 PTS | -0.576 | -0.576 | -0.06 / 0.209 |
| 修前浏览器图 | -0.699 | -0.665 | +0.52 / 0.231 |
| 修后真 PTS 第1轮 | -0.433 | -0.433 | -0.58 / 0.191 |
| 修后真 PTS 第2轮 | -0.320 | -0.320 | -0.44 / 0.189 |
| 修后真 PTS 第3轮 | -0.273 | -0.273 | -0.40 / 0.192 |
| 修后真 PTS 第4轮 | -0.313 | -0.313 | +0.34 / 0.173 |
| 修后真 PTS 第5轮 | -0.343 | -0.343 | +0.80 / 0.897 |
| 修后真 PTS 第6轮 | -0.296 | -0.296 | -0.44 / 0.339 |
| 修后浏览器图 第1轮 | -0.442 | -0.442 | -0.56 / 0.183 |
| 修后浏览器图 第2轮 | -0.360 | -0.360 | +0.32 / 0.171 |
| 修后浏览器图 第3轮 | -0.448 | -0.448 | -0.52 / 0.184 |
| 修后浏览器图 第4轮 | -0.324 | -0.324 | -0.40 / 0.206 |
| 修前产品端到端(不同短句) | -0.382 | -0.382 | -0.46 / 0.391 |
| 修后产品端到端首轮 | -0.507 | -0.507 | -0.26 / 0.203 |
| 修后产品端到端被打断轮 | -0.241 | -0.218 | -0.34 / 0.593 |
服务端第1轮刚进入;第2轮前空闲15秒;第3/4轮连续播放后空闲1秒;第5轮在2秒处发 `/interrupt_talk`;第6轮同peer再次送固定音频(仅驱动诊断,不能替代产品取消隔离)。浏览器图覆盖前四个完整场景,产品回归另覆盖打断后新peer重入。修前固定音频只复用审核单轮原始录像,**未重建修前完整多轮矩阵**;此限制不能写成完整A/B验收已过。
产品端到端使用实际LLM/TTS,不以固定音频替换上游;首轮虽同文本,实际TTS为9.52秒,第二轮7.96秒,修前为1.96秒短句,**不能将这些行当成同音频因果对照**。受控同音频的因果比较以真PTS/浏览器图为准。
稳态lag使用运动能量与声音包络相关:定义 `lip_minus_audio = -shift`,修正旧xcorr脚本打印标签与相关计算方向相反的问题。四个完整受控轮次相关系数只有约0.17~0.21,最佳lag不稳定,**无法证明稳态音素同步或排除感知漂移**;第5轮只有截断短窗,其高相关与边界lag也不具验收意义。不能拿“某个最佳lag接近零”冒充通过。
音频PTS逐包20ms、视频40ms,无跳步;独立单会话末端墙钟减PTS分别约+1.07ms/+0.22ms;瞬时墙钟停顿最大约110/129ms,累计偏移峰值约90ms后回落。没有观察到持续时钟漂移,未机械重跑620秒。并行诊断曾有约1秒停顿(关闭其他peer同步join时),相应原始trace保留;主表使用独立单会话重测,不能把并发记录的卡顿抹掉。
## 回归、完整录像及未测项
- 前端构建通过;ruff通过;原78项pytest通过;5项网关检查通过;新增4项队列/时钟检查通过。日志见复核包 `checks/` 与 `remote/evidence/*checks.log`。
- Chromium真实WebRTC回归通过:说话中取消、准备中取消、回日常、offer故障、缺素材、断网重连、快速取消再重入。快速重入首次与多轮采集并行时等待首声45秒超时,独立复跑通过,无pageerror或迟到旧轮错误;首轮未自动保存页面状态,不能追认已定位该偶发问题。
- `product-continuous.webm` 约66.7秒:日常片7.84秒,录制后等待24.52秒(至少三轮)→f96切点→正向进场→两轮实际LLM/TTS→说话中按钮取消→独立正向回程→阅读续播。无pageerror;末态“已回到阅读原位置”。两轮首声8.49/7.91秒(本次样本,不是SLA)。取消后200~1000ms窗口最大RMS约0.0000041,低于0.015,无旧声复播证据。
- 真人走查仍逐项待确认:日常循环接缝、两个切点、近景暖色、书本/手部/坐垫形变、微动作自然性、物理扬声器听感。没有用像素统计代替真人通过。
- 真人自然抢话、手机真机/iOS/Safari、真实系统后台/锁屏、共享TTS内部GPU即停、多用户并发仍未测;桌面390px与synthetic visibility仅是处理器回归。
## 阻塞与最小后续路径
目前证据只能确认空闲积压受限、真实音频未被队列裁切、PTS连续;不能确认“积压修复足以消除同步故障”。残余服务端约−0.27~−0.43秒、浏览器约−0.32~−0.45秒,产品首轮约−0.51秒,均不足以放行。
建议下一步在同一固定音频的弱起音/闭唇音附近,保存每个推理批次的输入采样区间、Whisper特征区间和实际paste-back视频帧对应关系,以原始PCM对齐/人工音素标注核实残余来自特征关联还是起点检测;再由总工审定最小修法。不要盲加固定视频延迟、删除真实静音段或移动特征窗来压低指标。
## 隔离与交接
复用 c726943、MuseTalk v1.5 及原P10 venv/权重/avatar,无新依赖。工作仅在 `/opt/hel718-fix` 私有目录;18080/18081/9880/10095、系统Python/CUDA/驱动、证书信任、防火墙与开机配置未改。精确PID 1018154(驱动)、1018618(网关)、1098378(浏览器图代理)已核对cmd/cwd并在比较Multica daemon PID 44286后停止;8010/8014/8933已释放;共享服务PID 538167/608898/445461/220889保持。详见 `cleanup.json`。没有部署长期站点。
草稿 PR #2 沿原分支更新head,不另建PR。候选交付不等于样机通过;本卡保持blocked,由总管组织后续归因与真人检查。
-84
View File
@@ -1,84 +0,0 @@
HEL-720 最小返工记录,2026-10-21。施工基线 `hel-714-daily-talk@415f837`,原草稿 PR #2,base `main@1e9b08a`。**结论:blocked;已完成一轮限定修复和验证,指标仍未全过,不发布、不合并。0.001候选阈值待真人听看确认。**
## 实现、粒度与边界
- `tools/patch_driver.py`、`driver_head.py`:在ASR入口对原始16k/320样本/20ms块计算RMS并携带独立渲染标记。`type=0`、PCM内容、事件原样保留;未用合成静音类型绕过HEL-718队列保护。
- 每个视频帧对应`audio_frames[i*2:i*2+2]`,每个批次8帧/16块。混合批次照常推理,仅把应闭唇的单帧预测替换为既有底板,不整批闭嘴。全闭唇批次复用既有跳过推理分支。画面选择仍沿原三元组队列,不按墙钟门控。
- 状态在输入端顺序计算,`start`重启头部、首个超过门槛的块锁定开口、`end`/`flush_talk`重置;新peer有独立实例。合成空闲不重启话语,避免流式饥饿后吞掉弱音。标记写在帧对象上,重置不追改旧帧。产品取消仍销毁peer,本卡未另造轮次隔离。
- 保留自定义音频类型和静态视频选择;画面闭唇期间真实音频的speaking/通知语义保持。可配置环境变量`HEL720_HEAD_RMS`,默认0.001,0关闭,非有限值/负值拒绝。
- `check_driver_head.py`检查边界、禁用、混合先弱后强、话语中弱音、事件、取消/新peer及实际推理→输出循环的PCM字节/类型/事件保留。`trace_head.py`是显式调用的诊断工具。`check_races.py`新增无论成功失败都保存页面状态/事件/截图。
- 未改窗口、队列、时钟、视频裁剪、固定延迟、transition、检测器或UI/素材。没有新依赖。
Whisper源码粒度核对:`run_step`每次追加16块,保留`l+r=20`块上下文;`_feature2chunks`的`start=l/2=5`、`feature_idx_multiplier=2`、`audio_feat_win=[2,3]`,特征索引窗口为`[2*i+6, 2*i+16)`。本轮仅在同一批次的每帧对应PCM对上选择画面,未改变这些参数。**没有逐特征张量转储,特征窗口说明来自固定驱动源码;实录trace直接覆盖PCM→渲染对→RTP,不能据此声称完整音素关联已验证。**
## 同固定音频的逐轮结果
沿用原fixed.wav,24kHz、6.76秒,SHA256 `8c0c1ec943882d4aafbb4f8cda488f138a19231dabe2a58335777c0c42fff3dc`。没有重新合成或裁掉头部。与HEL-718同`hel718-v2-mask-fix`检测器,脚本SHA256 `081e40486793d9c57dbd9d682aacbefe58670eb07a9b22675953cd6b883f0ec6`,ROI `(271,341,180,220)`;服务端不裁切,浏览器/产品`offset=(160,0), render-size=640`后缩放512。服务端motion/residual门槛 `3.1718642664/7.693739183`,浏览器 `4.294028549194336/10.492400400638578`,音频RMS门槛0.015,均未放宽。
所有差值为“口部起点-相应声音起点”,秒,负号为嘴提前。主验收是响亮RMS起点绝对差服务端≤0.15、浏览器≤0.25;另列弱瞬态的采样幅度起点和原始PCM首块口径,不能互换。样本幅度检测是独立辅助报告,非主检测器变更。原音频首次样本幅度绝对值>0.015在0.169208秒,首次20ms RMS>0.015在0.56秒;原PCM首块包含此前全部弱头。
| 路径/轮 | HEL-718响亮差 | 本轮响亮差 | 本轮残差差 | 样本幅度起点差 | 原PCM首块差 |
| --- | ---: | ---: | ---: | ---: | ---: |
| server/1 | -0.433 | -0.640 | -0.640 | -0.288 | -0.080 |
| server/2 | -0.320 | -0.159 | -0.159 | +0.212 | +0.420 |
| server/3 | -0.273 | -0.199 | -0.199 | +0.252 | +0.440 |
| server/4 | -0.313 | -0.068 | -0.068 | +0.312 | +0.500 |
| server/5 | -0.343 | -0.183 | -0.183 | +0.172 | +0.580 |
| server/6 | -0.296 | -0.103 | -0.103 | +0.252 | +0.720 |
| graph/1 | -0.442 | -0.350 | -0.350 | +0.024 | 未标定 |
| graph/2 | -0.360 | -0.307 | -0.307 | +0.068 | 未标定 |
| graph/3 | -0.448 | -0.220 | -0.220 | +0.153 | 未标定 |
| graph/4 | -0.324 | -0.208 | -0.208 | +0.166 | 未标定 |
| product/1 | 未标定 | -0.314 | -0.314 | +0.019 | 未标定 |
| product/2 | 未标定 | -0.322 | -0.288 | -0.318 | 未标定 |
0 3
server 1刚进入、2空闲15秒后、3/4连续轮次、5在2秒处打断(非完整轮)、6同peer重新送音频诊断。graph覆盖前四场景。product为实际LLM/TTS的另两轮,9.56/9.16秒,不是固定音频对照,不与HEL-718产品数值作因果比较。
浏览器“原PCM首块”未单独标定;表中的样本幅度起点直接从其录音解码采样测得,另受有损编码和图时钟误差影响。服务端原PCM首块用实际RTP PTS锚定。视频量化约40ms、主音频检测约22ms,阈值/弱瞬态的感知误差未标定。弱瞬态口径中服务端出现+0.21~+0.31秒反向滞后,不能只报告嘴提前缩小。稳态包络相关在完整受控轮中约0.139~0.368,仍不足以证明音素同步或感知漂移已通过;被截断短窗不用于稳态验收。
本轮所有JSON/CSV保留各自完整命令、窗口、idle-until、阈值、源/录像/检测器哈希。分析窗口在各轮开始前保留空闲段;第6轮排除上一被打断轮的尾音。首轮若把idle-until放在原PCM首块之前仅40ms,会把更早的真实口部变化藏掉;最终保留口部1.12秒、响亮1.76秒的−0.640秒结果,没有用后移检测窗口美化首轮。
## 直接证据与最小剩余问题
固定音频输入的每一轮都仅前8块闭唇,第8号块(0.16秒)RMS `0.0133345156` 超过0.001,结束头部规则;后面的弱块继续有口型。`head-policy-summary.json`显示所有渲染帧选择与对应两块标记一致(0个冲突),实录有3个一弱一强的混合帧,均正确保留预测帧。
服务端四个完整轮及打断后第6轮各338块,浏览器四轮各338块,均逐块SHA256按顺序匹配到编码前RTP输出;5号打断轮已输出104块后主动取消,其余未输出是取消,不冒充338块完整播放。音频320样本/20ms、视频3600tick/40ms PTS连续,无跳步。`trace-summary.json`含各peer原始计数;原始`trace.jsonl`记录门控、原PCM float/int16哈希、渲染对、RTP音频哈希/PTS和视频PTS。
首轮还有独立的时序疑点:`head-frame-map.json`中第0/1块的闭唇画面在视频0.96秒,RTP第0块音频在1.20秒;第8/9块的首次预测画面在1.12秒,对应音频第8块在1.36秒。该轮画面与对应音频PTS相差−0.24秒,不能把剩余偏差全部归因弱音;其余轮并非同一偏移。该图的video_pts按render输出顺序与无丢帧的video recv顺序配对,trace可复核。需要总工复核首轮供给停顿/两路PTS推进与输出关联;本卡未扩展到视频修剪或新时钟方案。
因此0.001方案只覆盖最前0.16秒,**未满足总体目标**。下一步先由总管安排真人听原音频0.00~0.60秒,确认0.169秒瞬态应否触发口型,并由总工审视上述首轮PTS证据。不要将“近无声均不可闻”“阈值应调到更高”写为已证事实,本轮不循环盲调。
## 构建、自测、录像和未验项
- 前端`npm run build`通过;完整样机runtime/tools的ruff通过;新增4项门控检查、受影响的原4项队列/时钟检查通过。原78项核心测试/5项网关检查与本次未改动模块无关,沿用HEL-718已通过记录,未机械重跑。
- 私有clean-driver首次/重复应用通过,最终再次应用所有Python文件哈希完全相同(`reapply.json`)。保留c726943/MuseTalk v1.5、原P10 venv、avatar和素材。
- 本轮Chromium实际WebRTC检查:说话中取消、准备中取消、回阅读、offer故障、断网/恢复通过;快速重入独立首测通过,页面无pageerror。已保存本次完整页面状态/事件/截图。HEL-718快速重入并行首测超时仍未定位,本次通过不能关闭历史疑点。网关故障注入期间有SSL断连回调日志,浏览器恢复通过;未据此扩展修改网关。
- 新`product-continuous.webm`约68秒,日常三轮→f96切点→正向进场→两轮交谈→说话中取消→独立正向回程→续读。取消后200~1000ms最大录音RMS `0.00000605`,无旧声复播证据。页面最终“已回到阅读原位置”,无pageerror。录制是浏览器实际画面/音频图的共同MediaRecorder,非物理扬声器校准。
- 供真人:fixed.wav的0.00~0.60秒(重点0.16~0.18瞬态、0.54~0.58响亮起点);新完整录像约26.3~30.5秒进场、38.2~39.6秒首轮弱头/开口/响亮起点、55.0~56.3秒第二轮开头、57.51秒取消、58.76~61.89秒正向回程、61.89~67.97秒续读。事件时间已减record-start,实际容器有一帧左右误差,以音画原件为准。
- 真人循环接缝/切点/暖色/书本/手部/坐垫/微动作、真人自然抢话、手机真机、真实后台/锁屏、共享TTS内部GPU即停逐项待验。390px桌面视口和synthetic visibility不等同真机/系统测试。0.001阈值、弱瞬态听感和稳态音素同步待验。任何脚本通过都不构成发布许可。
## 可复现命令与回滚
代码运行见README的HEL-720节。附件包含本轮实际`remote_supervisor.py`、`analyze_rounds.py`、`analyze_graph.py`、`weak_onsets.py`和`summarize.py`,各分析JSON含完整展开命令,`services.json`含实际启动参数/cwd/日志。诊断使用独立`/opt/hel720-fix`,复用材料不原地改写。
```bash
# 先确认8010/8014/8933空闲;PRIVATE_DRIVER为全新c726943副本
python3 tools/prepare_driver.py --source /path/to/clean-c726943 --destination "$PRIVATE_DRIVER"
python3 tools/patch_driver.py "$PRIVATE_DRIVER"
/opt/hel715-verify/venv/bin/python tools/check_driver_head.py "$PRIVATE_DRIVER"
/opt/hel715-verify/venv/bin/python tools/check_driver_audio.py "$PRIVATE_DRIVER"
# 在私有驱动cwd启动trace_head.py,后跟原app.py参数;原始命令见services.json
# 以下驱动录制是单会话顺序执行;浏览器各脚本也逐项运行,不并发制造peer关闭卡顿
/opt/hel715-verify/venv/bin/python tools/record_driver.py --wav fixed.wav --out server.mp4 --rounds 0,15,1,1 --post 9
LD_LIBRARY_PATH=/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu python3 tools/record_graph.py evidence
LD_LIBRARY_PATH=/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu python3 tools/record_product.py evidence
LD_LIBRARY_PATH=/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu python3 tools/check_races.py evidence/race
LD_LIBRARY_PATH=/home/xiaomo/pwlibs/root/usr/lib/x86_64-linux-gnu python3 tools/check_browser.py
python3 -m ruff check samples/companion/runtime samples/companion/tools
# 样机目录:npm ci --ignore-scripts --no-audit --no-fund;npm run build
```
回滚可在候选启动前`HEL720_HEAD_RMS=0`关闭策略;完全回滚则在新私有c726943副本应用415f837版本的`patch_driver.py`和`driver_audio.py`。不覆盖共享安装,不修改现网配置。
本轮对照Multica daemon PID 44286后停止自己的1425456/1425457/1425458,实际cmd/cwd/退出码见`cleanup.json`。8010/8014/8933已释放,18080/18081/9880/10095共享PID保持。没有运行中诊断遗留。提交/推送沿原分支和草稿PR,最终提交号见本卡结果评论。
-80
View File
@@ -1,80 +0,0 @@
HEL-722 一轮最小施工与验证,2026-10-21。基线 `hel-714-daily-talk@77b76083d05536893744d50ccf2a01c4a3c85a27`,原草稿 PR #2,base `main@1e9b08a`。
**结论:启动局部目标通过,交 in_review 复审;整体音画同步仍未通过,继续禁止部署、发布、合并。** 未调整门槛、PCM、特征窗、空闲视频队列或任何固定延迟。
## 已有预热缺口与最小修复
独立重现旧候选后,修正 HEL-721 的一项因果归因:约0.55秒并非全部是 MuseTalk 推理。首次 `/humanaudio` 在 aiohttp 主事件循环同步执行文件解码/重采样;`resampy.resample` 首次调用实测0.557秒,期间 WebRTC recv 停顿。首批真实模型推理另为0.201秒。旧启动 `warm_up` 只用固定 ones 特征和 latent 跑 pe/UNet/VAE,未跑上传重采样及 Whisper;会话的 `BaseASR.warm_up` 只填左右上下文,完全不提特征、不推理。固定张量与真实张量尺寸实际上均为 `(8,50,384)` / `(8,8,32,32)`,不是尺寸失配,不再沿用猜测。
旧候选本次实测:视频 recv 空洞0.574秒、render间隔0.632秒、视频时钟落后0.257秒,随后3个连续小于5ms的追赶间隔;视频深度39、音频深度93。首口部帧相对原第8块音频墙钟 vlag=-0.142秒,文件口径=-0.260秒。独立录端归零确实夸大偏差,但不能解释真实停顿。
`tools/patch_driver.py` 仅在 MuseTalk 的 HTTP 监听之前,改调用新 `driver_ready.prepare`:
1. 用内部短 WAV 走原私有解码函数,覆盖相同24k→16k首次重采样路径。
2. 使用同一已加载 Whisper、相同上下文长度及 `_feature2chunks([2,3],l/2,2)`。
3. 用真实 avatar latent 调用原 `MuseReal.inference_batch`,丢弃预测。
没有重复加载模型。没有会话/ASR构造器、HeadGate、音频输入/输出队列、TTS、网络或 start/end,内部样本不进入真实话语。`preparing/ready/failed`、阶段和总耗时写日志;异常明确抛出,未监听 HTTP。成功后 `/hel722/ready` 返回状态。两次冷进程准备耗时0.968/0.973秒(重采样0.461/0.455、特征0.198/0.201、推理0.309/0.316);第二进程从创建到 ready 标志约13.0秒,包含导入、模型和avatar加载,监听观测另有1秒轮询量化,没有把准备耗时冒充完整启动耗时。
## 测量口径与关联可信度
固定音频原件24kHz/6.76秒、SHA256 `8c0c1ec943882d4aafbb4f8cda488f138a19231dabe2a58335777c0c42fff3dc`,未重合成、未裁头。检测脚本 SHA256 `081e40486793d9c57dbd9d682aacbefe58670eb07a9b22675953cd6b883f0ec6`,ROI `(271,341,180,220)`、服务端 motion/residual `3.1718642664/7.693739183`、浏览器 `4.294028549194336/10.492400400638578`,浏览器裁切 offset160、size640→512,全保留。
原PCM key→render两块配对→视频recv序号,使用同一主机 `time.monotonic/perf_counter`,原PCM SHA顺序匹配到编码前音频recv;接收端独立记录实际 recv 的同一单调时钟与两轨PTS。文件指标另列,不混作墙钟。服务端视频步进3600、音频320,序号配对无中途负延迟;未入RTP的尾部render分别17/18帧,均为关闭时未拉取的队尾;这些帧可在关闭前已入队,不能写成“全在teardown后才render”,详见原trace。冷2进一步在AVFrame opaque携带原key/type/event,338块/338唯一key全部哈希、类型、事件一致,含最后5块原始全零PCM,预热期间输入PCM数=0。
冷1与旧基线用顺序哈希;原块0/8/28均非零、可直接追踪。尾部333~337号全零块与合成静音字节相同,旧格式不能独立证明这5块身份,因此没有用零/低相关性的 placement 结果证明定位。新opaque证据补足冷2该风险;冷1的关键起点不依赖全零尾部。录端视频关联是顺序及连续性核验,不是逐帧像素指纹。没有宣称完整音素对应已验证。
## 六轮服务端原门槛
秒;差值=口部-响亮声音,负=嘴提前,绝对值≤0.15达标。S4为2秒打断的局部轮,不能当完整稳态样本。弱瞬态栏是原第8块内幅度>0.015的采样起点,按16k重采样后0.16925秒定位;原24k为0.169208秒,量化/录制误差约40ms视频、22ms主RMS。主RMS仍为0.015,不替换验收口径。
|轮/场景|文件口部-响亮|发送墙钟口部-响亮|弱瞬态差|原PCM首块差|墙钟/文件达标|
|---|---:|---:|---:|---:|---|
|S1 冷进程A首轮|-0.251|-0.240|+0.151|+0.319|否/否|
|S2 同peer空闲15秒|-0.148|-0.140|+0.270|+0.440|是/是|
|S3 暖连续轮|-0.091|-0.079|+0.312|+0.482|是/是|
|S4 说话中取消(104块)|-0.160|-0.159|+0.232|+0.560|否/否|
|S5 同peer取消后再送(诊断)|-0.091|-0.079|+0.311|+0.481|是/是|
|S6 冷进程B首轮|-0.193|-0.178|+0.272|+0.441|否/否|
发送墙钟、文件均3/6;两次冷首轮仍未达整体同步门槛。接收端实际收帧墙钟口部-响亮依次为 -0.317/-0.198/-0.148/-0.212/-0.149/-0.259,记录的是 aiortc 解码接收时刻,不是浏览器扬声器时间,也不与发送端验收行偷换。
|轮|首口部帧vlag(相对所配原块)|首次推理|上传重采样|视频recv最大间隔*|render最大间隔*|音频补位块/内容跨度拉伸|
|---|---:|---:|---:|---:|---:|---|
|旧基线冷首轮|-0.142|0.201|0.557|0.574|0.632|0 / 0.00|
|S1|+0.160|0.178|0.083|0.134|0.342|0 / 0.00|
|S2|+0.280|0.080|0.085|0.128|0.426|2 / 0.04|
|S3|+0.322|0.082|0.086|0.116|0.339|6 / 0.12|
|S4|+0.241|0.084|0.092|0.148|0.385|8 / 0.16|
|S5|+0.320|0.079|0.084|0.133|0.342|8 / 0.16|
|S6|+0.281|0.178|0.090|0.111|0.343|3 / 0.06|
*窗口为上传前0.2秒至后2秒,包含跨窗口起点的间隔,避免漏掉上传时的停顿。原批处理按8帧突发生产仍存在;没有宣称逐帧生产间距40ms。两次冷首轮时钟最大落后0.093/0.071秒,连续<5ms追赶间隔最多1个,已与暖轮0~1个同构,原0.24秒异常及连续追赶消失。冷首轮视频深度峰值16/14(暖轮约14~16),音频深度25/21,不是通过新增一秒排队取得结果。
冷工作线程首推仍约0.178秒,暖轮约0.08秒;预热没有声称覆盖所有线程局部初始化。原预渲染队列相位、每批边界和合成音频补位仍使冷暖/轮间差异浮动:例如S1第一口部render→recv为0.321秒,S6为0.506秒,S6弱到响亮之间另插入0.06秒音频补位。已有逐块墙钟表可以分解这些差异,不能将全部差异继续归咎弱头,也没有调阈值或补固定延迟抵消它们。
## 暖驱动新peer浏览器对照(单列)
同驱动A已暖,新建peer、相同固定音频四轮。共同MediaRecorder文件的口部-响亮为 -0.234/-0.211/-0.220/-0.221秒,4/4过浏览器0.25门槛。驱动侧首口部对应原PCM对vlag为 +0.320/+0.301/+0.242/+0.321;第2/4轮是第7/8块混合对,保留预测画面符合既有规则。它们不证明冷驱动已热,故不冒充独立冷启动。浏览器文件弱瞬态口径另为 +0.144/+0.133/+0.114/+0.215秒(有损解码采样幅度>0.015,不代替主RMS门槛);原PCM首块在浏览器播放端未独立定位,不能用低相关性推算。浏览器音频通路额外延迟仍未独立标定,不批准固定补偿;文件采集也不冒充物理扬声器/屏幕或真人听感。
## HEL-721 统计纠错与残余风险
按原列值独立取绝对值,服务端S4/S6达标(2/6),浏览器第3/4轮达标(2/4);实时修正列同样是S4/S6,不是“仅S4”或“浏览器全超线”。附件 `HEL721-corrected-gates.json`逐轮列出,原复审zip原封保留,不沿用其错误gate文字。原结论整体未通过仍成立。
全部固定音频完整轮仅前8块闭唇、首个超门槛块后弱音保持;本轮全trace渲染标记0冲突。S4已输入338块,取消前实际输出104块,其余234块取消;S5新话语从新start重置,产品路径仍用销毁peer隔离。首轮嘴不再早于配对块,但相对响亮/弱瞬态仍有正负残差;稳态包络只是运动能量启发式,音素与真人感知未验。本卡只交局部修复复审,不盲调残差。
## 检查、隔离、重现与回滚
新增2项检查:禁止探针构造真实会话/写PCM/通知/触门控,准备成功只返回状态;注入解码/推理失败必须抛异常。另实跑进程级重采样故障,启动exit=1、明确failed日志,8010未监听。原4项HeadGate和4项队列/时钟检查全过。干净c726943副本应用及重复应用116个Python文件哈希一致,和私有运行候选一致。
`npm run build`、runtime/tools的ruff、diff检查通过。实际Chromium准备中/说话中取消、快速重入、断网/恢复/offer故障通过,pageerror为空,有页面状态、事件、截图和连续录像。历史快速重入并行首测超时仍未定位;本轮通过不关闭历史疑点。网关故障注入中仍有既有SSL断连回调异常,页面恢复成功,未扩范围修共享服务。核心人格/记忆模块未改,沿用既有核心检查,未机械重跑。
私有工作目录 `/opt/hel722-fix`,源代码、模型/权重/avatar只读复用材料,新建代码副本;不修改共享安装/配置/端口/数据。仓库CLI因未配置该项目资源拒绝checkout,改用明确仓库URL普通git clone,确认工作树干净、head/base与无并行运行;不新建PR。
实际完整命令在证据包 `diagnostic/supervisor.py`、services.json、逐轮analysis JSON及脚本中。baseline在打HEL-722补丁之前跑;cold1为 `record_driver.py --rounds 0,15,1 --post 9`;cold2为相同录制器移除打断段的 `record_once.py --rounds 0 --post 9`。先查8010/8014/8933空闲,再前台运行supervisor,发送record命令,收齐结果后stop,禁止留下运行中诊断。浏览器脚本按原README命令运行,需已有Playwright/Chromium库路径;全部文件随附件,无新依赖。`diagnostic/summarize.py`在证据包根运行产生逐轮JSON。`analyze.py`在私有远端执行检测,统一窗口和参数,固定检测器未改。
前台监督进程均已收回;对照Multica daemon PID44286后,只停止记录的自有精确PID;cleanup.json记录实际cmd/cwd/exit。8010/8014/8933释放,18080/18081/9880/10095等共享服务PID保持。无诊断留运行。
完整回滚:停止自己的候选,另建c726943私有副本,取 `77b7608:samples/companion/tools/{patch_driver.py,driver_audio.py,driver_head.py}` 应用,复用同avatar/权重;不要编辑共享安装。仓库本次提交可用 `git revert <本次提交>`撤回,提交号见本卡结果评论。只推原候选分支,不合并。
真人弱音听感、画面循环/切点/暖色/书本手部坐垫、自然抢话、手机真机、后台锁屏、共享TTS内部GPU即停仍逐项待验。本轮不访问4080、不新增依赖、不部署、不发布。
-73
View File
@@ -1,73 +0,0 @@
HEL-714 候选交付记录,2026-10-20~21(上海时间)。基线 main `1e9b08a`,引入已选 P10 实验提交 `6a5e155`,独立样机位于 `samples/companion`。
## 结论
代码、构建、基础自测和连续音画已交候选;**同步与自然观感、真人自然抢话、手机真机仍未通过体验验收,禁止据此发布或宣称首版完成**。后续由总管组织总工审查。当前需要继续处理的首要问题是口部起点提前的来源;没有用“思考中”掩盖,也未盲加固定延迟。
已实现:单组日常片按活动/手动天气/时段匹配及明确回退;已确认 v2 近景图生成目视微动作候选;真实正向进场/回程和第 96 帧可切点;文字与麦克风入口;FunASR→LLM→TTS→MuseTalk;停止、倾听、回日常、再进入;旧轮隔离;缺片、网络、模型与音色错误提示;素材版本/校验/回滚。
## 同步:实际播放证据、修正和未通过项
P10 附件 REPORT、MATERIAL-SPEC、e2e-session、chain-demo-browser、llm-chain 均经鉴权 CLI 下载,文档与远端实验分支一致。先读了原始适配器、音频特征/输出队列和录制脚本。
旧采样脚本按音频样本/视频帧数重建容器时间轴,首个视频 recorder 建立前的音频也会丢弃;因此不把原报告的“口型晚 0.5–1.3 秒”当作已标定真实浏览器偏差。本轮改为同一 Chromium 绘制的画面+同一 WebAudio 实际播放图输出,交给一个 MediaRecorder;不重新按帧数生成时间戳。
还修正了两项采集问题:Chromium 从 WebRTC video 元素建立 MediaElementSource 时分析输出全零,改用同一接收流的 MediaStreamSource,播放和录音共用输出;无 peer 时音频图缺少活动源会裁掉录像开头,增加恒为零的音频时钟后,最终录像视频从 0 秒开始、首个静音音频包 0.03 秒,完整保留日常和进场。无物理扬声器/屏幕的硬件时钟校准。
| 样本 | 口部变化起点−声音起点 | 含义 |
| --- | ---: | --- |
| P10 同驱动、原裁切底板,重新在浏览器采集 | −0.081s | 只比较起点,不能证明逐音节通过;没有复现报告的明显起步滞后 |
| 新目视底板,batch 16,第二轮完整静息段校准 | −0.635s | 口部先变化,体验验收仍有缺口 |
| 新目视底板,batch 8,单份对照 | −0.622s | 减半批量未稳定消除差异 |
| 最终包 batch 8,复测第二轮 | −0.424s | 仍有提前;不能据单个数值说同步已修好 |
这些数值来自源片相对口部残差与音频 RMS>0.015,使用录制文件本身的 PTS。新底板先按整段无语音静息片校准,再查口部残差越阈;坐标与窗口保存在脚本/CSV/JSON 中。头部微动、生成器改动嘴周和噪声会影响检测;它不是音素对齐或人工听看结论。各段不是相同台词、相同负载的受控基准,不能将上表差值解释为修复幅度。连续音画、口部帧条和原始数值一并提供。
下一条最小路径:固定同一段含清晰闭唇/开口音节的音频,在同一浏览器做原生 video 音频播放与 WebAudio 输出的 A/B,采集轨道/播放时钟并逐音节听看,区分浏览器播放图与 Whisper 前瞻/音频帧关联;确认稳定偏移来源后再改时间基准或特征对齐。当前没有证明消除了明显错位,列为体验验收阻塞。
## 素材和自然往返
复用已有 Wan2.2 生成工具,独立前台任务生成并停止自己的 ComfyUI PID 687113。无新模型、无新增依赖,未访问 4080。新 avatar 预处理只用原 MuseTalk 权重,新缓存独立保存在样机副本。三个任务 history 均 success,实际生成耗时约 65.5s、37.6s、34.4s。
| 素材 | 来源和长度 | 审核状态 |
| --- | --- | --- |
| 日常 | P10 loop,196帧/25fps | 单份雨夜阅读;其他天气/活动缺片,明确回退 |
| 倾听/交谈底板 | 确认过的04近景v2中裁,81个新生成帧→127帧/25fps,5.08s | 目视、眨眼、呼吸候选;身体/目光来自片段 |
| 进场 | 日常f96→近景v2,49帧→77帧/25fps,3.08s | 真正正向生成,非淡化/倒放;仍待人工检查形变和镜头连续性 |
| 回程 | 近景v2→日常f96,独立49帧→77帧/25fps,3.08s | 与进场分开生成,取消声音不等回程 |
页面等到日常 f96 再进场;回来续该位置。连续录屏覆盖完整往返、说话中取消、再次说话和准备期取消。联系表已核对同人物/发型/服装/房间的方向一致性,不代表动态自然性通过。镜头变焦、书本/手部、坐垫形变、循环衔接必须由总工看连续片人工裁定。
canonical 素材放 `assets/personas/wanwan-v2-cream-night`,含 daily/talk/ref/voice/transitions 和 manifest。版本 hel714-v2,前版 hel714-v1;6 个文件逐一 SHA256。构建只在全部校验后挂载选中整包至生成目录 public/assets。v1/v2 均校验通过,篡改 talk 文件会拒绝;复核包提供 v1 完整回滚包。固定 companion_id=wanwan 与 persona/服装/版本分离;代码不写人格、八字、记忆或关系。没有跨人物、跨服装或真实天气验证。
## 真实链路、取消与恢复
既有 ASR 协议经只读检查:FunASR 2pass WebSocket,16k PCM,online 展示、offline 终稿,端点发送 is_speaking=false;没有通过端口猜协议。既有 LLM halogen-qwen3.8-flash-next(.11:8731)、CosyVoice xiaoyun(.12:9880)保持原服务。
每轮独立 peer。前端立即断开播放音频图、暂停/静音并丢弃视频、递增轮次;后端取消请求、关闭上游响应并精确关闭 driver peer/session,不复用旧媒体队列。最终连续录像中,两次按钮取消100ms后的1.1秒窗口内,无RMS>0.015的残留声音帧(最大0.000145/0);这只是数字播放图的观测上界。旧 ICE/offer 迟到和超时异常按轮次拒绝;取消连接时也立即唤醒等待,避免旧超时误停新轮。LLM/TTS 生成与 ICE/进场并行,ready 门控防止画面到位前发声。TTS 整句缓冲减少分句空隙,长句首声较慢;共享 TTS 内部 GPU 生成是否随客户端断开即时停止未获证明,未改共享服务。
- 原78项 Python测试通过;TypeScript严格检查+Vite构建通过;新增Python代码ruff通过。
- 5项网关检查通过:取消LLM、取消TTS、等待画面ready时取消、错误音色不回退、模型不可达。
- Chromium真实WebRTC回归通过:文字回复、说话轮取消、准备期取消、再次说话、回阅读续播、暂停、无匹配场景回退、offer失败、断网/恢复重连、缺片提示。未停止共享模型模拟故障。
- ICE/offer建立中快速取消再发送的真实浏览器回归通过,旧超时没有停止新轮。
- 合成WAV经Chromium假麦克风→Worklet/VAD→真实ASR终稿→LLM/TTS/口型通过;旧回复已出声,检测到开口后停止,新终稿“你好,请听我说一个问题。”正常产生新回复。附有声连续录像。**输入不是人现场说话,不计真人自然抢话通过**;长停顿可拆轮、轻声阈值和扬声器回声仍待耳机/真麦克风校准。
- 1280×800 和390×844视口可操作,无横向溢出、无pageerror。390px只是桌面Chromium视口,手机真机/iOS/Safari未测。
- 页面隐藏处理器停止音视频/麦克风并要求主动恢复;测试用合成visibilitychange,真实系统切后台/锁屏未测。
## 设备、延迟和长时观察
推理 .12:RTX5090D 32GB,Linux7.0.0,复用 torch2.8.0+cu128、LiveTalking c726943、MuseTalk v1.5、aiortc1.15.0、PyAV17.1.0、aiohttp3.14.5。浏览器在 .13 用已安装 Playwright/Chromium,headless;没有真实耳机/麦克风或手机接入。
测量口径:同一轮用户提交完成(或ASR终稿提交)→播放图RMS首次>0.015,包含该轮建连、等待可切点、进场和模型处理。不是模型首包、不是物理耳朵校准,也不拿不同样本相加。LLM/queued字段只是同轮分段记录。视频“首次可见”只有媒体ready标记,逐音节首个可见口型仍需同步复核,不与声音口径混用。
最终连续浏览器音画的两轮:首次从日常进入7.43s,已在近景的下一轮5.74s;本轮记录期间有长测客户端并行,不是单用户SLA。此前batch16连续618.41秒、15次真实模型回复、15次首声全部返回、无pageerror/错误状态;P50 5.62s,范围4.93~7.57s,10秒采样总显存最大20248MiB,末尾18277MiB。前段曾并行功能/麦克风自测,不能说这是纯单会话显存峰值。
batch8最终代码与新素材目录的长测 619.89 秒,15轮提交均返回实际声音,P50 5.19s、范围4.49~7.79s;无pageerror或错误状态。10秒采样总显存最大16168MiB、末尾15110MiB。前段并行了功能、麦克风和取消竞态测试。与batch16样本非严格受控对照,不能把差值当作SLA改善保证。总显存采样包含既有TTS等进程,10秒采样可能漏过瞬时峰值;不声称已测得绝对峰值或无泄漏的长期保证。
## 隔离与交接
代码/模型不合并、不部署、不改开机启动。现有18080/18081、8090、9880、10095等共享服务保持。临时服务使用8010/8014,仅本轮自测;启动前确认空闲,按精确PID记录和停止,不使用pkill/fuser清理。权重和用户素材保留。
分支 `hel-714-daily-talk`,实际[草稿PR #2](http://192.168.200.36:3200/leefer/CyberGril/pulls/2),目标main。通过Gitea现有SSH的AGit创建,无需Token;PR和远端分支head均已核对。交付时Multica的PR链接表仍为空,CLI只有查询功能,没有盲改标题或调用平台私有API。最终提交号随卡片评论交接。
本轮最后的网关PID706044、driver PID737284已按精确PID/命令/cwd核对后停止,先核对其不等于Multica daemon PID44286。8010/8014均无监听;18080/18081仍为原PID538167/608898,9880仍为445461,10095仍为原docker代理。停止后总显存约5701MiB。权重、生成素材和新avatar保留,未部署长期站点。故障注入的HTTPS客户端断开曾产生Python/aiohttp sendfile清理traceback,页面随后恢复且不影响长测;原始网关日志保留,没有升级共享依赖掩盖它。当前同步/自然观感与真人、真机缺口均显式列出,不能据构建和基础测试通过自审上线。
-3
View File
@@ -1,3 +0,0 @@
{
"persona_id": "wanwan-v2-cream-night"
}
@@ -1,60 +0,0 @@
{
"companion_id": "wanwan",
"persona_id": "wanwan-v2-cream-night",
"version": "hel714-v2",
"previous_version": "hel714-v1",
"identity_ref": "ref/identity.png",
"created_by": "HEL-714 / P10 reused assets",
"created_at": "2026-10-20",
"pipeline_version": "hel714-wan-first-last-v1",
"outfit": "cream",
"light": "warm-bedroom-night",
"weather_source": "manual-test",
"default_daily": "reading-rain-night",
"daily_clips": [
{
"id": "reading-rain-night",
"file": "daily/reading-rain-night.mp4",
"timeofday": "night",
"weather": "rain",
"activity": "reading",
"loop_points": [
0,
195
],
"loop_review": "pending-human-review"
}
],
"talk_clips": [
{
"id": "gaze",
"file": "talk/gaze.mp4",
"avatar_id": "hel714-gaze-v1",
"framing": "approved v2 near reference, center square crop",
"approved": false,
"gaze_review": "new 81-frame subtle-motion candidate; pending human review",
"source_sha256": "d8028b938096ed02d03f727c3723cf423d8dd2ab794d84b532c778a2cde118d5",
"driver_revision": "c726943"
}
],
"transition": {
"enter": "transitions/enter.mp4",
"return": "transitions/return.mp4",
"cut_frame": 96,
"available": true,
"approved": false,
"cut_points": [
96
],
"reason": "forward-generated first/last conditioned motion, not reversed or crossfaded"
},
"manual_consistency_review": "candidate contact sheet checked; final human motion review pending",
"sha256": {
"ref/identity.png": "e40b72c44611cc6f0a9651ad24cc4b0985e2bb8efa19cba963d515a7a6cf4c8f",
"daily/reading-rain-night.mp4": "776801d38db4a4c3c0afa7488b380987fd9541c699dcfa6e8b26aa492d8255cf",
"talk/gaze.mp4": "d8028b938096ed02d03f727c3723cf423d8dd2ab794d84b532c778a2cde118d5",
"transitions/enter.mp4": "d014fc07f0f1c4ba6686f51380cc99f472119c70527a65b8f37c7992dded8828",
"transitions/return.mp4": "ca5cdfd8a3dd1267a5e95b587fb1d5fc921421993742707e9fe931bedeee9c7c",
"voice/voice.json": "dc235f2eccba209fdb63873b9f74ee99a899fe9e8883e497704d6ebd75b3ee2f"
}
}
@@ -1,4 +0,0 @@
{
"registered_voice": "xiaoyun",
"silent_fallback": false
}
-26
View File
@@ -1,26 +0,0 @@
<!doctype html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>晚晚 · 交谈样机</title></head>
<body><main>
<header><h1>晚晚</h1><small>本地交谈样机 · 人物 v2</small></header>
<section class="glass stage" aria-label="晚晚的房间">
<video id="daily" playsinline muted loop preload="auto"></video>
<video id="talk" playsinline autoplay hidden></video>
<video id="listening" playsinline muted loop hidden></video>
<video id="bridge" playsinline muted hidden></video>
<p id="scene-label">日常成片 · 阅读</p>
</section>
<section class="glass controls">
<p id="status" role="status" aria-live="polite">正在检查素材…</p>
<p id="warning" role="alert">近景仍是阅读片裁切;抬头、目视交谈与自然回程尚未通过观感验收。</p>
<div class="row"><button id="enter">进入交谈</button><button id="cancel">停下,听我说</button><button id="back">回到阅读</button><button id="pause">暂停日常</button></div>
<form id="form"><label for="text">对晚晚说</label><div class="row"><input id="text" maxlength="500" autocomplete="off" placeholder="说说今天的事"><button type="submit">发送</button></div></form>
<div class="row"><button id="mic" aria-pressed="false">开启麦克风</button><button id="retry">重连</button><button id="record">录制音画证据</button></div>
<p id="transcript" aria-live="polite"></p><p id="reply"></p>
<details><summary>场景测试</summary><div class="row">
<label>活动 <select id="activity"><option value="reading">阅读</option><option value="rest">休息</option></select></label>
<label>天气(手动测试) <select id="weather"><option value="rain">雨天</option><option value="clear">晴天</option></select></label>
<label>时段 <select id="timeofday"><option value="night">夜晚</option><option value="day">白天</option></select></label>
</div><p id="selection"></p><small>真实天气未接入。素材替换不修改身份、八字、记忆或关系;本页未接入这些模块。</small></details>
<a id="download" hidden>下载连续音画</a>
</section>
</main><script type="module" src="/src/main.ts"></script></body></html>
-1166
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
{
"name": "cybergril-companion-sample",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"typecheck": "tsc --noEmit",
"build": "python3 tools/stage_assets.py && tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"pixi.js": "^8.0.0"
},
"devDependencies": {
"typescript": "~5.9.3",
"vite": "^5.4.0"
}
}
-23
View File
@@ -1,23 +0,0 @@
/* Native AudioWorklet: 100ms PCM packets. AudioContext requests 16kHz. */
class PCM extends AudioWorkletProcessor {
constructor() { super(); this.buffer = []; }
process(inputs) {
const channel = inputs[0]?.[0];
if (!channel) return true;
this.buffer.push(...channel);
const count = Math.round(sampleRate / 10);
while (this.buffer.length >= count) {
const input = this.buffer.splice(0, count);
const output = new Int16Array(1600);
let sum = 0;
for (let i = 0; i < 1600; i++) {
const value = input[Math.min(count - 1, Math.floor(i * count / 1600))];
output[i] = Math.round(Math.max(-1, Math.min(1, value)) * 32767);
sum += value * value;
}
this.port.postMessage({pcm:output.buffer, rms:Math.sqrt(sum/1600), ms:100}, [output.buffer]);
}
return true;
}
}
registerProcessor('pcm', PCM);
-8
View File
@@ -1,8 +0,0 @@
{
"driver": "http://127.0.0.1:8010",
"llm": "http://192.168.200.11:8731/v1/chat/completions",
"model": "halogen-qwen3.8-flash-next",
"tts": "http://127.0.0.1:9880",
"voice": "xiaoyun",
"asr": "ws://127.0.0.1:10095"
}
-253
View File
@@ -1,253 +0,0 @@
"""Isolated sample gateway. Reuses existing aiohttp; never mutates companion state.
Each speaking turn owns a WebRTC peer. Cancellation retires that peer, cancels
upstream HTTP work, and closes the driver session. A late packet cannot be played
by the next peer. TTS is buffered as one utterance to avoid punctuation gaps.
"""
import argparse
import asyncio
import contextlib
import io
import json
import secrets
import ssl
import time
import wave
from pathlib import Path
import aiohttp
from aiohttp import web
ROOT = Path(__file__).resolve().parents[1]
class Gateway:
def __init__(self, config):
self.config = config
self.sessions = {}
self.http = None
async def start(self, app):
self.http = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=90))
async def stop(self, app):
for token in list(self.sessions):
await self.retire(token)
await self.http.close()
async def driver(self, path, data):
async with self.http.post(self.config["driver"] + path, json=data) as response:
response.raise_for_status()
result = await response.json()
if result.get("code", 0) != 0:
raise RuntimeError(result.get("msg", "driver failed"))
return result
async def retire(self, token):
session = self.sessions.pop(token, None)
if not session:
return
task = session.get("task")
if task and not task.done():
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
# Flush and destroy the peer, including inference/output queues. The
# isolated driver extension closes only this peer, never other services.
await self.driver("/hel714/close", {"sessionid": session["sid"]})
async def offer(self, request):
params = await request.json()
manifest = json.loads((ROOT / "dist/assets/manifest.json").read_text())
clips = manifest.get("talk_clips", [])
if not clips:
raise RuntimeError("当前外观包没有交谈底板,未改用其他人物")
answer = await self.driver(
"/offer", {"sdp": params["sdp"], "type": "offer", "avatar": clips[0]["avatar_id"]}
)
token = secrets.token_urlsafe(24)
self.sessions[token] = {"sid": answer["sessionid"], "task": None, "ready": asyncio.Event()}
return web.json_response({**answer, "token": token})
async def ready(self, request):
session = self.sessions.get((await request.json())["token"])
if not session:
raise web.HTTPConflict(text="会话已取消")
session["ready"].set()
return web.json_response({"ready": True})
async def cancel(self, request):
await self.retire((await request.json())["token"])
return web.json_response({"cancelled": True})
async def reply(self, session, text):
started = time.monotonic()
cfg = self.config
async with self.http.get(cfg["tts"] + "/health") as response:
response.raise_for_status()
health = await response.json()
if not health.get("ok") or cfg["voice"] not in health.get("voices", []):
raise RuntimeError("指定音色不可用;已停止,未回退其他音色")
async with self.http.post(
cfg["llm"],
json={
"model": cfg["model"],
"messages": [
{
"role": "system",
"content": "你是晚晚。当前画面是你在卧室阅读,书名未知。用一两句简短自然的中文回应。不编造书名,不声称有未接入的记忆、天气或感知。",
},
{"role": "user", "content": text},
],
"max_tokens": 100,
},
) as response:
response.raise_for_status()
body = await response.json()
reply = body["choices"][0]["message"]["content"]
llm_ms = round((time.monotonic() - started) * 1000)
pcm = bytearray()
async with self.http.post(
cfg["tts"] + "/tts",
json={
"voice": cfg["voice"],
"text": reply,
},
) as response:
response.raise_for_status()
async for chunk in response.content.iter_chunked(4800):
pcm.extend(chunk)
if len(pcm) > 24000 * 2 * 45:
raise RuntimeError("语音超过样机上限,已停止")
if not pcm:
raise RuntimeError("语音服务未返回音频")
# Generate while the camera transition/ICE handshake runs, but never
# enqueue speech until the browser confirms the media view is ready.
if session.get("ready"):
await asyncio.wait_for(session["ready"].wait(), timeout=20)
wav = io.BytesIO()
with wave.open(wav, "wb") as output:
output.setnchannels(1)
output.setsampwidth(2)
output.setframerate(24000)
output.writeframes(pcm)
form = aiohttp.FormData()
form.add_field("sessionid", session["sid"])
form.add_field("file", wav.getvalue(), filename="utterance.wav", content_type="audio/wav")
async with self.http.post(cfg["driver"] + "/humanaudio", data=form) as response:
response.raise_for_status()
result = await response.json()
if result.get("code") != 0:
raise RuntimeError(result.get("msg", "audio enqueue failed"))
return {
"text": reply,
"llm_ms": llm_ms,
"queued_ms": round((time.monotonic() - started) * 1000),
"audio_seconds": len(pcm) / 48000,
}
async def turn(self, request):
params = await request.json()
session = self.sessions.get(params["token"])
if not session:
raise web.HTTPConflict(text="会话已取消,请重新进入")
if session["task"]:
raise web.HTTPConflict(text="本轮已提交,请先取消")
text = str(params.get("text", "")).strip()
if not text or len(text) > 500:
raise web.HTTPBadRequest(text="请输入1至500字")
task = session["task"] = asyncio.create_task(self.reply(session, text))
try:
result = await task
except asyncio.CancelledError:
raise web.HTTPConflict(text="本轮已取消") from None
return web.json_response(result)
async def asr(self, request):
browser = web.WebSocketResponse(max_msg_size=1024 * 1024)
await browser.prepare(request)
try:
async with self.http.ws_connect(self.config["asr"]) as upstream:
await upstream.send_json(
{
"mode": "2pass",
"wav_name": "hel714",
"is_speaking": True,
"wav_format": "pcm",
"chunk_size": [5, 10, 5],
"audio_fs": 16000,
"itn": True,
}
)
async def receive():
async for message in upstream:
if message.type == aiohttp.WSMsgType.TEXT:
data = json.loads(message.data)
await browser.send_json(data)
await browser.close()
reader = asyncio.create_task(receive())
try:
async for message in browser:
if message.type == aiohttp.WSMsgType.BINARY:
await upstream.send_bytes(message.data)
elif message.type == aiohttp.WSMsgType.TEXT:
# Only endpoint signals are accepted from the client.
await upstream.send_json({"is_speaking": False})
finally:
reader.cancel()
with contextlib.suppress(asyncio.CancelledError):
await reader
except (aiohttp.ClientError, TimeoutError) as error:
if not browser.closed:
await browser.send_json({"error": f"ASR不可达: {type(error).__name__}"})
finally:
await browser.close()
return browser
@web.middleware
async def guard(request, handler):
origin = request.headers.get("Origin")
if origin and origin != f"{request.scheme}://{request.host}":
raise web.HTTPForbidden(text="仅接受同源请求")
try:
return await handler(request)
except (aiohttp.ClientError, TimeoutError, RuntimeError, KeyError) as error:
return web.json_response({"error": str(error)}, status=502)
def make_app(config):
gateway = Gateway(config)
app = web.Application(middlewares=[guard], client_max_size=1024 * 1024)
app.on_startup.append(gateway.start)
app.on_cleanup.append(gateway.stop)
app.router.add_post("/api/offer", gateway.offer)
app.router.add_post("/api/cancel", gateway.cancel)
app.router.add_post("/api/turn", gateway.turn)
app.router.add_post("/api/ready", gateway.ready)
app.router.add_get("/api/asr", gateway.asr)
app.router.add_get("/", lambda _: web.FileResponse(ROOT / "dist/index.html"))
app.router.add_static("/", ROOT / "dist", show_index=False)
return app
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8014)
parser.add_argument("--cert")
parser.add_argument("--key")
args = parser.parse_args()
tls = None
if args.cert:
tls = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
tls.load_cert_chain(args.cert, args.key)
web.run_app(
make_app(json.loads((ROOT / "runtime/config.json").read_text())),
host=args.host,
port=args.port,
ssl_context=tls,
)
-70
View File
@@ -1,70 +0,0 @@
/* Existing rain-room base controls, extracted without overrides. */
* {
box-sizing:border-box
}
[hidden] {
display:none!important
}
html,body {
margin:0;
min-height:100%;
background:var(--scene-night-deep);
color:var(--ink-primary);
font-family:var(--font-sans);
font-size:var(--fs-body);
letter-spacing:.02em
}
body {
height:100dvh;
overflow:hidden
}
button,input,textarea,select {
font:inherit;
color:inherit
}
button {
cursor:pointer;
border:1px solid transparent;
background:transparent;
border-radius:var(--r-control);
padding:var(--space-2) var(--space-3);
transition:transform 160ms var(--ease-out),opacity 160ms var(--ease-out)
}
button:active {
transform:scale(.94);
opacity:.7
}
button:disabled {
color:var(--ink-disabled);
cursor:default
}
button[aria-disabled=true] {
color:var(--ink-disabled)
}
button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible {
outline:2px solid var(--accent);
outline-offset:3px
}
p,h1,h2 {
margin:0
}
small {
font-size:var(--fs-caption);
color:var(--ink-secondary)
}
h1 {
font-size:var(--fs-title);
font-weight:500
}
h2 {
font-size:var(--fs-title);
font-weight:500
}
.glass {
background:var(--glass-bg);
border:1px solid var(--glass-border);
border-radius:var(--r-panel);
backdrop-filter:var(--glass-blur);
-webkit-backdrop-filter:var(--glass-blur);
box-shadow:inset 0 1px 0 var(--glass-highlight),var(--shadow-panel)
}
-323
View File
@@ -1,323 +0,0 @@
import './style.css';
type Clip = { id:string; file:string; activity:string; weather:string; timeofday:string };
type Manifest = { companion_id:string; version:string; default_daily:string; daily_clips:Clip[]; talk_clips:{file:string;avatar_id:string; approved:boolean}[]; transition?:{enter:string;return:string;cut_frame:number;available:boolean} };
const el = <T extends HTMLElement>(id:string) => document.getElementById(id) as T;
const daily=el<HTMLVideoElement>('daily'), talk=el<HTMLVideoElement>('talk');
const listening=el<HTMLVideoElement>('listening'), bridge=el<HTMLVideoElement>('bridge');
let view:'daily'|'talk'|'listening'|'bridge'='daily';
const status=el('status');
let manifest:Manifest, clipId='', inTalk=false, paused=false, generation=0;
let dailyMissing=false;
let pc:RTCPeerConnection|null=null, token='', abort:AbortController|null=null;
let serial:Promise<unknown>=Promise.resolve();
let mic:MediaStream|null=null, micContext:AudioContext|null=null, asr:WebSocket|null=null;
let utterance=false, loud=0, quiet=0, submitted=false, micEpoch=0, finalTimer=0;
let asrTimeout=0, endpointSent=false;
let partials:string[]=[];
let mediaSource:MediaStreamAudioSourceNode|null=null, audioContext:AudioContext|null=null;
let destination:MediaStreamAudioDestinationNode|null=null, analyser:AnalyserNode|null=null;
let recording:MediaRecorder|null=null, recordingRaf=0, downloadUrl='';
const events:{at:number; event:string; detail:unknown}[]=[];
function mark(event:string, detail:unknown={}) { events.push({at:performance.now(),event,detail}); }
Object.assign(window,{sampleEvents:events});
function message(text:string) { status.textContent=text; mark('status',text); }
function setView(next:typeof view) {
view=next;
for(const [name,video] of [['daily',daily],['talk',talk],['listening',listening],['bridge',bridge]] as const) {
video.hidden=name!==next;
if(name!==next)video.pause();
}
if(next==='listening')void listening.play().catch(()=>message('倾听底板不可用,请回到日常或重试。'));
}
async function playBridge(file:string, epoch:number) {
bridge.src=`/assets/${file}`;setView('bridge');await bridge.play();
const deadline=performance.now()+15000;
while(!bridge.ended&&epoch===generation) {
if(bridge.error||performance.now()>deadline)throw new Error('转场素材无法播放');
await new Promise(r=>setTimeout(r,40));
}
}
async function enterBridge(epoch:number) {
const transition=manifest.transition;
if(view!=='daily'||!transition?.available)return;
const cut=transition.cut_frame/25;
message('等待阅读可切点,再抬头交谈…');
if(paused){paused=false;el('pause').textContent='暂停日常';}
await daily.play();
const deadline=performance.now()+12000;
let previous=daily.currentTime;
while(epoch===generation) {
const now=daily.currentTime;
if(previous<=cut&&now>=cut)break;
if(performance.now()>deadline)throw new Error('日常视频未到达可切点');
previous=now;await new Promise(r=>setTimeout(r,16));
}
if(epoch!==generation)return;
daily.pause();daily.currentTime=cut;mark('enter-cut',{frame:transition.cut_frame});
await playBridge(transition.enter,epoch);
}
async function api(path:string, data:unknown, signal?:AbortSignal) {
const response=await fetch(`/api/${path}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data),signal});
if(!response.ok) throw new Error((await response.text()).slice(0,200));
return response.json();
}
function enqueue<T>(fn:()=>Promise<T>):Promise<T> {
const next=serial.then(fn,fn); serial=next.catch(()=>{}); return next;
}
function stopMic() {
micEpoch++; clearTimeout(finalTimer); clearTimeout(asrTimeout); asr?.close(); asr=null;
mic?.getTracks().forEach(t=>t.stop());mic=null;
void micContext?.close();micContext=null;
utterance=false;partials=[]; loud=quiet=0;
el('mic').setAttribute('aria-pressed','false');el('mic').textContent='开启麦克风';
}
// Immediate local silence is independent of network/transition progress.
function silence(reason:string, keepMic=false) {
generation++; abort?.abort();abort=null;
mediaSource?.disconnect();mediaSource=null;
talk.muted=true;talk.pause();talk.srcObject=null;
pc?.close();pc=null;setView(inTalk?'listening':'daily');
const oldToken=token;token='';
if(!keepMic)stopMic();
if(oldToken)void enqueue(()=>api('cancel',{token:oldToken})).catch(e=>message(`已本地停止;服务端取消失败:${e.message}`));
mark('cancel',{reason,generation});
}
function fail(error:unknown) {
inTalk=false;silence('error');
message(dailyMissing?'日常素材丢失或无法解码,交谈入口已禁用。请恢复素材包后刷新。':`已停止交谈,保留日常。${error instanceof Error?error.message:String(error)};可点击重连。`);
}
async function audioGraph() {
if(!audioContext) {
audioContext=new AudioContext();destination=audioContext.createMediaStreamDestination();
analyser=audioContext.createAnalyser();
// Keep the recording clock producing actual silence before the first peer
// arrives. Otherwise Chromium may omit the opening daily/transition frames
// while waiting for the first audio packet.
const clock=audioContext.createConstantSource();clock.offset.value=0;
clock.connect(destination);clock.connect(audioContext.destination);clock.start();
}
await audioContext.resume();
}
async function connect(epoch:number, onOffer?:(token:string)=>void) {
if(pc&&token) return;
const peer=new RTCPeerConnection({iceServers:[]});pc=peer;
const stream=new MediaStream();talk.srcObject=stream;talk.muted=true;
peer.ontrack=event=>{if(epoch===generation)stream.addTrack(event.track)};
peer.onconnectionstatechange=()=>{
if(peer!==pc)return;
if(['failed','disconnected'].includes(peer.connectionState))fail(new Error('媒体连接中断'));
};
peer.addTransceiver('audio',{direction:'recvonly'});peer.addTransceiver('video',{direction:'recvonly'});
await peer.setLocalDescription(await peer.createOffer());
await new Promise<void>((resolve,reject)=>{
const timeout=setTimeout(()=>reject(new Error('媒体协商超时')),8000);
const check=()=>{if(peer.iceGatheringState==='complete'){clearTimeout(timeout);resolve()}};
peer.addEventListener('icegatheringstatechange',check);check();
peer.addEventListener('connectionstatechange',()=>{
if(peer.connectionState==='closed'){clearTimeout(timeout);reject(new Error('本轮媒体已取消'));}
});
});
const answer=await api('offer',peer.localDescription);
if(epoch!==generation) { peer.close();await api('cancel',{token:answer.token});return; }
token=answer.token;
onOffer?.(token);
await peer.setRemoteDescription(answer);
await talk.play();
await new Promise<void>((resolve,reject)=>{
const timeout=setTimeout(()=>reject(new Error('媒体未到达,HTTP成功不代表媒体已通')),10000);
const check=()=>{if(talk.readyState>=2){clearTimeout(timeout);resolve()}};
talk.addEventListener('loadeddata',check,{once:true});check();
});
if(epoch!==generation)return;
await enterBridge(epoch);
if(epoch!==generation)return;
setView('talk');await talk.play();
// New peer contains only this turn's media, so unmuting cannot reveal an old queue.
mediaSource=audioContext!.createMediaStreamSource(stream);
mediaSource.connect(audioContext!.destination);mediaSource.connect(destination!);mediaSource.connect(analyser!);
talk.muted=true;el('scene-label').textContent='本地实时口型 · 目光与身体来自底板';
await api('ready',{token});
mark('media-ready');
}
async function enter() {
if(!manifest?.talk_clips.length)throw new Error('交谈素材缺失,入口已禁用');
inTalk=true;message('正在连接本地交谈…');await audioGraph();
const epoch=generation;
try { await enqueue(()=>connect(epoch)); }
catch(error) { if(epoch===generation)throw error;return; }
if(epoch===generation)message('正在听。可以输入文字,或开启麦克风。');
}
async function send(text:string) {
if(!text.trim())return;
const finishedAt=performance.now();
silence('new-turn',true);inTalk=true;
const epoch=generation;
message('正在连接本轮媒体…');await audioGraph();
abort=new AbortController();
const signal=abort.signal;
let pending:Promise<{result:any;error:unknown}>|undefined;
mark('input-finished',{text,finishedAt});
try {
await enqueue(()=>connect(epoch,currentToken=>{
pending=api('turn',{token:currentToken,text},signal).then(result=>({result,error:null}),error=>({result:null,error}));
}));
} catch(error) { if(epoch===generation)throw error;return; }
if(epoch!==generation)return;
message('正在准备回复与语音,等待首声…');
const analysis=analyser!, values=new Float32Array(analysis.fftSize);
let firstAudio=false, monitor=0;
function measure() {
if(epoch!==generation)return;
analysis.getFloatTimeDomainData(values);
const rms=Math.sqrt(values.reduce((s,v)=>s+v*v,0)/values.length);
if(!firstAudio&&rms>0.015){firstAudio=true;mark('first-audible',{latencyMs:performance.now()-finishedAt});message('正在回应…');}
monitor=requestAnimationFrame(measure);
} measure();
try {
if(!pending)throw new Error('本轮请求未创建');
const response=await pending;
if(response.error)throw response.error;
const result=response.result;
if(epoch!==generation)return;
el('reply').textContent=result.text;mark('audio-queued',result);
// Duration covers utterance playback plus driver lookahead; cancellation
// still independently retires the peer at any point during this wait.
const deadline=performance.now()+(result.audio_seconds+8)*1000;
while(epoch===generation&&performance.now()<deadline)await new Promise(r=>setTimeout(r,100));
if(epoch===generation){silence('completed',true);message('正在听。');}
} catch(error) { if(epoch===generation)throw error; }
finally {cancelAnimationFrame(monitor);}
}
async function selectClip() {
const activity=el<HTMLSelectElement>('activity').value, weather=el<HTMLSelectElement>('weather').value, timeofday=el<HTMLSelectElement>('timeofday').value;
const exact=manifest.daily_clips.find(c=>c.activity===activity&&c.weather===weather&&c.timeofday===timeofday);
const clip=exact??manifest.daily_clips.find(c=>c.id===manifest.default_daily);
if(!clip)throw new Error('默认日常素材缺失');
el('selection').textContent=exact?'已匹配活动/手动天气/时段。':`缺少所选场景,回退默认阅读片(${clip.weather}/${clip.timeofday})。`;
if(clip.id!==clipId){daily.src=`/assets/${clip.file}`;clipId=clip.id;}
if(!paused&&!inTalk&&!document.hidden)await daily.play();
}
async function startMic() {
if(mic){stopMic();return;}
if(!isSecureContext||!navigator.mediaDevices)throw new Error('麦克风需要HTTPS或localhost安全上下文;文字仍可用');
mic=await navigator.mediaDevices.getUserMedia({audio:{echoCancellation:true,noiseSuppression:true,autoGainControl:true},video:false});
micContext=new AudioContext({sampleRate:16000});
await micContext.audioWorklet.addModule('/pcm-worklet.js');
const source=micContext.createMediaStreamSource(mic), processor=new AudioWorkletNode(micContext,'pcm');
const gain=micContext.createGain();gain.gain.value=0;
source.connect(processor);processor.connect(gain);gain.connect(micContext.destination);
const epoch=++micEpoch;
const preRoll:ArrayBuffer[]=[];
const commit=(socket:WebSocket)=>{
if(!endpointSent||!partials.length)return;
clearTimeout(finalTimer);
finalTimer=window.setTimeout(()=>{
if(epoch!==micEpoch||socket!==asr||submitted)return;
clearTimeout(asrTimeout);submitted=true;const text=partials.join('');socket.close();asr=null;
utterance=false;loud=quiet=0;preRoll.length=0;
void send(text).catch(fail);
},600);
};
processor.port.onmessage=({data}:{data:{pcm:ArrayBuffer;rms:number;ms:number}})=>{
if(epoch!==micEpoch)return;
preRoll.push(data.pcm);if(preRoll.length>3)preRoll.shift();
if(data.rms>0.025){loud+=data.ms;quiet=0;}else{quiet+=data.ms;if(!utterance)loud=0;}
if(!utterance&&loud>=150){
silence('microphone-onset',true);inTalk=true;message('检测到开口,已停止旧回复,正在识别…');
utterance=true;submitted=false;partials=[];endpointSent=false;
asr=new WebSocket(`${location.protocol==='https:'?'wss':'ws'}://${location.host}/api/asr`);
const socket=asr;
socket.onopen=()=>{if(socket===asr)preRoll.forEach(packet=>socket.send(packet))};
socket.onmessage=event=>{
if(epoch!==micEpoch||socket!==asr||submitted)return;
const response=JSON.parse(event.data);
mark('asr-result',response);
if(response.error){fail(new Error(response.error));return;}
if(response.text)el('transcript').textContent=`识别:${response.text}`;
if(String(response.mode).endsWith('offline')&&response.text){
partials.push(response.text);commit(socket);
}
};
socket.onerror=()=>fail(new Error('ASR连接失败'));
socket.onclose=()=>{if(socket===asr&&!submitted){asr=null;utterance=false;message('识别连接结束,请重新开口或用文字。');}};
}
if(utterance&&!endpointSent&&asr?.readyState===WebSocket.OPEN)asr.send(data.pcm);
if(utterance&&!endpointSent&&quiet>=650&&asr?.readyState===WebSocket.OPEN){
endpointSent=true;asr.send(JSON.stringify({is_speaking:false}));commit(asr);
mark('microphone-endpoint');
asrTimeout=window.setTimeout(()=>fail(new Error('ASR未返回终稿,请重新开麦或输入文字')),6000);
// ASR must return an actual final; never fabricate one from silence.
}
};
await micContext.resume();el('mic').setAttribute('aria-pressed','true');el('mic').textContent='关闭麦克风';
message('麦克风已开启。开口会停止旧回复;建议佩戴耳机。');
}
// Record the rendered canvas and the same media element's audible output on a
// shared browser MediaRecorder clock, instead of rebuilding timestamps by count.
async function record() {
if(recording?.state==='recording'){recording.stop();return;}
await audioGraph();
const canvas=document.createElement('canvas');canvas.width=960;canvas.height=720;
const context=canvas.getContext('2d')!;
const theme=getComputedStyle(document.documentElement);
const background=theme.getPropertyValue('--scene-night-deep').trim();
const ink=theme.getPropertyValue('--ink-primary').trim();
const draw=()=>{
context.fillStyle=background;context.fillRect(0,0,960,720);
const video=view==='bridge'?bridge:view==='listening'?listening:view==='talk'?talk:daily;
if(video.readyState>=2){const scale=Math.min(960/video.videoWidth,640/video.videoHeight);context.drawImage(video,(960-video.videoWidth*scale)/2,0,video.videoWidth*scale,video.videoHeight*scale);}
context.fillStyle=ink;context.font='18px sans-serif';context.fillText(status.textContent??'',12,675);context.fillText(`${(performance.now()/1000).toFixed(3)}s · 浏览器播放音画`,12,705);
recordingRaf=requestAnimationFrame(draw);
};draw();
const stream=canvas.captureStream(25);destination!.stream.getAudioTracks().forEach(t=>stream.addTrack(t));
const chunks:Blob[]=[];
recording=new MediaRecorder(stream,{mimeType:'video/webm;codecs=vp8,opus'});
recording.ondataavailable=e=>chunks.push(e.data);
recording.onstop=()=>{
cancelAnimationFrame(recordingRaf);stream.getVideoTracks().forEach(t=>t.stop());
if(downloadUrl)URL.revokeObjectURL(downloadUrl);
downloadUrl=URL.createObjectURL(new Blob(chunks,{type:'video/webm'}));
const link=el<HTMLAnchorElement>('download');link.href=downloadUrl;link.download='wanwan-browser-av.webm';link.hidden=false;
el('record').textContent='录制音画证据';mark('record-stop');
};
recording.start(1000);el('record').textContent='停止录制';mark('record-start');
}
el('enter').onclick=()=>void enter().catch(fail);
el('retry').onclick=()=>{silence('reconnect');void enter().catch(fail)};
el('cancel').onclick=()=>{silence('button',true);message('已停下,正在听。')};
el('back').onclick=()=>{
const needsReturn=view!=='daily';inTalk=false;silence('daily');const epoch=generation;
void (async()=>{
if(needsReturn&&manifest.transition?.available) {
message('已停止声音,正在回到阅读…');
await playBridge(manifest.transition.return,epoch);
}
if(epoch!==generation)return;
setView('daily');el('scene-label').textContent='日常成片 · 阅读';await selectClip();message('已回到阅读原位置。');
})().catch(fail);
};
el('pause').onclick=()=>{paused=!paused;daily.pause();if(!paused)void selectClip().catch(fail);el('pause').textContent=paused?'继续日常':'暂停日常'};
el('mic').onclick=()=>void startMic().catch(fail);
el('record').onclick=()=>void record().catch(fail);
el('form').onsubmit=event=>{event.preventDefault();const input=el<HTMLInputElement>('text');const text=input.value;input.value='';void send(text).catch(error=>{if(error?.name!=='AbortError')fail(error)})};
for(const id of ['activity','weather','timeofday'])el(id).onchange=()=>void selectClip().catch(fail);
document.addEventListener('visibilitychange',()=>{
if(document.hidden){inTalk=false;silence('hidden');daily.pause();message('页面已隐藏,音视频与麦克风均已停止;恢复后需主动进入。');}
});
window.addEventListener('pagehide',()=>{const old=token;silence('pagehide');if(old)void fetch('/api/cancel',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:old}),keepalive:true})});
window.addEventListener('offline',()=>fail(new Error('网络已断开')));
daily.onerror=()=>{dailyMissing=true;el<HTMLButtonElement>('enter').disabled=true;fail(new Error('日常素材丢失或无法解码'))};
talk.onerror=()=>fail(new Error('实时视频无法解码'));
void (async()=>{
const response=await fetch('/assets/manifest.json');if(!response.ok)throw new Error('素材manifest不可达');
manifest=await response.json();await selectClip();
if(manifest.talk_clips.length)listening.src=`/assets/${manifest.talk_clips[0].file}`;
if(manifest.transition?.available)el('warning').textContent='已补目视底板与真实往返候选;自然衔接、同脸和同步仍待人工观感验收。';
el<HTMLButtonElement>('enter').disabled=!manifest.talk_clips.length;
message('日常视频已就绪。交谈入口会连接本地实时口型。');
})().catch(fail);
-20
View File
@@ -1,20 +0,0 @@
@import './tokens.css';
@import './controls.css';
main { height:100dvh; overflow:auto; padding:var(--space-6); display:grid; grid-template-columns:minmax(0,1fr) minmax(320px,420px); grid-template-rows:auto 1fr; gap:var(--space-4); }
header { grid-column:1/-1; display:flex; align-items:baseline; gap:var(--space-4); }
.stage { position:relative; overflow:hidden; min-height:280px; }
.stage video { width:100%; height:100%; object-fit:contain; position:absolute; inset:0; }
#scene-label { position:absolute; bottom:var(--space-4); left:var(--space-4); padding:var(--space-2); background:var(--glass-bg-strong); border-radius:var(--r-control); }
.controls { padding:var(--space-5); display:flex; flex-direction:column; gap:var(--space-4); overflow:auto; }
.row { display:flex; gap:var(--space-2); flex-wrap:wrap; align-items:center; }
.controls button { border-color:var(--glass-border); background:var(--bubble-her); min-height:44px; }
#enter,button[type=submit] { background:var(--accent); color:var(--accent-ink); }
label { display:block; color:var(--ink-secondary); font-size:var(--fs-small); }
input,select { border:1px solid var(--glass-border); border-radius:var(--r-control); background:var(--glass-bg-strong); padding:var(--space-3); min-width:0; }
input { flex:1; width:100%; }
#warning { color:var(--state-danger); font-size:var(--fs-small); }
#selection, #transcript { color:var(--ink-secondary); }
summary { cursor:pointer; padding-block:var(--space-2); }
a { color:var(--accent); }
@media(max-width:760px) { main { grid-template-columns:1fr; grid-template-rows:auto minmax(260px,45dvh) auto; padding:var(--space-3); } .controls { overflow:visible; } }
@media(prefers-reduced-motion:reduce) { button { transition:none; } button:active { transform:none; } }
-73
View File
@@ -1,73 +0,0 @@
/* Reused verbatim from hel-700-rain-room; no alternative product theme. */
:root {
--scene-night-deep:#16233c;
--scene-city-blue:#305481;
--scene-city-mid:#5a76a2;
--scene-tower:#2b456b;
--scene-glass-blue:#718fc2;
--scene-amber:#e8a45c;
--scene-amber-soft:#f2c894;
--scene-wood:#59361d;
--scene-cream:#e8d9c8;
--scene-blanket:#362928;
--ink-primary:#f2efe9;
--ink-secondary:rgba(242,239,233,.64);
--ink-tertiary:rgba(242,239,233,.4);
--ink-disabled:rgba(242,239,233,.28);
--accent:#e8a45c;
--accent-hover:#f2b879;
--accent-pressed:#c9853f;
--accent-ink:#1a1208;
--glass-bg:rgba(14,18,30,.55);
--glass-bg-strong:rgba(14,18,30,.72);
--glass-border:rgba(255,255,255,.08);
--glass-highlight:rgba(255,255,255,.14);
--bubble-user:rgba(120,144,180,.2);
--bubble-user-border:rgba(255,255,255,.07);
--bubble-her:rgba(255,255,255,.06);
--bubble-failed:rgba(200,90,80,.14);
--state-danger:#e07a6a;
--font-sans:"PingFang SC","HarmonyOS Sans SC","MiSans","Source Han Sans SC",system-ui,-apple-system,sans-serif;
--fs-caption:12px;
--fs-small:13px;
--fs-body:14px;
--fs-chat:15px;
--fs-nav:15px;
--fs-title:20px;
--fs-brand:16px;
--fs-time:13px;
--space-1:4px;
--space-2:8px;
--space-3:12px;
--space-4:16px;
--space-5:20px;
--space-6:24px;
--space-8:32px;
--space-10:40px;
--r-panel:24px;
--r-bubble:16px;
--r-card:16px;
--r-control:12px;
--r-pill:999px;
--shadow-panel:0 24px 80px rgba(0,0,0,.45);
--shadow-pop:0 8px 24px rgba(0,0,0,.35);
--glow-accent:0 0 12px rgba(232,164,92,.45);
--glass-blur:blur(28px) saturate(1.2);
--z-scene-far:0;
--z-scene-rain:1;
--z-scene-near:2;
--z-caption:10;
--z-nav:10;
--z-topbar:20;
--z-panel:30;
--z-toast:40;
--ease-out:cubic-bezier(.23,1,.32,1);
--ease-in-out:cubic-bezier(.77,0,.175,1);
--ease-drawer:cubic-bezier(.32,.72,0,1);
--lamp-opacity:.12;
--panel-width:clamp(360px,30vw,460px);
--panel-top:64px;
--panel-inset:24px;
--scene-height:330px;
--drawer-height:30dvh;
}
-91
View File
@@ -1,91 +0,0 @@
"""Source-relative lip residual + audio RMS using browser MediaRecorder PTS.
Heuristic evidence only: no automatic perceptual-sync acceptance.
"""
import argparse
import csv
import json
from pathlib import Path
import av
import cv2
import numpy as np
p = argparse.ArgumentParser()
p.add_argument("video", type=Path)
p.add_argument("--source", type=Path, default=Path("/opt/hel715-verify/talk-close.mp4"))
p.add_argument("--size", type=int, default=768)
p.add_argument("--offset-x", type=int, default=0)
p.add_argument("--offset-y", type=int, default=0)
p.add_argument("--render-size", type=int, default=768)
p.add_argument("--start", type=float, default=0)
p.add_argument("--stop", type=float, default=100000)
p.add_argument("--roi", default="0.4492,0.6185,0.4753,0.6315")
p.add_argument("--idle-until", type=float)
a = p.parse_args()
source = [f.to_ndarray(format="bgr24") for f in av.open(str(a.source)).decode(video=0)]
size = a.size
source = [cv2.resize(f, (size, size)) for f in source]
# Broad lower-face region; source matching uses pixels outside it and outside
# caption/watermark so normal head movement is not mistaken for synthesized lips.
x0, x1, y0, y1 = [round(v * size) for v in map(float, a.roi.split(","))]
mask = np.ones((48, 48), dtype=bool)
mask[0:3] = False
mask[43:] = False
mask[
round(y0 * 48 / size) : round(y1 * 48 / size), round(x0 * 48 / size) : round(x1 * 48 / size)
] = False
features = np.asarray([cv2.resize(f, (48, 48))[mask].astype(np.float32) for f in source])
rows = []
for f in av.open(str(a.video)).decode(video=0):
if not a.start <= float(f.time) <= a.stop:
continue
image = f.to_ndarray(format="bgr24")
image = image[a.offset_y : a.offset_y + a.render_size, a.offset_x : a.offset_x + a.render_size]
image = cv2.resize(image, (size, size))
feature = cv2.resize(image, (48, 48))[mask].astype(np.float32)
index = np.argmin(((features - feature) ** 2).mean(axis=(1, 2)))
residual = np.abs(image.astype(np.float32) - source[index]).mean(axis=2)
rows.append([float(f.time), float(residual[y0:y1, x0:x1].mean()), int(index)])
audio = []
for f in av.open(str(a.video)).decode(audio=0):
if not a.start <= float(f.time) <= a.stop:
continue
data = f.to_ndarray().astype(np.float64)
if f.format.is_planar and f.format.name.startswith("fl"):
pass
elif data.max() > 1:
data /= 32768
audio.append([float(f.time), float(np.sqrt((data**2).mean()))])
with a.video.with_suffix(".sync.csv").open("w") as out:
w = csv.writer(out)
w.writerow(["time_seconds", "lip_residual", "source_index", "audio_rms"])
for t, residual, idx in rows:
nearest = min(audio, key=lambda x: abs(x[0] - t))
w.writerow([t, residual, idx, nearest[1]])
first_audio = next((t for t, rms in audio if rms > 0.015), None)
baseline = [
r
for t, r, _ in rows
if first_audio and t < (a.idle_until if a.idle_until is not None else first_audio - 0.5)
]
threshold = max(4, float(np.quantile(baseline, 0.99)) * 1.5) if baseline else 4
first_lip = next(
(
t
for t, r, _ in rows
if r > threshold
and first_audio
and t >= (a.idle_until if a.idle_until is not None else first_audio - 1)
),
None,
)
result = dict(
first_audio=first_audio,
first_lip=first_lip,
residual_threshold=threshold,
onset_difference_seconds=first_lip - first_audio if first_lip and first_audio else None,
caveat="Source-relative residual heuristic, not phoneme alignment or perceptual acceptance",
)
a.video.with_suffix(".sync.json").write_text(json.dumps(result, indent=2))
print(result)
-50
View File
@@ -1,50 +0,0 @@
import asyncio
import json
import pathlib
from playwright.async_api import async_playwright
OUT = pathlib.Path(__file__).resolve().parents[1] / "evidence"
OUT.mkdir(exist_ok=True)
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(
args=["--no-sandbox", "--autoplay-policy=no-user-gesture-required"]
)
page = await browser.new_page()
await page.goto("http://192.168.200.12:8010")
await page.evaluate("""async () => {
document.body.innerHTML='<video id="v" autoplay playsinline></video><canvas id="c" width="768" height="768"></canvas>';
const v=document.querySelector('video'), c=document.querySelector('canvas'), ctx=c.getContext('2d');
const ac=new AudioContext(), dest=ac.createMediaStreamDestination();
const pc=window.pc=new RTCPeerConnection({iceServers:[]});
const stream=new MediaStream(); v.srcObject=stream;
pc.ontrack=e=>stream.addTrack(e.track);
pc.addTransceiver('audio',{direction:'recvonly'}); pc.addTransceiver('video',{direction:'recvonly'});
await pc.setLocalDescription(await pc.createOffer());
await new Promise(r=>{if(pc.iceGatheringState==='complete')r();else pc.onicegatheringstatechange=()=>{if(pc.iceGatheringState==='complete')r()}});
const a=await (await fetch('/offer',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(pc.localDescription)})).json();
window.sid=a.sessionid; await pc.setRemoteDescription(a);
await new Promise(r=>v.onloadeddata=r); await v.play();
v.muted=true; const source=ac.createMediaStreamSource(stream); source.connect(ac.destination); source.connect(dest);
await ac.resume(); const recorded=c.captureStream(25); dest.stream.getAudioTracks().forEach(t=>recorded.addTrack(t));
window.metrics=[]; const analyser=ac.createAnalyser();source.connect(analyser);let buffer=new Float32Array(analyser.fftSize);
function draw(){ctx.drawImage(v,0,0,768,768);ctx.fillStyle='white';ctx.font='20px sans-serif';ctx.fillText((performance.now()/1000).toFixed(3),20,750);analyser.getFloatTimeDomainData(buffer);metrics.push([performance.now(),Math.sqrt(buffer.reduce((s,x)=>s+x*x,0)/buffer.length)]);window.raf=requestAnimationFrame(draw)}draw();
window.chunks=[];window.rec=new MediaRecorder(recorded,{mimeType:'video/webm;codecs=vp8,opus'});rec.ondataavailable=e=>chunks.push(e.data);rec.start(1000);
}""")
await page.wait_for_timeout(1500)
await page.evaluate(
"""async()=>{window.sent=performance.now();await fetch('/human',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionid:sid,type:'echo',text:'你好。八百标兵奔北坡,炮兵并排北边跑。现在我停下来听你说话。'})})}"""
)
await page.wait_for_timeout(18000)
data = await page.evaluate(
"""async()=>{await new Promise(r=>{rec.onstop=r;rec.stop()});const b=new Blob(chunks);return Array.from(new Uint8Array(await b.arrayBuffer()))}"""
)
(OUT / "baseline-browser-av.webm").write_bytes(bytes(data))
(OUT / "baseline.json").write_text(json.dumps(await page.evaluate("({sent,metrics})")))
await browser.close()
asyncio.run(main())
-136
View File
@@ -1,136 +0,0 @@
"""Real Chromium/WebRTC check; fake devices are explicitly synthetic fixtures."""
import asyncio
import json
import pathlib
import time
from playwright.async_api import async_playwright
ROOT = pathlib.Path(__file__).resolve().parents[1]
OUT = ROOT / "evidence"
OUT.mkdir(exist_ok=True)
URL = "https://192.168.200.12:8014"
async def download(page, path):
await page.click("#record")
await page.wait_for_selector("#download:not([hidden])")
async with page.expect_download() as pending:
await page.click("#download")
await (await pending.value).save_as(path)
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(
args=["--no-sandbox", "--autoplay-policy=no-user-gesture-required"]
)
context = await browser.new_context(
ignore_https_errors=True, viewport={"width": 1280, "height": 800}
)
page = await context.new_page()
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
await page.goto(URL)
await page.wait_for_function(
"document.querySelector('#status').textContent.includes('就绪')"
)
await page.click("#record")
await page.wait_for_timeout(1000)
await page.fill("#text", "我今天下班了,有点累。请只用一句简短的话回应。")
await page.click("button[type=submit]")
await page.wait_for_function(
"window.sampleEvents.some(e=>e.event==='first-audible')", timeout=60000
)
await page.wait_for_timeout(1500)
await page.click("#cancel")
await page.wait_for_timeout(1500)
assert await page.evaluate("document.querySelector('#talk').srcObject===null")
await page.fill("#text", "你在看什么书?只说一句话。")
await page.click("button[type=submit]")
await page.wait_for_function(
"window.sampleEvents.filter(e=>e.event==='first-audible').length>=2", timeout=60000
)
await page.wait_for_timeout(6000)
await page.click("#back")
await page.wait_for_function(
"document.querySelector('#status').textContent.includes('已回到阅读')", timeout=15000
)
assert not await page.locator("#daily").evaluate("(v)=>v.paused")
await page.click("#pause")
assert await page.locator("#daily").evaluate("(v)=>v.paused")
await page.click("#pause")
await page.locator("summary").click()
await page.select_option("#weather", "clear")
assert "回退" in await page.locator("#selection").inner_text()
await page.click("#enter")
await page.wait_for_function(
"document.querySelector('#status').textContent.includes('正在听')", timeout=30000
)
await page.fill("#text", "这是取消准备阶段的测试,请简短回复。")
await page.click("button[type=submit]")
await page.wait_for_function(
"document.querySelector('#status').textContent.includes('准备回复')", timeout=30000
)
await page.wait_for_timeout(1000)
await page.click("#cancel")
await page.wait_for_timeout(6000)
assert await page.locator("#talk").evaluate("(v)=>v.srcObject===null")
await page.click("#back")
(OUT / "sample-events.json").write_text(
json.dumps(await page.evaluate("window.sampleEvents"), ensure_ascii=False, indent=2)
)
await download(page, OUT / "sample-browser-av.webm")
await page.screenshot(path=str(OUT / "desktop.png"))
await page.set_viewport_size({"width": 390, "height": 844})
await page.screenshot(path=str(OUT / "mobile.png"), full_page=True)
mobile_overflow = await page.evaluate("document.documentElement.scrollWidth > innerWidth")
# Failure tests use browser request routing, never stop shared models.
await page.route("**/api/offer", lambda route: route.abort())
await page.click("#retry")
await page.wait_for_function(
"document.querySelector('#status').textContent.includes('已停止交谈')", timeout=30000
)
await page.unroute("**/api/offer")
await page.click("#retry")
await page.wait_for_function(
"document.querySelector('#status').textContent.includes('正在听')", timeout=30000
)
await context.set_offline(True)
await page.wait_for_timeout(500)
assert await page.locator("#talk").evaluate("(v)=>v.srcObject===null")
await context.set_offline(False)
await page.reload()
await page.wait_for_function(
"document.querySelector('#status').textContent.includes('就绪')"
)
# This is a handler test, not a claim of real OS background testing.
await page.evaluate(
"Object.defineProperty(document,'hidden',{configurable:true,value:true});document.dispatchEvent(new Event('visibilitychange'))"
)
assert await page.locator("#daily").evaluate("(v)=>v.paused")
await page.evaluate(
"Object.defineProperty(document,'hidden',{configurable:true,value:false})"
)
await page.route("**/reading-rain-night.mp4", lambda route: route.abort())
await page.reload()
await page.wait_for_function(
"document.querySelector('#status').textContent.includes('素材丢失')", timeout=30000
)
result = {
"pageerrors": errors,
"mobile_overflow": mobile_overflow,
"visibility_test": "synthetic event only; real OS background not tested",
"cancel": "speaking and preparing",
"network": "offer abort, offline, recovery",
"missing_asset": "daily video request abort",
"time": time.time(),
}
# Events from the main continuous recording were captured before reload.
(OUT / "browser-check.json").write_text(json.dumps(result, ensure_ascii=False, indent=2))
await browser.close()
print(json.dumps(result, ensure_ascii=False), flush=True)
asyncio.run(main())
@@ -1,92 +0,0 @@
"""Run with the pinned driver venv: python check_driver_audio.py PRIVATE_DRIVER."""
import hashlib
import importlib.util
import pathlib
import sys
import time
import unittest
from types import SimpleNamespace
import numpy as np
ROOT = pathlib.Path(sys.argv.pop(1)).resolve()
sys.path.insert(0, str(ROOT))
spec = importlib.util.spec_from_file_location('driver_webrtc', ROOT / 'server/webrtc.py')
driver = importlib.util.module_from_spec(spec)
spec.loader.exec_module(driver)
PendingAudio = driver.PendingAudio
class QueueChecks(unittest.TestCase):
def test_idle_is_bounded_speech_and_events_are_lossless(self):
pending = PendingAudio()
expected = []
for i in range(2000):
if i % 100 == 0:
# Real zero PCM and custom audio are not synthetic silence.
item = (i, {'event': i} if i % 200 else {}, 0 if i % 200 else 2)
expected.append(item)
else:
item = (i, None, 1)
pending.put(item)
actual = list(pending.queue)
self.assertEqual([x for x in actual if x[2] != 1], expected)
self.assertEqual(sum(x[2] == 1 for x in actual), 3)
self.assertLessEqual(pending.high_water, len(expected) + 3)
self.assertGreater(pending.dropped_silence, 1900)
marker = ('event', {'end': True}, 1)
pending.put(marker)
for i in range(50):
pending.put((i, None, 1))
self.assertIn(marker, list(pending.queue))
def test_real_audio_has_bounded_backpressure(self):
pending = PendingAudio()
for i in range(100):
pending.put((i, None, 0))
import queue
with self.assertRaises(queue.Full):
pending.put((101, None, 0), block=False)
self.assertEqual([x[0] for x in pending.queue], list(range(100)))
def test_warmup_drains_all_context(self):
from avatars.audio_features.base_asr import BaseASR
asr = BaseASR(SimpleNamespace(fps=25, batch_size=8, l=10, r=10))
asr.warm_up()
self.assertEqual(len(asr.frames), 20)
self.assertTrue(asr.output_queue.empty())
first = np.arange(320, dtype=np.float32)
asr.put_audio_frame(first, {'first': True})
frame = asr.get_audio_frame()
np.testing.assert_array_equal(frame.data, first)
self.assertEqual(frame.type, 0)
self.assertEqual(frame.userdata, {'first': True})
class ClockChecks(unittest.IsolatedAsyncioTestCase):
async def test_starvation_preserves_cadence_and_first_pcm(self):
player = SimpleNamespace(_start=lambda _: None, _stop=lambda _: None, notify=lambda _: None)
track = driver.PlayerStreamTrack(player, 'audio')
frame = driver.AudioFrame.from_ndarray(np.ones((1, 320), np.int16), format='s16', layout='mono')
frame.sample_rate = 16000
track._queue.put((frame, None, 0))
start = time.monotonic()
frames = [await track.recv() for _ in range(15)]
self.assertEqual([f.pts for f in frames], list(range(0, 15 * 320, 320)))
self.assertGreater(time.monotonic() - start, 0.25)
self.assertLess(time.monotonic() - start, 0.5)
self.assertTrue(all(not f.to_ndarray().any() for f in frames[1:]))
# Resume with exact first speech PCM, without trimming any syllable.
pcm = np.arange(320, dtype=np.int16).reshape(1, -1)
voice = driver.AudioFrame.from_ndarray(pcm, format='s16', layout='mono')
voice.sample_rate = 16000
track._queue.put((voice, {'start': True}, 0))
out = await track.recv()
self.assertEqual(out.pts, 15 * 320)
self.assertEqual(hashlib.sha256(out.to_ndarray().tobytes()).digest(), hashlib.sha256(pcm.tobytes()).digest())
track.stop()
if __name__ == '__main__':
unittest.main(verbosity=2)
@@ -1,103 +0,0 @@
"""Exercise the patched ingress, mixed inference batch and real output loop."""
import hashlib
import pathlib
import queue
import sys
import unittest
from threading import Event
from types import SimpleNamespace
import numpy as np
ROOT = pathlib.Path(sys.argv.pop(1)).resolve()
sys.path.insert(0, str(ROOT))
from avatars.audio_features.base_asr import BaseASR # noqa: E402
from avatars.base_avatar import AudioFrameData, BaseAvatar # noqa: E402
from avatars.hel720_head import HeadGate # noqa: E402
def frame(value, status=None, kind=0):
return AudioFrameData(np.full(320, value, dtype=np.float64), kind,
{'status': status} if status else {})
class HeadChecks(unittest.TestCase):
def test_boundary_disabled_and_invalid(self):
gate = HeadGate(.001)
self.assertTrue(gate.classify(frame(.001, 'start')))
self.assertFalse(gate.classify(frame(.0010001)))
self.assertFalse(gate.classify(frame(0)))
self.assertFalse(HeadGate(0).classify(frame(0, 'start')))
for value in [-1, float('nan'), float('inf')]:
with self.assertRaises(ValueError):
HeadGate(value)
def test_lifecycle_starvation_custom_and_end(self):
gate = HeadGate(.001)
self.assertTrue(gate.classify(frame(0, 'start')))
self.assertFalse(gate.classify(frame(.1)))
self.assertFalse(gate.classify(frame(0, kind=1)))
self.assertFalse(gate.classify(frame(0, kind=2)))
self.assertFalse(gate.classify(frame(0))) # weak mid-utterance remains speech
self.assertFalse(gate.classify(frame(0, 'end')))
self.assertTrue(gate.classify(frame(0)))
self.assertFalse(gate.classify(frame(.1)))
self.assertTrue(gate.classify(frame(0, 'start')))
def test_cancel_and_new_peer_keep_old_decisions_isolated(self):
opt = SimpleNamespace(fps=25, batch_size=8, l=10, r=10)
asr = BaseASR(opt)
asr.put_audio_frame(np.ones(320), {'status': 'start'})
old = asr.queue.get()
asr.flush_talk()
asr.put_audio_frame(np.zeros(320), {})
self.assertTrue(asr.queue.get().hel720_head_closed)
self.assertFalse(old.hel720_head_closed)
fresh = BaseASR(opt)
fresh.put_audio_frame(np.zeros(320), {})
self.assertTrue(fresh.queue.get().hel720_head_closed)
def test_mixed_batch_render_and_exact_audio_events_types(self):
asr = BaseASR(SimpleNamespace(fps=25, batch_size=8, l=10, r=10))
values = [0] * 5 + [.02] + [0] * 10
expected = []
for i, value in enumerate(values):
data = np.full(320, value, np.float32)
event = {'status': 'start'} if i == 0 else {'status': 'end'} if i == 15 else {}
asr.put_audio_frame(data, event)
expected.append((hashlib.sha256((data * 32767).astype(np.int16).tobytes()).hexdigest(), event, 0))
frames = [asr.queue.get() for _ in values]
self.assertEqual([f.hel720_head_closed for f in frames], [True]*5 + [False]*11)
for f in frames:
asr.output_queue.put(f)
asr.feat_queue.put(['feature']*8)
avatar = BaseAvatar.__new__(BaseAvatar)
avatar.asr, avatar.batch_size = asr, 8
avatar.frame_list_cycle = [np.zeros((32, 32, 3), np.uint8) for _ in range(20)]
avatar.custom_index = {}
avatar.res_frame_queue = queue.Queue()
avatar.inference_batch = lambda index, features: [np.ones((32, 32, 3), np.uint8)*100 for _ in features]
avatar.inference(SimpleNamespace(is_set=lambda: avatar.res_frame_queue.qsize() == 8))
results = list(avatar.res_frame_queue.queue)
self.assertEqual([x[0] is None for x in results], [True, True] + [False]*6)
quit_event = Event()
videos, audios = [], []
def video(image):
videos.append(image.copy())
if len(videos) == 8:
quit_event.set()
avatar.output = SimpleNamespace(start=lambda: None, stop=lambda: None,
push_video_frame=video,
push_audio_data=lambda pcm, event, kind: audios.append(
(hashlib.sha256(pcm.tobytes()).hexdigest(), event, kind)))
avatar.record_video_data = lambda image: None
avatar.record_audio_data = lambda pcm: None
avatar.paste_back_frame = lambda image, index: image
avatar.process_frames(quit_event)
self.assertEqual([int(v[-1, -1, 0]) for v in videos], [0, 0] + [100]*6)
self.assertEqual(audios, expected)
if __name__ == '__main__':
unittest.main(verbosity=2)
@@ -1,47 +0,0 @@
"""Startup failure must propagate; preparation must never touch live I/O or gates."""
import pathlib
import sys
import unittest
from types import SimpleNamespace
from unittest.mock import patch
import numpy as np
root = pathlib.Path(sys.argv.pop(1)).resolve()
sys.path.insert(0, str(root))
from avatars.base_avatar import BaseAvatar # noqa: E402
from avatars.hel720_head import HeadGate # noqa: E402
from avatars.hel722_ready import prepare # noqa: E402
from avatars.musetalk_avatar import MuseReal # noqa: E402
class ReadyChecks(unittest.TestCase):
def test_decoder_failure_is_fatal_without_constructing_session(self):
with patch.object(MuseReal, '__init__', side_effect=AssertionError('live session')), \
patch('resampy.resample', side_effect=ValueError('injected resampler failure')), \
self.assertRaisesRegex(RuntimeError, 'decode_resample'):
prepare(SimpleNamespace(), None, None)
def test_discard_predictions_and_no_pcm_events_or_gate_mutation(self):
opt = SimpleNamespace(fps=25, batch_size=8, l=10, r=10)
processor = SimpleNamespace(audio2feat=lambda x: np.zeros((1500, 5, 384), np.float16))
avatar = [None, None, None, None, [np.zeros((1, 8, 32, 32))]]
model = [None, None, None, None, processor]
# Real decode/resample and feature slicing; deny all live-session entrypoints.
with patch.object(MuseReal, '__init__', side_effect=AssertionError('live session')), \
patch.object(HeadGate, 'classify', side_effect=AssertionError('gate')), \
patch.object(BaseAvatar, 'put_audio_file', side_effect=AssertionError('input')), \
patch.object(BaseAvatar, 'notify', side_effect=AssertionError('event')), \
patch.object(MuseReal, 'inference_batch', return_value=np.zeros((8, 4, 4, 3))) as infer:
status = prepare(opt, model, avatar)
self.assertEqual(status['state'], 'ready')
self.assertEqual(status['feature_shape'], [8, 50, 384])
self.assertEqual(infer.call_count, 1)
self.assertNotIn('prediction', status)
with patch.object(MuseReal, 'inference_batch', side_effect=RuntimeError('GPU failed')), \
self.assertRaisesRegex(RuntimeError, 'inference'):
prepare(opt, model, avatar)
unittest.main()
-127
View File
@@ -1,127 +0,0 @@
"""Cancellation/voice checks against local fake HTTP services, no paid APIs."""
import asyncio
import importlib.util
import pathlib
import unittest
from aiohttp import web
from aiohttp.test_utils import TestServer
spec = importlib.util.spec_from_file_location(
"sample_server", pathlib.Path(__file__).resolve().parents[1] / "runtime/server.py"
)
server = importlib.util.module_from_spec(spec)
spec.loader.exec_module(server)
class GatewayChecks(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.started = asyncio.Event()
self.release = asyncio.Event()
self.mode = "llm"
self.voice = "xiaoyun"
self.queued = 0
self.closed = []
app = web.Application()
async def health(request):
return web.json_response({"ok": True, "voices": [self.voice]})
async def llm(request):
if self.mode == "llm":
self.started.set()
await self.release.wait()
return web.json_response({"choices": [{"message": {"content": "你好。"}}]})
async def tts(request):
self.started.set()
await self.release.wait()
return web.Response(body=b"\0\0" * 2400)
async def audio(request):
self.queued += 1
return web.json_response({"code": 0})
async def close(request):
self.closed.append((await request.json())["sessionid"])
return web.json_response({"closed": True})
app.router.add_get("/health", health)
app.router.add_post("/llm", llm)
app.router.add_post("/tts", tts)
app.router.add_post("/humanaudio", audio)
app.router.add_post("/hel714/close", close)
self.upstream = TestServer(app)
await self.upstream.start_server()
base = str(self.upstream.make_url("")).rstrip("/")
self.gateway = server.Gateway(
{
"driver": base,
"llm": base + "/llm",
"model": "fixture",
"tts": base,
"voice": "xiaoyun",
}
)
await self.gateway.start(None)
async def asyncTearDown(self):
self.release.set()
await self.gateway.stop(None)
await self.upstream.close()
async def cancel_at(self, mode):
self.mode = mode
session = {"sid": "old-session", "task": None}
self.gateway.sessions["old-token"] = session
task = session["task"] = asyncio.create_task(self.gateway.reply(session, "测试"))
await asyncio.wait_for(self.started.wait(), 2)
await self.gateway.retire("old-token")
self.assertTrue(task.cancelled())
self.release.set()
await asyncio.sleep(0.05)
self.assertEqual(self.queued, 0)
self.assertEqual(self.closed, ["old-session"])
self.assertNotIn("old-token", self.gateway.sessions)
# Idempotence: a late duplicate cancel cannot close a new session.
self.gateway.sessions["new-token"] = {"sid": "new-session", "task": None}
await self.gateway.retire("old-token")
self.assertIn("new-token", self.gateway.sessions)
async def test_cancel_during_llm(self):
await self.cancel_at("llm")
async def test_cancel_during_tts(self):
await self.cancel_at("tts")
async def test_missing_voice_is_explicit(self):
self.voice = "default"
with self.assertRaisesRegex(RuntimeError, "未回退"):
await self.gateway.reply({"sid": "test"}, "测试")
self.assertEqual(self.queued, 0)
async def test_cancel_while_waiting_for_media_ready(self):
self.mode = "tts"
session = {"sid": "old-session", "task": None, "ready": asyncio.Event()}
self.gateway.sessions["old-token"] = session
task = session["task"] = asyncio.create_task(self.gateway.reply(session, "测试"))
await asyncio.wait_for(self.started.wait(), 2)
self.release.set()
await asyncio.sleep(0.05)
self.assertEqual(self.queued, 0)
await self.gateway.retire("old-token")
session["ready"].set()
await asyncio.sleep(0.05)
self.assertTrue(task.cancelled())
self.assertEqual(self.queued, 0)
async def test_model_unreachable(self):
self.gateway.config["llm"] = "http://127.0.0.1:1/unavailable"
with self.assertRaises(server.aiohttp.ClientError):
await self.gateway.reply({"sid": "unreachable"}, "测试")
self.assertEqual(self.queued, 0)
if __name__ == "__main__":
unittest.main(verbosity=2)
@@ -1,89 +0,0 @@
"""Synthetic WAV through Chromium's microphone device, not a human VAD pass."""
import asyncio
import json
import pathlib
from playwright.async_api import async_playwright
ROOT = pathlib.Path(__file__).resolve().parents[1]
OUT = ROOT / "evidence"
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(
args=[
"--no-sandbox",
"--autoplay-policy=no-user-gesture-required",
"--use-fake-device-for-media-stream",
"--use-fake-ui-for-media-stream",
f"--use-file-for-fake-audio-capture={OUT / 'synthetic-microphone.wav'}%noloop",
]
)
context = await browser.new_context(ignore_https_errors=True, permissions=["microphone"])
page = await context.new_page()
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
await page.goto("https://192.168.200.12:8014")
await page.wait_for_function(
"document.querySelector('#status').textContent.includes('就绪')"
)
await page.click("#enter")
await page.wait_for_function(
"document.querySelector('#status').textContent.includes('正在听')", timeout=40000
)
await page.click("#record")
await page.click("#mic")
await page.wait_for_function(
"document.querySelector('#mic').getAttribute('aria-pressed')==='true'"
)
await page.fill("#text", "请用三句话描述你眼前卧室的温暖氛围,大约六十个字。")
await page.click("button[type=submit]")
await page.wait_for_function(
"window.sampleEvents.some(e=>e.event==='cancel'&&e.detail.reason==='microphone-onset')",
timeout=25000,
)
await page.wait_for_function(
"document.querySelector('#transcript').textContent.includes('识别:')", timeout=15000
)
await page.wait_for_function(
"window.sampleEvents.filter(e=>e.event==='input-finished').length>=2", timeout=15000
)
await page.wait_for_timeout(18000)
await page.click("#record")
await page.wait_for_selector("#download:not([hidden])")
async with page.expect_download() as pending:
await page.click("#download")
await (await pending.value).save_as(OUT / "synthetic-microphone-av.webm")
events = await page.evaluate("window.sampleEvents")
onset = next(
e["at"]
for e in events
if e["event"] == "cancel" and e["detail"]["reason"] == "microphone-onset"
)
audible_before = [e for e in events if e["event"] == "first-audible" and e["at"] < onset]
audible_after = [e for e in events if e["event"] == "first-audible" and e["at"] > onset]
result = dict(
fixture="CosyVoice synthesized WAV through fake Chromium microphone; no human recording",
secure_context=await page.evaluate("isSecureContext"),
transcript=await page.locator("#transcript").inner_text(),
speech_was_audible_before_onset=bool(audible_before),
new_reply_after_asr=bool(audible_after),
events=events,
pageerrors=errors,
)
(OUT / "synthetic-microphone.json").write_text(
json.dumps(result, ensure_ascii=False, indent=2)
)
await page.click("#mic")
await page.click("#back")
await page.wait_for_timeout(4000)
await browser.close()
print(
json.dumps({k: v for k, v in result.items() if k != "events"}, ensure_ascii=False),
flush=True,
)
asyncio.run(main())
-65
View File
@@ -1,65 +0,0 @@
"""Cancel during ICE/offer, then verify a late old error cannot stop a new turn."""
import asyncio
import json
import pathlib
import sys
from playwright.async_api import async_playwright
OUT = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path(__file__).resolve().parents[1] / "evidence"
OUT.mkdir(parents=True, exist_ok=True)
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(
args=["--no-sandbox", "--autoplay-policy=no-user-gesture-required"]
)
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
errors = []
page.on("pageerror", lambda e: errors.append(str(e)))
try:
await page.goto("https://192.168.200.12:8014")
await page.wait_for_function(
"document.querySelector('#status').textContent.includes('就绪')"
)
await page.click("#enter")
await page.wait_for_function(
"document.querySelector('#status').textContent.includes('正在听')", timeout=40000
)
await page.fill("#text", "这一轮马上取消。")
await page.click("button[type=submit]")
await page.wait_for_timeout(100)
await page.click("#cancel")
await page.fill("#text", "新一轮只回复:你好。")
await page.click("button[type=submit]")
await page.wait_for_function(
"window.sampleEvents.some(e=>e.event==='first-audible')", timeout=45000
)
await page.wait_for_timeout(10000)
events = await page.evaluate("window.sampleEvents")
failures = [
e for e in events if e["event"] == "status" and "媒体协商超时" in str(e["detail"])
]
result = {"pageerrors": errors, "late_error_failures": failures, "events": events}
(OUT / "race-check.json").write_text(json.dumps(result, ensure_ascii=False, indent=2))
assert not failures
await page.click("#back")
await page.wait_for_timeout(4000)
finally:
(OUT / "race-page-state.json").write_text(json.dumps({
"url": page.url,
"status": await page.locator('#status').inner_text(),
"events": await page.evaluate('window.sampleEvents'),
"pageerrors": errors,
"media": await page.locator('video').evaluate_all(
'(vs)=>vs.map(v=>({id:v.id, ready:v.readyState, paused:v.paused, time:v.currentTime}))'),
}, ensure_ascii=False, indent=2))
await page.screenshot(path=str(OUT / 'race-final.png'))
await browser.close()
print("ICE cancellation race passed", flush=True)
asyncio.run(main())
-35
View File
@@ -1,35 +0,0 @@
"""Pending audio before RTP timestamps: only type=1 synthetic idle is expendable."""
import queue
class PendingAudio(queue.Queue):
"""Keep at most three un-timestamped synthetic blocks; preserve speech/events.
One render producer, one RTP consumer. Real speech (including zero PCM and
custom type>1 audio) uses bounded Queue backpressure, never a drop policy.
"""
silence_limit = 3
def __init__(self):
super().__init__(maxsize=100)
self.dropped_silence = 0
self.high_water = 0
def put(self, item, block=True, timeout=None):
with self.not_full:
# Trim before assigning pts, never inside the RTP consumer. Event
# markers remain observable even if a producer puts one on silence.
idle = [i for i, (_, event, kind) in enumerate(self.queue) if kind == 1 and not event]
incoming_idle = item[2] == 1 and not item[1]
excess = len(idle) + int(incoming_idle) - self.silence_limit
for i in reversed(idle[:max(0, excess)]):
del self.queue[i]
self.unfinished_tasks -= 1
self.dropped_silence += 1
if excess > 0:
self.not_full.notify_all()
super().put(item, block=block, timeout=timeout)
with self.mutex:
self.high_water = max(self.high_water, len(self.queue))
-42
View File
@@ -1,42 +0,0 @@
"""Candidate head-only render policy; audio bytes, types and events stay intact."""
import math
import os
import threading
import numpy as np
class HeadGate:
def __init__(self, threshold=None):
self.threshold = float(os.environ.get('HEL720_HEAD_RMS', '0.001')
if threshold is None else threshold)
if not math.isfinite(self.threshold) or self.threshold < 0:
raise ValueError('HEL720_HEAD_RMS must be finite and >= 0 (0 disables)')
self._lock = threading.Lock()
self.leading = True
def reset(self):
with self._lock:
self.leading = True
def classify(self, frame):
# Called on original 16k PCM at ASR ingress, BEFORE feature batching.
# Synthetic idle never rearms a running utterance (stream starvation).
with self._lock:
if frame.type != 0:
return False
status = (frame.userdata or {}).get('status')
if status == 'start':
self.leading = True
rms = float(np.sqrt(np.mean(np.square(frame.data, dtype=np.float64))))
closed = self.threshold > 0 and self.leading and rms <= self.threshold
if not closed:
self.leading = False
if status == 'end':
self.leading = True
return closed
def render_speech(frames):
return any(f.type == 0 and not getattr(f, 'hel720_head_closed', False) for f in frames)
-64
View File
@@ -1,64 +0,0 @@
"""Warm the pinned MuseTalk file/feature/inference path before HTTP accepts peers.
No avatar constructor, input/output queue, HeadGate, TTS or network is used.
The probe owns only model references and a private ASR helper; discard predictions.
"""
import json
import time
from io import BytesIO
import numpy as np
import soundfile as sf
def prepare(opt, model, avatar):
from avatars.audio_features.whisper import WhisperASR
from avatars.base_avatar import BaseAvatar
from avatars.musetalk_avatar import MuseReal
from utils.logger import logger
started = time.monotonic()
state = {'state': 'preparing', 'started_wall': started, 'stages': {}}
logger.info('HEL-722 readiness %s', json.dumps(state))
stage = 'decode_resample'
try:
# Match the actual upload decoder, including its first-use resampler JIT.
# These bytes are local, never passed to put_audio_file/put_audio_frame.
wav = BytesIO()
sf.write(wav, np.zeros(2400, np.float32), 24000, format='WAV', subtype='PCM_16')
wav.seek(0)
probe = MuseReal.__new__(MuseReal)
probe.sample_rate = 16000
t = time.monotonic()
BaseAvatar._BaseAvatar__create_bytes_stream(probe, wav)
state['stages'][stage] = time.monotonic() - t
probe.batch_size = opt.batch_size
probe.vae, probe.unet, probe.pe, probe.timesteps, probe.audio_processor = model
probe.input_latent_list_cycle = avatar[-1]
# __new__ avoids ASR queues/gates as well as session/output construction.
asr = WhisperASR.__new__(WhisperASR)
stage = 'features'
t = time.monotonic()
inputs = np.zeros((opt.l + opt.r + opt.batch_size * 2) * (16000 // (opt.fps * 2)),
dtype=np.float32)
features = probe.audio_processor.audio2feat(inputs)
chunks = asr._feature2chunks(features, opt.batch_size, [2, 3], opt.l / 2, 2)
state['stages'][stage] = time.monotonic() - t
state['feature_shape'] = list(np.stack(chunks).shape)
state['latent_shape'] = list(avatar[-1][0].shape)
stage = 'inference'
t = time.monotonic()
prediction = probe.inference_batch(0, chunks)
if len(prediction) != opt.batch_size or not np.isfinite(prediction).all():
raise RuntimeError('Invalid startup inference output')
state['stages'][stage] = time.monotonic() - t
state.update(state='ready', elapsed=time.monotonic() - started)
logger.info('HEL-722 readiness %s', json.dumps(state))
return state
except Exception as exc:
state.update(state='failed', stage=stage, error=str(exc),
elapsed=time.monotonic() - started)
logger.exception('HEL-722 readiness %s', json.dumps(state))
raise RuntimeError(f'HEL-722 startup warmup failed at {stage}') from exc
@@ -1,251 +0,0 @@
"""Generate one v2 gaze idle and two forward first/last-frame transitions.
Owns and stops its exact server PID in finally. Does not modify the installation.
"""
import json
import os
import subprocess
import time
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path("/media/lucian/Downloads/ComfyUI")
TASK = Path("/opt/hel714-material")
URL = "http://127.0.0.1:18189"
def request(path, payload=None):
data = None if payload is None else json.dumps(payload).encode()
req = urllib.request.Request(
URL + path, data=data, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=20) as response:
return json.load(response)
def graph(kind):
nodes = {}
def add(key, kind, **inputs):
nodes[str(key)] = dict(class_type=kind, inputs=inputs)
for key, mode in [(1, "high"), (2, "low")]:
add(
key,
"UNETLoader",
unet_name=f"wan2.2_i2v_{mode}_noise_14B_fp8_scaled.safetensors",
weight_dtype="default",
)
add(
key + 2,
"LoraLoaderModelOnly",
model=[str(key), 0],
lora_name=f"wan2.2_i2v_lightx2v_4steps_lora_v1_{mode}_noise.safetensors",
strength_model=1.0,
)
add(key + 4, "ModelSamplingSD3", model=[str(key + 2), 0], shift=5.0)
add(
7,
"CLIPLoader",
clip_name="umt5_xxl_fp8_e4m3fn_scaled.safetensors",
type="wan",
device="cpu",
)
add(8, "VAELoader", vae_name="wan_2.1_vae.safetensors")
add(9, "LoadImage", image="start.png")
add(17, "LoadImage", image="end.png")
add(
10,
"CLIPTextEncode",
clip=["7", 0],
text=(
"Locked off camera, photorealistic young adult woman with short dark hair, "
"cream cardigan and dark trousers seated on the same sofa in the same bedroom. "
"She gently raises her gaze from the book to meet the viewer, then holds a calm listening gaze. "
"Her hands keep holding the open book without moving it. Natural continuous breathing. "
"Fixed seat position, stationary room and furniture, identical face and outfit."
),
)
add(
11,
"CLIPTextEncode",
clip=["7", 0],
text="camera movement, zoom, changing face, changing clothes, standing up, "
"extra fingers, distorted hands, deformed face, moving furniture, subtitles",
)
add(
12,
"WanFirstLastFrameToVideo",
positive=["10", 0],
negative=["11", 0],
vae=["8", 0],
width=512,
height=512,
length=49,
batch_size=1,
start_image=["9", 0],
end_image=["17", 0],
)
add(
13,
"KSamplerAdvanced",
model=["5", 0],
add_noise="enable",
noise_seed=714,
steps=4,
cfg=1.0,
sampler_name="euler",
scheduler="simple",
positive=["12", 0],
negative=["12", 1],
latent_image=["12", 2],
start_at_step=0,
end_at_step=2,
return_with_leftover_noise="enable",
)
add(
14,
"KSamplerAdvanced",
model=["6", 0],
add_noise="disable",
noise_seed=714,
steps=4,
cfg=1.0,
sampler_name="euler",
scheduler="simple",
positive=["12", 0],
negative=["12", 1],
latent_image=["13", 0],
start_at_step=2,
end_at_step=4,
return_with_leftover_noise="disable",
)
add(15, "VAEDecode", samples=["14", 0], vae=["8", 0])
add(16, "SaveWEBM", images=["15", 0], codec="vp9", fps=16.0, filename_prefix="enter", crf=20.0)
nodes["9"]["inputs"]["image"] = "daily-cut.png" if kind == "enter" else "near.png"
nodes["17"]["inputs"]["image"] = "daily-cut.png" if kind == "return" else "near.png"
nodes["10"]["inputs"]["text"] = {
"enter": "Photorealistic adult woman, same short dark hair, cream cardigan, same bedroom. Starting from reading, she gently raises her head to meet the viewer. Smooth slow camera push toward her half body, ending at the exact reference framing. Hands keep holding the same book. Continuous physically plausible movement, stable face, stable clothing and light.",
"return": "Photorealistic adult woman, same short dark hair, cream cardigan, same bedroom. She gently lowers her gaze to the book and returns to reading, with a smooth slow camera pull back to the exact final reference. Same hands holding the same book, continuous physically plausible movement, stable face, stable clothing and light.",
"idle": "Locked camera, photorealistic adult woman in cream cardigan in the same bedroom. Calmly looks toward the viewer, lips gently closed, one subtle natural blink and breathing. Keeps holding the open book still. Stable face, clothing, seat, camera, light and room.",
}[kind]
nodes["11"]["inputs"]["text"] = (
"changing identity, changing clothes, extra fingers, distorted hands, deformed face, melting furniture, subtitles, speaking mouth"
)
nodes["12"]["inputs"]["length"] = 81 if kind == "idle" else 49
nodes["16"]["inputs"]["filename_prefix"] = kind
return nodes
def main():
(TASK / "assets").mkdir(parents=True, exist_ok=True)
# Explicit first/last frames are prepared from the approved v2 source.
assert (TASK / "assets/daily-cut.png").exists()
assert (TASK / "assets/near.png").exists()
if list(TASK.glob("*-history.json")):
raise RuntimeError("Use a new task directory; existing generation evidence is preserved")
for name in ["user", "temp"]:
(TASK / name).mkdir(exist_ok=True)
os.environ["HF_HUB_OFFLINE"] = "1"
os.environ["TRANSFORMERS_OFFLINE"] = "1"
command = [
str(ROOT / "venv/bin/python"),
str(ROOT / "main.py"),
"--listen",
"127.0.0.1",
"--port",
"18189",
"--disable-all-custom-nodes",
"--offline",
"--disable-manager-ui",
"--disable-assets",
"--reserve-vram",
"8",
"--disable-dynamic-vram",
"--input-directory",
str(TASK / "assets"),
"--output-directory",
str(TASK),
"--user-directory",
str(TASK / "user"),
"--temp-directory",
str(TASK / "temp"),
"--database-url",
"sqlite:///:memory:",
]
# Refuse to attach to an unrelated listener.
import socket
with socket.socket() as sock:
if sock.connect_ex(("127.0.0.1", 18189)) == 0:
raise RuntimeError("Task port already occupied; no process started")
with open(TASK / "server.log", "w") as log:
server = subprocess.Popen(command, cwd=ROOT, stdout=log, stderr=subprocess.STDOUT)
(TASK / "process.json").write_text(json.dumps(dict(pid=server.pid, command=command)))
try:
deadline = time.monotonic() + 90
while True:
if server.poll() is not None:
raise RuntimeError(f"ComfyUI exited: {server.returncode}; see server.log")
try:
info = request("/object_info")
break
except (OSError, urllib.error.URLError):
if time.monotonic() > deadline:
raise TimeoutError("Startup exceeded 90 seconds") from None
time.sleep(1)
for kind in ["idle", "enter", "return"]:
workflow = graph(kind)
(TASK / f"{kind}-workflow.json").write_text(json.dumps(workflow, indent=2))
required = {n["class_type"]: info.get(n["class_type"]) for n in workflow.values()}
(TASK / f"{kind}-nodes.json").write_text(json.dumps(required, indent=2))
if any(v is None for v in required.values()):
raise RuntimeError("Required native node unavailable; see nodes.json")
result = request("/prompt", {"prompt": workflow})
(TASK / f"{kind}-submission.json").write_text(json.dumps(result))
prompt_id = result["prompt_id"]
print(
f"Generating one clip: {prompt_id}; owned server PID {server.pid}", flush=True
)
deadline = time.monotonic() + 2400
last_report = 0
while time.monotonic() < deadline:
history = request("/history/" + prompt_id)
if prompt_id in history:
(TASK / f"{kind}-history.json").write_text(json.dumps(history, indent=2))
print(json.dumps(history[prompt_id]["status"]), flush=True)
if history[prompt_id]["status"]["status_str"] != "success":
raise RuntimeError("Generation failed; see history.json")
break
if time.monotonic() - last_report > 30:
print(
"Single generation still active; existing services untouched.",
flush=True,
)
last_report = time.monotonic()
time.sleep(3)
else:
request("/interrupt", {})
raise TimeoutError("Single generation exceeded 40 minutes")
except urllib.error.HTTPError as error:
(TASK / "api-error.json").write_bytes(error.read())
raise
finally:
server.terminate()
try:
server.wait(timeout=20)
except subprocess.TimeoutExpired:
server.kill()
server.wait()
print(f"Stopped owned server PID {server.pid}", flush=True)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--task", type=Path, default=TASK)
TASK = parser.parse_args().task
main()
-41
View File
@@ -1,41 +0,0 @@
import argparse
import asyncio
from pathlib import Path
import aiohttp
from aiohttp import web
p = argparse.ArgumentParser()
p.add_argument('--wav', type=Path, required=True)
p.add_argument('--index', type=Path, required=True)
a = p.parse_args()
INDEX = a.index.read_bytes()
WAV = a.wav.read_bytes()
async def proxy(request):
data = await request.read()
async with aiohttp.ClientSession() as http, http.request(
request.method,
"http://127.0.0.1:8010" + request.path[len("/lt") :],
data=data if request.method == "POST" else None,
headers={"Content-Type": request.headers.get("Content-Type", "")} if request.method == "POST" else {},
) as r:
body = await r.read()
return web.Response(status=r.status, body=body, content_type=r.content_type)
async def main():
app = web.Application()
app.router.add_get("/", lambda _: web.Response(body=INDEX, content_type="text/html"))
app.router.add_get("/fixed.wav", lambda _: web.Response(body=WAV, content_type="audio/wav"))
app.router.add_route("*", "/lt/{tail:.*}", proxy)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", 8933)
await site.start()
print("serving on 8933")
await asyncio.Event().wait()
asyncio.run(main())
-192
View File
@@ -1,192 +0,0 @@
#!/usr/bin/env python
"""HEL-717 onset analysis: audio RMS onset vs lip-motion onset, multiple detectors.
Works on any muxed recording (browser webm or true-pts mp4). Mouth ROI is
derived from the recording itself (temporal variance), coordinates are saved.
"""
import argparse
import csv
import hashlib
import json
import sys
from pathlib import Path
import av
import cv2
import numpy as np
p = argparse.ArgumentParser()
p.add_argument("video", type=Path)
p.add_argument("--source", type=Path, default=Path("/opt/hel714-sample/dist/assets/talk/gaze.mp4"))
p.add_argument("--size", type=int, default=512)
p.add_argument("--idle", type=float, required=True, help="seconds of pre-speech idle to calibrate")
p.add_argument("--roi", help="x0,x1,y0,y1 in pixels at --size; default derive from variance")
p.add_argument("--strip", type=Path, help="write a contact strip of the ROI around onset")
p.add_argument("--offset-x", type=int, default=0)
p.add_argument("--offset-y", type=int, default=0)
p.add_argument("--render-size", type=int, default=0, help="crop window size before resize (canvas recordings)")
p.add_argument("--start", type=float, default=0)
p.add_argument("--stop", type=float, default=100000)
p.add_argument("--out", type=Path)
p.add_argument("--idle-until", type=float)
p.add_argument("--motion-threshold", type=float)
p.add_argument("--residual-threshold", type=float)
a = p.parse_args()
frames = []
rs = a.render_size or None
for f in av.open(str(a.video)).decode(video=0):
if not a.start <= float(f.time) <= a.stop:
continue
img = f.to_ndarray(format="bgr24")
if rs:
img = img[a.offset_y : a.offset_y + rs, a.offset_x : a.offset_x + rs]
img = cv2.resize(img, (a.size, a.size))
frames.append((float(f.time), img))
audio = []
for f in av.open(str(a.video)).decode(audio=0):
if not a.start <= float(f.time) <= a.stop:
continue
data = f.to_ndarray().astype(np.float64)
if data.max() > 1:
data /= 32768
audio.append([float(f.time), float(np.sqrt((data**2).mean()))])
stack = np.stack([img for _, img in frames]).astype(np.float32)
std = stack.std(axis=0).mean(axis=2)
if a.roi:
roi = tuple(int(v) for v in map(float, a.roi.split(",")))
print("using explicit mouth ROI (x0,x1,y0,y1):", roi)
else:
region = np.zeros_like(std, dtype=bool)
h = a.size
region[int(h * 0.35) : int(h * 0.80), int(h * 0.25) : int(h * 0.75)] = True
stdm = np.where(region, std, 0)
# smooth, then take the top-percentile blob
k = np.ones((15, 15), np.float32) / 225
stds = cv2.filter2D(stdm, -1, k)
thresh = np.quantile(stds[stds > 0], 0.995)
ys, xs = np.where(stds >= thresh)
x0, x1, y0, y1 = int(xs.min()), int(xs.max()), int(ys.min()), int(ys.max())
pad = 6
roi = (max(0, x0 - pad), min(a.size, x1 + pad), max(0, y0 - pad), min(a.size, y1 + pad))
print("derived mouth ROI (x0,x1,y0,y1):", roi, "fractions:", [v / a.size for v in roi])
# --- detector 1: frame-to-frame motion inside ROI (independent of source board)
motion = []
for i in range(1, len(frames)):
t0, i0 = frames[i - 1]
t1, i1 = frames[i]
m = np.abs(i1[roi[2] : roi[3], roi[0] : roi[1]].astype(np.float32) - i0[roi[2] : roi[3], roi[0] : roi[1]].astype(np.float32)).mean()
motion.append([t1, float(m)])
# --- detector 2: source-matching residual (same as construction analyzer)
source = [cv2.resize(f.to_ndarray(format="bgr24"), (a.size, a.size)) for f in av.open(str(a.source)).decode(video=0)]
mask = np.ones((48, 48), dtype=bool)
mask[0:3] = False
mask[43:] = False
mr = np.zeros((48, 48), dtype=bool)
my0, my1 = int(roi[2] * 48 / a.size), int(roi[3] * 48 / a.size)
mx0, mx1 = int(roi[0] * 48 / a.size), int(roi[1] * 48 / a.size)
mr[my0:my1, mx0:mx1] = True
mask = mask & ~mr
feats = np.asarray([cv2.resize(f, (48, 48))[mask].astype(np.float32) for f in source])
residuals = []
for t, img in frames:
feat = cv2.resize(img, (48, 48))[mask].astype(np.float32)
idx = int(np.argmin(((feats - feat) ** 2).mean(axis=(1, 2))))
res = np.abs(img.astype(np.float32) - source[idx]).mean(axis=2)
residuals.append([t, float(res[roi[2] : roi[3], roi[0] : roi[1]].mean()), idx])
first_audio = next((t for t, r in audio if r > 0.015), None)
def onset(rows, idle_end, floor):
base = [m for t, m, * _ in [tuple(r) for r in rows] if t <= idle_end]
if not base:
return None, None
th = max(floor, float(np.quantile(base, 0.99)) * 1.5)
hit = next((t for t, m, *_ in [tuple(r) for r in rows] if m > th and t > idle_end), None)
return hit, th
idle_end = a.idle_until if a.idle_until is not None else (first_audio - 0.7 if first_audio else a.idle)
lip_motion_onset, motion_th = onset(motion, idle_end, 1.2)
if a.motion_threshold is not None:
motion_th = a.motion_threshold
lip_motion_onset = next((t for t, m in motion if t > idle_end and m > motion_th), None)
lip_residual_onset, residual_th = onset(residuals, idle_end, 4)
if a.residual_threshold is not None:
residual_th = a.residual_threshold
lip_residual_onset = next((t for t, m, _ in residuals if t > idle_end and m > residual_th), None)
result = {
"detector_version": "hel718-v2-mask-fix",
"detector_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
"command": [sys.executable, *sys.argv],
"parameters": {k: str(v) if isinstance(v, Path) else v for k, v in vars(a).items()},
"effective_idle_until": idle_end,
"audio_threshold": 0.015,
"input_sha256": hashlib.sha256(a.video.read_bytes()).hexdigest(),
"source_sha256": hashlib.sha256(a.source.read_bytes()).hexdigest(),
"detection_uncertainty_seconds": {"video": 0.04, "audio": 0.022},
"video": str(a.video),
"frames": len(frames),
"audio_packets": len(audio),
"first_audio": first_audio,
"lip_motion_onset": lip_motion_onset,
"lip_motion_threshold": motion_th,
"motion_delta_seconds": (lip_motion_onset - first_audio) if lip_motion_onset and first_audio else None,
"lip_residual_onset": lip_residual_onset,
"lip_residual_threshold": residual_th,
"residual_delta_seconds": (lip_residual_onset - first_audio) if lip_residual_onset and first_audio else None,
"derived_roi": [int(v) for v in roi],
"roi_fractions": [round(v / a.size, 4) for v in roi],
"note": "delta<0 means lips move before sound; ROI derived from temporal variance, coordinates saved",
}
# Same envelope detector for each window; positive result means lips later.
if motion and audio:
grid = np.arange(max(motion[0][0], audio[0][0]), min(motion[-1][0], audio[-1][0]), 0.02)
m = np.interp(grid, np.asarray(motion)[:, 0], np.asarray(motion)[:, 1])
au = np.interp(grid, np.asarray(audio)[:, 0], np.asarray(audio)[:, 1])
m = np.convolve(m, np.ones(5) / 5, 'same')
au = np.convolve(au, np.ones(5) / 5, 'same')
active = grid[au > 0.02]
if len(active):
selected = (grid >= active.min() - 0.8) & (grid <= active.max() + 0.8)
m, au = m[selected], au[selected]
correlations = []
for shift in range(-40, 41):
x = m[:len(m)-shift or None] if shift >= 0 else m[-shift:]
y = au[shift:] if shift >= 0 else au[:len(au)+shift]
if len(x) > 2 and np.std(x) > 0 and np.std(y) > 0:
correlations.append({"lip_minus_audio_seconds": -shift * 0.02,
"correlation": float(np.corrcoef(x, y)[0, 1])})
result["steady_envelope"] = {"best": max(correlations, key=lambda c: c["correlation"]),
"all_lags": correlations,
"smooth_seconds": 0.1, "active_rms_threshold": 0.02,
"caveat": "motion energy heuristic, not phoneme alignment"}
out = a.out or a.video.with_suffix(".onset.json")
out.write_text(json.dumps(result, indent=2))
if a.strip and first_audio:
# contact strip: frames every 100ms from first_audio-1.2s to +1.6s
times = [first_audio - 1.2 + 0.1 * i for i in range(30)]
crops = []
for tt in times:
best = min(frames, key=lambda fr: abs(fr[0] - tt))[1]
crop = best[roi[2] - 20 : roi[3] + 20, roi[0] - 30 : roi[1] + 30]
crops.append(cv2.resize(crop, (120, 80)))
rows = [np.hstack(crops[i : i + 10]) for i in range(0, 30, 10)]
cv2.imwrite(str(a.strip), np.vstack(rows))
print("strip:", a.strip)
with out.with_suffix(".csv").open("w") as f:
w = csv.writer(f)
w.writerow(["time_seconds", "motion", "lip_residual", "source_index", "audio_rms"])
mmap = {t: m for t, m in motion}
amap = {t: r for t, r in audio}
for t, res, idx in residuals:
near_audio = min(audio, key=lambda x: abs(x[0] - t))[1] if audio else ""
near_motion = min(motion, key=lambda x: abs(x[0] - t))[1] if motion else ""
w.writerow([t, near_motion, res, idx, near_audio])
print(json.dumps(result, indent=2))
-41
View File
@@ -1,41 +0,0 @@
"""Verify a whole versioned pack before building or rolling back.
Material versions and outfit labels are independent of the stable companion id.
This command never writes identity, bazi, memory or relationship state.
"""
import argparse
import hashlib
import json
from pathlib import Path
def verify(root):
root = root.resolve()
manifest = json.loads((root / "manifest.json").read_text())
assert manifest["version"] and manifest["persona_id"]
assert manifest["companion_id"] == "wanwan", "Companion identity cannot change with assets"
files = [manifest["identity_ref"]]
files += [c["file"] for c in manifest["daily_clips"] + manifest["talk_clips"]]
transition = manifest.get("transition", {})
if transition.get("available"):
files += [transition["enter"], transition["return"]]
for name in files:
assert name in manifest["sha256"], f"No checksum: {name}"
for name, checksum in manifest["sha256"].items():
path = (root / name).resolve()
assert path.is_relative_to(root), f"Unsafe asset path: {name}"
assert hashlib.sha256(path.read_bytes()).hexdigest() == checksum, name
return manifest
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"--assets",
type=Path,
default=Path(__file__).resolve().parents[1] / "assets/personas/wanwan-v2-cream-night",
)
args = parser.parse_args()
manifest = verify(args.assets)
print("Verified asset pack:", manifest["version"], len(manifest["sha256"]), "files")
-160
View File
@@ -1,160 +0,0 @@
"""Apply only to a private c726943 code copy, never the reused installation.
The extra endpoint destroys one owned peer rather than reusing partially flushed
queues. The caller must retain the randomly generated session id.
"""
import argparse
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument("copy", type=Path)
args = parser.parse_args()
root = args.copy.resolve()
if root == Path("/opt/hel715-verify/LiveTalking"):
raise SystemExit("Refusing to edit reused P10 installation")
p = root / "server/rtc_manager.py"
s = p.read_text()
needle = " self.pcs.add(pc)"
replacement = needle + "\n pc.hel714_sessionid = sessionid"
if replacement not in s:
assert s.count(needle) >= 1
p.write_text(s.replace(needle, replacement, 1))
p = root / "app.py"
s = p.read_text()
needle = " setup_routes(appasync)"
replacement = (
""" async def hel714_close(request):
params = await request.json()
sid = params['sessionid']
session = session_manager.get_session(sid)
if session:
session.flush_talk()
for peer in list(rtc_manager.pcs):
if getattr(peer, 'hel714_sessionid', None) == sid:
await peer.close()
rtc_manager.pcs.discard(peer)
session_manager.remove_session(sid)
return web.json_response({'closed': sid})
appasync.router.add_post('/hel714/close', hel714_close)
"""
+ needle
)
if replacement not in s:
assert s.count(needle) >= 1
p.write_text(s.replace(needle, replacement, 1))
print("Installed HEL-714 peer retirement endpoint in private copy:", root)
# HEL-718: all edits remain reproducible on a clean pinned driver copy.
def replace_once(path, before, after):
text = path.read_text()
if after in text:
return
assert text.count(before) == 1, f"Unexpected c726943 source: {path}"
path.write_text(text.replace(before, after, 1))
(root / "server/hel718_audio.py").write_text(
Path(__file__).with_name("driver_audio.py").read_text()
)
p = root / "server/webrtc.py"
replace_once(p, "import queue\n", "import queue\nfrom server.hel718_audio import PendingAudio\n")
replace_once(p, "self._queue = queue.Queue(maxsize=100)",
"self._queue = PendingAudio() if kind == 'audio' else queue.Queue(maxsize=100)")
# Preserve the initial common audio/video origin. Once started, pace BEFORE
# selecting audio, so fresh speech cannot wait behind a dequeued idle block.
s = p.read_text()
start = s.find(" # frame = self.frames[self.counter % 30]")
end = s.index(" frame.pts = pts", max(0, start))
replacement = ''' self._player._start(self)
if self.kind == 'audio' and hasattr(self, '_timestamp'):
pts, time_base = await self.next_timestamp()
try:
frame, eventpoint, _ = self._queue.get_nowait()
except queue.Empty:
frame = AudioFrame.from_ndarray(
np.zeros((1, 320), dtype=np.int16), layout='mono', format='s16')
frame.sample_rate = SAMPLE_RATE
eventpoint = None
else:
while self.readyState == 'live':
try:
frame, eventpoint, *_ = self._queue.get_nowait()
break
except queue.Empty:
await asyncio.sleep(0.005)
else:
raise Exception('Track stopped')
pts, time_base = await self.next_timestamp()
'''
if "np.zeros((1, 320), dtype=np.int16), layout='mono'" not in s:
assert start >= 0, "Unexpected c726943 recv implementation"
p.write_text(s[:start] + replacement + s[end:])
replace_once(p, "def push_audio(self, frame, eventpoint=None):",
"def push_audio(self, frame, eventpoint=None, audio_type=0):")
replace_once(p, "self.__audio._queue.put((new_frame, eventpoint))",
"self.__audio._queue.put((new_frame, eventpoint, audio_type))")
# Wall-clock adjustments must not change packet cadence.
s = p.read_text()
start, end = s.index(" async def next_timestamp"), s.index(" async def recv")
s = s[:start] + s[start:end].replace("time.time()", "time.monotonic()") + s[end:]
p.write_text(s)
p = root / "streamout/webrtc.py"
replace_once(p, " def get_buffer_size(self) -> int:", ''' def push_audio_data(self, frame, eventpoint, audio_type):
if self._player:
self._player.push_audio(frame, eventpoint, audio_type)
def get_buffer_size(self) -> int:''')
p = root / "avatars/base_avatar.py"
replace_once(p, " self.output.push_audio_frame(frame, audio_frame.userdata)",
''' if hasattr(self.output, 'push_audio_data'):
self.output.push_audio_data(frame, audio_frame.userdata, audio_frame.type)
else:
self.output.push_audio_frame(frame, audio_frame.userdata)''')
p = root / "avatars/audio_features/base_asr.py"
replace_once(p, " for _ in range(self.stride_left_size):\n self.output_queue.get()",
" for _ in range(self.stride_left_size + self.stride_right_size):\n self.output_queue.get()")
print("Installed HEL-718 bounded synthetic idle, continuous RTP audio, and warm-up drain")
# HEL-720: tag original PCM at ingress; never change type or feature windows.
(root / "avatars/hel720_head.py").write_text(
Path(__file__).with_name("driver_head.py").read_text()
)
p = root / "avatars/audio_features/base_asr.py"
replace_once(p, "from avatars.base_avatar import BaseAvatar,AudioFrameData",
"from avatars.base_avatar import BaseAvatar,AudioFrameData\nfrom avatars.hel720_head import HeadGate")
replace_once(p, " self.parent = parent", " self.parent = parent\n self.hel720_head = HeadGate()")
replace_once(p, " self.queue.queue.clear()",
" self.queue.queue.clear()\n self.hel720_head.reset()")
replace_once(p, " self.queue.put(AudioFrameData(data=audio_chunk,type=0,userdata=datainfo))", ''' frame = AudioFrameData(data=audio_chunk,type=0,userdata=datainfo)
frame.hel720_head_closed = self.hel720_head.classify(frame)
self.queue.put(frame)''')
p = root / "avatars/base_avatar.py"
replace_once(p, "from utils.logger import logger",
"from utils.logger import logger\nfrom avatars.hel720_head import render_speech")
replace_once(p, " if audioframe.type == 0:",
" if render_speech([audioframe]):")
replace_once(p, " self.res_frame_queue.put((res_frame, audio_frames[i*2:i*2+2], mirror_index(length, index)))",
''' pair = audio_frames[i*2:i*2+2]
self.res_frame_queue.put((res_frame if render_speech(pair) else None, pair, mirror_index(length, index)))''')
replace_once(p, " if audio_frames[0].type!=0 and audio_frames[1].type!=0: #全为静音数据,只需要取fullimg",
" if res_frame is None: # synthetic idle OR head-only closed-lip render")
replace_once(p, " self.speaking = False\n audiotype = audio_frames[0].type",
" self.speaking = current_speaking\n audiotype = audio_frames[0].type or 1")
print("Installed HEL-720 per-PCM head-only render flags (candidate RMS 0.001; 0 disables)")
# HEL-722: prepare actual upload + feature + avatar inference before listening.
(root / "avatars/hel722_ready.py").write_text(
Path(__file__).with_name("driver_ready.py").read_text()
)
p = root / "app.py"
replace_once(p, " warm_up(opt.batch_size,model) ", ''' from avatars.hel722_ready import prepare
hel722_ready = prepare(opt, model, global_avatars[opt.avatar_id])''')
replace_once(p, ' appasync["rtc_manager"] = rtc_manager', ''' appasync["rtc_manager"] = rtc_manager
if opt.model == 'musetalk':
async def hel722_readiness(request):
return web.json_response(hel722_ready)
appasync.router.add_get('/hel722/ready', hel722_readiness)''')
print("Installed HEL-722 fail-closed startup preparation; /hel722/ready after success")
-26
View File
@@ -1,26 +0,0 @@
"""Create a private code copy; symlink only existing weights, never overwrite."""
import argparse
import shutil
from pathlib import Path
parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, default=Path("/opt/hel715-verify/LiveTalking"))
parser.add_argument("--destination", type=Path, required=True)
args = parser.parse_args()
source, destination = args.source.resolve(), args.destination.resolve()
assert source != destination and not destination.exists(), "Destination must be new"
def ignore(directory, names):
excluded = {n for n in names if n == "__pycache__" or n.endswith(".log")}
if Path(directory).resolve() == source:
excluded.update({"models", "data", ".git"})
return excluded
shutil.copytree(source, destination, ignore=ignore)
(destination / "models").symlink_to(source / "models", target_is_directory=True)
(destination / "data/avatars").mkdir(parents=True)
print("Private driver code:", destination)
print("Weights reused read-only by convention:", source / "models")
@@ -1,37 +0,0 @@
"""Convert the three generated candidates to 25fps; retain real forward motion."""
import json
from pathlib import Path
import av
import numpy as np
from PIL import Image, ImageDraw
root = Path("/opt/hel714-material")
assets = Path("/opt/hel714-sample/dist/assets")
metrics = {}
sheet = Image.new("RGB", (7 * 220, 3 * 245))
draw = ImageDraw.Draw(sheet)
for row, (kind, name) in enumerate([("idle", "talk"), ("enter", "enter"), ("return", "return")]):
source = next(root.glob(f"{kind}_*.webm"))
frames = [f.to_image() for f in av.open(str(source)).decode(video=0)]
out = av.open(str(assets / f"{name}.mp4"), "w")
stream = out.add_stream("libx264", rate=25)
stream.width = stream.height = 512
stream.pix_fmt = "yuv420p"
stream.options = {"crf": "18", "preset": "fast"}
count = round(len(frames) * 25 / 16)
for i in range(count):
frame = av.VideoFrame.from_image(frames[min(len(frames) - 1, int(i * 16 / 25))])
for packet in stream.encode(frame):
out.mux(packet)
for packet in stream.encode():
out.mux(packet)
out.close()
for column, index in enumerate(np.linspace(0, len(frames) - 1, 7).astype(int)):
sheet.paste(frames[index].resize((220, 220)), (column * 220, row * 245))
draw.text((column * 220, row * 245 + 222), f"{kind} f{index}", fill="white")
metrics[kind] = {"input_frames": len(frames), "output_frames": count, "seconds": count / 25}
sheet.save(root / "candidate-contact.png")
(root / "candidate-metrics.json").write_text(json.dumps(metrics, indent=2))
print(metrics)
-149
View File
@@ -1,149 +0,0 @@
#!/usr/bin/env python
"""HEL-717 diagnostic: record the driver's own WebRTC output with TRUE RTP pts.
No browser involved. If the lips-early offset appears here, it originates
upstream (feature lookahead / frame association); if not, it is introduced by
the browser playback/recording graph.
"""
import argparse
import asyncio
import hashlib
import json
import sys
import time
from fractions import Fraction
from pathlib import Path
import aiohttp
import av
import numpy as np
from aiortc import RTCPeerConnection, RTCSessionDescription
p = argparse.ArgumentParser()
p.add_argument("--base", default="http://127.0.0.1:8010")
p.add_argument("--wav", required=True)
p.add_argument("--out", required=True)
p.add_argument("--post", type=float, default=9.0)
p.add_argument("--rounds", default="0,15,1,1", help="Idle seconds before each full utterance; then interrupt and restart")
a = p.parse_args()
events = []
def log(name, **kw):
row = {"t": time.time(), "rel": time.perf_counter(), "event": name, **kw}
events.append(row)
print(json.dumps({k: v for k, v in row.items() if k != "t"}), flush=True)
async def main():
pc = RTCPeerConnection()
container = av.open(a.out, "w")
vstream = container.add_stream("libx264", rate=25)
vstream.time_base = Fraction(1, 90000)
vstream.pix_fmt = "yuv420p"
vstream.options = {"crf": "18", "preset": "veryfast"}
astream = container.add_stream("aac", rate=48000)
astream.time_base = Fraction(1, 48000)
astream.layout = "mono"
stats = {"v": 0, "a": 0}
timeline = []
def video_track(track):
async def loop():
while True:
try:
frame = await track.recv()
except Exception:
break
stats["v"] += 1
timeline.append({"kind": "v", "pts": frame.pts, "tb": str(frame.time_base), "wall": time.perf_counter()})
img = frame.to_ndarray(format="bgr24")
out = av.VideoFrame.from_ndarray(img, format="bgr24")
out.pts = frame.pts
out.time_base = frame.time_base
for pkt in vstream.encode(out):
container.mux(pkt)
return asyncio.ensure_future(loop())
def audio_track(track):
async def loop():
while True:
try:
frame = await track.recv()
except Exception:
break
stats["a"] += 1
data = frame.to_ndarray()
# aiortc delivers s16 mono 48k planar [1, n]
pcm = data.reshape(-1).astype(np.int16)
timeline.append({"kind": "a", "pts": frame.pts, "tb": str(frame.time_base), "wall": time.perf_counter(),
"rms": float(np.sqrt((pcm.astype(np.float64) / 32768.0) ** 2).mean())})
out = av.AudioFrame.from_ndarray(pcm.reshape(1, -1), format="s16", layout="mono")
out.sample_rate = frame.sample_rate
out.pts = frame.pts
out.time_base = frame.time_base
for pkt in astream.encode(out):
container.mux(pkt)
return asyncio.ensure_future(loop())
@pc.on("track")
def on_track(track):
log("track", kind=track.kind)
if track.kind == "video":
video_track(track)
else:
audio_track(track)
pc.addTransceiver("video", direction="recvonly")
pc.addTransceiver("audio", direction="recvonly")
offer = await pc.createOffer()
await pc.setLocalDescription(offer)
for _ in range(100):
if pc.iceGatheringState == "complete":
break
await asyncio.sleep(0.05)
async with aiohttp.ClientSession() as http:
async with http.post(a.base + "/offer", json={"sdp": pc.localDescription.sdp, "type": "offer"}) as r:
answer = await r.json()
sid = answer["sessionid"]
log("offer-ok", sid=sid)
await pc.setRemoteDescription(RTCSessionDescription(sdp=answer["sdp"], type=answer["type"]))
for _ in range(200):
if stats["v"]:
break
await asyncio.sleep(0.05)
log("first-video", v=stats["v"], a=stats["a"])
wav_bytes = Path(a.wav).read_bytes()
async def send(label):
log("send", label=label, v=stats["v"], a=stats["a"])
form = aiohttp.FormData()
form.add_field("sessionid", sid)
form.add_field("file", wav_bytes, filename="utterance.wav", content_type="audio/wav")
async with http.post(a.base + "/humanaudio", data=form) as r:
log("humanaudio-ok", label=label, body=await r.json())
for index, idle in enumerate(map(float, a.rounds.split(","))):
await asyncio.sleep(idle)
await send(f"full-{index+1}-idle-{idle}")
await asyncio.sleep(a.post)
await send("interrupted")
await asyncio.sleep(2)
async with http.post(a.base + "/interrupt_talk", json={"sessionid": sid}) as r:
log("interrupt", body=await r.json())
await asyncio.sleep(1)
await send("restart-same-peer-diagnostic")
await asyncio.sleep(a.post)
await pc.close()
async with http.post(a.base + "/hel714/close", json={"sessionid": sid}) as r:
log("closed", body=await r.json())
for pkt in vstream.encode():
container.mux(pkt)
for pkt in astream.encode():
container.mux(pkt)
container.close()
Path(a.out + ".timeline.json").write_text(json.dumps({"command": [sys.executable, *sys.argv], "wav_sha256": hashlib.sha256(Path(a.wav).read_bytes()).hexdigest(), "events": events, "timeline": timeline}))
log("done", v=stats["v"], a=stats["a"])
asyncio.run(main())
-89
View File
@@ -1,89 +0,0 @@
<!doctype html>
<html>
<head><meta charset="utf-8"><title>HEL-717 remote-stream graph</title></head>
<body>
<video id="talk" muted playsinline autoplay></video>
<canvas id="cv" width="960" height="720"></canvas>
<script>
// Replicates the product playback graph exactly:
// remote WebRTC stream -> <video> (muted, drawn to canvas via rAF)
// -> MediaStreamSource -> context.destination + MediaStreamDestination
// MediaRecorder over canvas.captureStream(25) + destination track (zero clock warm).
const events = [];
function mark(name, detail={}) { events.push({at: performance.now(), event: name, ...detail}); }
const talk = document.getElementById('talk');
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
function draw() {
ctx.fillStyle = '#101010'; ctx.fillRect(0, 0, 960, 720);
if (talk.readyState >= 2) {
const s = Math.min(960 / talk.videoWidth, 640 / talk.videoHeight);
ctx.drawImage(talk, (960 - talk.videoWidth * s) / 2, 0, talk.videoWidth * s, talk.videoHeight * s);
}
ctx.fillStyle = '#fff'; ctx.font = '18px sans-serif';
ctx.fillText(`${(performance.now() / 1000).toFixed(3)}s · remote-stream graph`, 12, 705);
requestAnimationFrame(draw);
}
draw();
window.diagRun = async function () {
const actx = new AudioContext();
const dest = actx.createMediaStreamDestination();
const analyser = actx.createAnalyser();
const clock = actx.createConstantSource(); clock.offset.value = 0;
clock.connect(dest); clock.connect(actx.destination); clock.start();
const stream = new MediaStream();
talk.srcObject = stream;
const pc = new RTCPeerConnection({iceServers: []});
pc.ontrack = e => stream.addTrack(e.track);
pc.addTransceiver('audio', {direction: 'recvonly'});
pc.addTransceiver('video', {direction: 'recvonly'});
await pc.setLocalDescription(await pc.createOffer());
await new Promise((res, rej) => {
const to = setTimeout(() => rej(new Error('ice timeout')), 8000);
const check = () => { if (pc.iceGatheringState === 'complete') { clearTimeout(to); res(); } };
pc.addEventListener('icegatheringstatechange', check); check();
});
mark('offer-sent');
const answer = await (await fetch('/lt/offer', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(pc.localDescription)})).json();
await pc.setRemoteDescription(answer);
await talk.play();
await new Promise((res, rej) => {
const to = setTimeout(() => rej(new Error('media timeout')), 10000);
const check = () => { if (talk.readyState >= 2) { clearTimeout(to); res(); } };
talk.addEventListener('loadeddata', check, {once: true}); check();
});
mark('media-ready');
const src = actx.createMediaStreamSource(stream);
src.connect(actx.destination); src.connect(dest); src.connect(analyser);
// recorder
const recStream = cv.captureStream(25);
dest.stream.getAudioTracks().forEach(t => recStream.addTrack(t));
const chunks = [];
const rec = new MediaRecorder(recStream, {mimeType: 'video/webm;codecs=vp8,opus'});
rec.ondataavailable = e => chunks.push(e.data);
const stopped = new Promise(r => rec.onstop = r);
rec.start(1000);
mark('recorder-start');
const wav = await (await fetch('/fixed.wav')).arrayBuffer();
for (const [index, idle] of [0, 15000, 1000, 1000].entries()) {
await new Promise(r => setTimeout(r, idle));
const form = new FormData();
form.append('sessionid', answer.sessionid);
form.append('file', new Blob([wav]), 'utterance.wav');
mark('humanaudio-post', {round: index + 1, idle});
const rr = await fetch('/lt/humanaudio', {method: 'POST', body: form});
mark('humanaudio-ack', {status: rr.status});
await new Promise(r => setTimeout(r, 9000));
}
rec.stop(); await stopped;
recStream.getVideoTracks().forEach(t => t.stop());
await pc.close();
await fetch('/lt/hel714/close', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({sessionid:answer.sessionid})});
await actx.close();
const blob = new Blob(chunks, {type: 'video/webm'});
window.diagResult = {blobUrl: URL.createObjectURL(blob), events};
document.title = 'done';
};
</script>
</body>
</html>
-32
View File
@@ -1,32 +0,0 @@
import asyncio
import json
import sys
from playwright.async_api import async_playwright
OUT = sys.argv[1] if len(sys.argv) > 1 else "."
async def main():
async with async_playwright() as pw:
browser = await pw.chromium.launch(
headless=True,
args=["--autoplay-policy=no-user-gesture-required", "--no-sandbox"],
)
page = await browser.new_page(viewport={"width": 1280, "height": 800})
await page.goto("http://192.168.200.12:8933/")
await page.evaluate("diagRun()")
await page.wait_for_function("document.title === 'done'", timeout=180000)
from pathlib import Path
out = Path(OUT)
out.mkdir(parents=True, exist_ok=True)
events = await page.evaluate("window.diagResult.events")
async with page.expect_download() as pending:
await page.evaluate("""() => {const a=document.createElement('a'); a.href=window.diagResult.blobUrl;a.download='graph.webm';a.click()}""")
await (await pending.value).save_as(out / 'graph-after.webm')
(out / 'graph-after.events.json').write_text(json.dumps(events, indent=2))
print('saved graph audio/video', flush=True)
await browser.close()
asyncio.run(main())
-59
View File
@@ -1,59 +0,0 @@
"""Continuous real product recording: three daily loops, talk, cancel, return."""
import asyncio
import json
import pathlib
import sys
from playwright.async_api import async_playwright
OUT = pathlib.Path(sys.argv[1])
OUT.mkdir(parents=True, exist_ok=True)
async def main():
async with async_playwright() as pw:
browser = await pw.chromium.launch(args=['--no-sandbox', '--autoplay-policy=no-user-gesture-required'])
context = await browser.new_context(ignore_https_errors=True, viewport={'width': 1280, 'height': 800})
page = await context.new_page()
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
await page.goto('https://192.168.200.12:8014')
await page.wait_for_function("document.querySelector('#status').textContent.includes('就绪')")
await page.click('#record')
duration = await page.locator('#daily').evaluate('(v)=>v.duration')
await page.wait_for_timeout((duration * 3 + 1) * 1000)
await page.click('#enter')
await page.wait_for_function("document.querySelector('#status').textContent.includes('正在听')", timeout=40000)
await page.fill('#text', '请原样念这一句,不添加内容:八百标兵奔北坡,炮兵并排北边跑。宝宝抱抱爸爸,妈妈慢慢说。')
await page.click('button[type=submit]')
await page.wait_for_function("window.sampleEvents.some(e=>e.event==='first-audible')", timeout=60000)
await page.wait_for_timeout(8000)
# Actual second turn is cancelled while speaking.
await page.fill('#text', '请再念一遍:八百标兵奔北坡,炮兵并排北边跑。宝宝抱抱爸爸,妈妈慢慢说。')
await page.click('button[type=submit]')
await page.wait_for_function("window.sampleEvents.filter(e=>e.event==='first-audible').length>=2", timeout=60000)
await page.wait_for_timeout(1200)
await page.click('#cancel')
await page.wait_for_timeout(1200)
await page.click('#back')
await page.wait_for_function("document.querySelector('#status').textContent.includes('已回到阅读')", timeout=20000)
await page.wait_for_timeout(6000)
assert not await page.locator('#daily').evaluate('(v)=>v.paused')
await page.click('#record')
await page.wait_for_selector('#download:not([hidden])')
async with page.expect_download() as pending:
await page.click('#download')
await (await pending.value).save_as(OUT / 'product-continuous.webm')
(OUT / 'product-continuous.json').write_text(json.dumps({
'command': [sys.executable, *sys.argv], 'daily_duration': duration,
'daily_recording_seconds_before_enter': duration * 3 + 1,
'events': await page.evaluate('window.sampleEvents'), 'pageerrors': errors,
'final_status': await page.locator('#status').inner_text(),
'human_review': 'pending: seams, cuts, warmth, book/hands/cushion; real barge-in, phone, OS background untested',
}, ensure_ascii=False, indent=2))
await browser.close()
print('Saved continuous product audio/video', flush=True)
asyncio.run(main())
-85
View File
@@ -1,85 +0,0 @@
"""Ten-minute real browser / local model session, sampled resource evidence."""
import asyncio
import json
import pathlib
import subprocess
import time
from playwright.async_api import async_playwright
OUT = pathlib.Path(__file__).resolve().parents[1] / "evidence"
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(
args=["--no-sandbox", "--autoplay-policy=no-user-gesture-required"]
)
context = await browser.new_context(ignore_https_errors=True)
page = await context.new_page()
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
await page.goto("https://192.168.200.12:8014")
await page.wait_for_function(
"document.querySelector('#status').textContent.includes('就绪')"
)
await page.click("#enter")
await page.wait_for_function(
"document.querySelector('#status').textContent.includes('正在听')", timeout=40000
)
start = time.monotonic()
samples = []
for index in range(15):
text = f"这是第{index + 1}次交谈。请只回复一句不超过十五个字的自然中文。"
await page.fill("#text", text)
await page.click("button[type=submit]")
for _tick in range(4):
await page.wait_for_timeout(10000)
state = await page.evaluate("""()=>({status:document.querySelector('#status').textContent,
videoTime:document.querySelector('#talk').currentTime,
frames:document.querySelector('#talk').getVideoPlaybackQuality().totalVideoFrames,
events:window.sampleEvents.slice(-3)})""")
gpu = subprocess.run(
[
"ssh",
"root@192.168.200.12",
"nvidia-smi --query-gpu=memory.used,utilization.gpu --format=csv,noheader,nounits",
],
capture_output=True,
text=True,
check=True,
).stdout.strip()
sample = dict(
elapsed=round(time.monotonic() - start, 2), turn=index + 1, state=state, gpu=gpu
)
samples.append(sample)
(OUT / "soak-progress.json").write_text(
json.dumps(samples, ensure_ascii=False, indent=2)
)
print(
"soak",
index + 1,
"elapsed",
round(time.monotonic() - start),
"status",
state["status"],
"gpu",
gpu,
flush=True,
)
result = dict(
duration_seconds=round(time.monotonic() - start, 2),
samples=samples,
events=await page.evaluate("window.sampleEvents"),
pageerrors=errors,
scope="One logical session, peer retired each turn by cancellation design; 15 real LLM/TTS/avatar turns",
)
(OUT / "soak.json").write_text(json.dumps(result, ensure_ascii=False, indent=2))
await page.click("#back")
await page.wait_for_timeout(4000)
await browser.close()
print("SOAK COMPLETE", result["duration_seconds"], len(errors), flush=True)
asyncio.run(main())
-24
View File
@@ -1,24 +0,0 @@
"""Mount the verified active persona pack at the sample's /assets URL.
public/assets is generated build output. Canonical user-replaceable packs live
under assets/personas; no identity or companion-state file is touched.
"""
import json
import shutil
from pathlib import Path
from pack_assets import verify
root = Path(__file__).resolve().parents[1]
active = json.loads((root / "assets/active.json").read_text())
source = (root / "assets/personas" / active["persona_id"]).resolve()
assert source.is_relative_to((root / "assets/personas").resolve())
manifest = verify(source)
assert manifest["persona_id"] == active["persona_id"]
destination = root / "public/assets"
assert not destination.is_symlink(), "Generated asset mount must not be a symlink"
if destination.exists():
shutil.rmtree(destination)
shutil.copytree(source, destination)
print("Staged complete asset pack:", manifest["persona_id"], manifest["version"])
-40
View File
@@ -1,40 +0,0 @@
"""Optional test-only trace wrapper; pass app.py arguments after the trace path."""
import json
import runpy
import sys
import time
import numpy as np
from server import webrtc
out = open(sys.argv.pop(1), 'w', buffering=1) # noqa: SIM115 -- process-lifetime trace
def write(event, player, **fields):
out.write(json.dumps({'wall': time.monotonic(), 'event': event, 'player': id(player), **fields}) + '\n')
push = webrtc.HumanPlayer.push_audio
recv = webrtc.PlayerStreamTrack.recv
def push_audio(self, frame, eventpoint=None, audio_type=0):
push(self, frame, eventpoint, audio_type)
q = self.audio._queue
write('push', self, kind=audio_type, rms=float(np.sqrt(np.mean((frame.astype(float) / 32768) ** 2))),
depth=q.qsize(), dropped=q.dropped_silence, high_water=q.high_water)
async def receive(self):
frame = await recv(self)
fields = {'kind': self.kind, 'pts': frame.pts, 'tb': str(frame.time_base), 'depth': self._queue.qsize()}
if self.kind == 'audio':
fields['rms'] = float(np.sqrt(np.mean((frame.to_ndarray().astype(float) / 32768) ** 2)))
write('recv', self._player, **fields)
return frame
webrtc.HumanPlayer.push_audio = push_audio
webrtc.PlayerStreamTrack.recv = receive
runpy.run_path('app.py', run_name='__main__')
-100
View File
@@ -1,100 +0,0 @@
"""Test-only trace: original PCM -> batched features -> rendered pairs -> RTP."""
import hashlib
import json
import runpy
import sys
import time
import numpy as np
from avatars.base_avatar import BaseAvatar
from avatars.hel720_head import HeadGate
from server import webrtc
out = open(sys.argv.pop(1), 'w', buffering=1) # noqa: SIM115
def digest(data):
return hashlib.sha256(data.tobytes()).hexdigest()
def write(event, **fields):
out.write(json.dumps({'wall': time.monotonic(), 'event': event, **fields}) + '\n')
classify = HeadGate.classify
init = BaseAvatar.__init__
push = webrtc.HumanPlayer.push_audio
recv = webrtc.PlayerStreamTrack.recv
player_init = webrtc.HumanPlayer.__init__
def init_player(self, avatar_session, *args, **kw):
player_init(self, avatar_session, *args, **kw)
put = self.audio._queue.put
def tagged_put(item, *args, **kw):
frame, eventpoint, kind = item
keys = getattr(avatar_session, 'hel722_trace_pair', [])
cursor = getattr(avatar_session, 'hel722_trace_cursor', 0)
frame.opaque = {'key': keys[cursor] if cursor < len(keys) else None,
'origin_kind': kind, 'status': eventpoint,
'sid': avatar_session.sessionid}
avatar_session.hel722_trace_cursor = cursor + 1
return put(item, *args, **kw)
self.audio._queue.put = tagged_put
def classify_trace(self, frame):
closed = classify(self, frame)
seq = getattr(self, 'trace_seq', 0)
self.trace_seq = seq + 1
frame.hel720_trace = [id(self), seq]
write('pcm', key=frame.hel720_trace, samples=[seq*320, (seq+1)*320],
rms=float(np.sqrt(np.mean(frame.data.astype(float)**2))), closed=closed,
threshold=self.threshold, status=frame.userdata, kind=frame.type,
float_sha256=digest(frame.data), pcm_sha256=digest((frame.data*32767).astype(np.int16)))
return closed
def init_trace(self, opt):
init(self, opt)
get = self.res_frame_queue.get
def render_get(*args, **kw):
result = get(*args, **kw)
image, frames, index = result
self.hel722_trace_pair = [getattr(f, 'hel720_trace', None) for f in frames]
self.hel722_trace_cursor = 0
write('render', sid=self.sessionid, index=index, closed=image is None,
keys=[getattr(f, 'hel720_trace', None) for f in frames],
flags=[getattr(f, 'hel720_head_closed', False) for f in frames],
kinds=[f.type for f in frames])
return result
self.res_frame_queue.get = render_get
def push_audio(self, frame, eventpoint=None, audio_type=0):
push(self, frame, eventpoint, audio_type)
q = self.audio._queue
write('push', player=id(self), kind=audio_type, pcm_sha256=digest(frame), status=eventpoint,
rms=float(np.sqrt(np.mean((frame.astype(float)/32768)**2))),
depth=q.qsize(), dropped=q.dropped_silence, high_water=q.high_water)
async def receive(self):
frame = await recv(self)
fields = {'player': id(self._player), 'kind': self.kind, 'pts': frame.pts,
'tb': str(frame.time_base), 'depth': self._queue.qsize()}
if self.kind == 'audio':
fields['origin'] = frame.opaque
fields['pcm_sha256'] = digest(frame.to_ndarray())
fields['rms'] = float(np.sqrt(np.mean((frame.to_ndarray().astype(float)/32768)**2)))
write('recv', **fields)
return frame
webrtc.HumanPlayer.__init__ = init_player
HeadGate.classify = classify_trace
BaseAvatar.__init__ = init_trace
webrtc.HumanPlayer.push_audio = push_audio
webrtc.PlayerStreamTrack.recv = receive
runpy.run_path('app.py', run_name='__main__')
-20
View File
@@ -1,20 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": [
"src"
]
}
@@ -1,51 +0,0 @@
{
"companion_id": "wanwan",
"persona_id": "wanwan-v2-cream-night",
"version": "hel714-v1",
"previous_version": null,
"identity_ref": "identity.png",
"created_by": "HEL-714 / P10 reused assets",
"created_at": "2026-10-20",
"pipeline_version": "hel715-v1",
"outfit": "cream",
"light": "warm-bedroom-night",
"weather_source": "manual-test",
"default_daily": "reading-rain-night",
"daily_clips": [
{
"id": "reading-rain-night",
"file": "daily.mp4",
"timeofday": "night",
"weather": "rain",
"activity": "reading",
"loop_points": [
0,
195
],
"loop_review": "pending-human-review"
}
],
"talk_clips": [
{
"id": "close",
"file": "talk.mp4",
"avatar_id": "hel715-talk-close",
"framing": "1.75x reading crop",
"approved": false,
"gaze_review": "not a newly generated eye-contact plate"
}
],
"transition": {
"file": "return.mp4",
"approved": false,
"cut_points": [],
"reason": "independent source; continuity not accepted"
},
"manual_consistency_review": "same P10 source; not an accepted natural transition",
"sha256": {
"identity.png": "e40b72c44611cc6f0a9651ad24cc4b0985e2bb8efa19cba963d515a7a6cf4c8f",
"daily.mp4": "776801d38db4a4c3c0afa7488b380987fd9541c699dcfa6e8b26aa492d8255cf",
"talk.mp4": "daf5af9e08efc8580338064b4095e7451e6df531697d4400b49e7e481b1ba62a",
"return.mp4": "f86541a4d141b05c4b2fa3270e22d3d086d5fc128fe0524f385e140455c60875"
}
}
+141
View File
@@ -0,0 +1,141 @@
"""Check requirement provenance and handoff completeness; no service or network access."""
import argparse
import hashlib
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DOCS = ROOT / 'docs/requirements'
def read(path):
return json.loads(path.read_text(encoding='utf-8'))
def check(handoff=None, base_ref=None):
errors = []
baseline = read(DOCS / 'baseline.json')
rows = baseline['requirements']
ids = [r['id'] for r in rows]
known = set(ids)
evidence = {}
for path in sorted((DOCS / 'sources').glob('comment-evidence*.json')):
for comment in read(path):
old = evidence.get(comment['id'])
if old and old != comment:
errors.append(f'同一来源评论出现不同记录:{comment["id"]}')
evidence[comment['id']] = comment
if len(ids) != len(known):
errors.append('重复的需求编号')
text = (DOCS / 'current.md').read_text(encoding='utf-8')
headings = re.findall(r'^## ((?:CG|VIS|AM|UX|GOV|DEL)-\d{3}) · ', text, re.M)
if headings != ids:
errors.append('完整正文与结构化需求编号不一致')
for row in rows:
key = row['id']
if not re.fullmatch(r'(?:CG|VIS|AM|UX|GOV|DEL)-\d{3}', key):
errors.append(f'非法编号:{key}')
if not row.get('topic') or not row.get('conclusion') or not row.get('source_comment_ids'):
errors.append(f'缺少要求或来源:{key}')
if row.get('conclusion','') not in text or row.get('boundary','') not in text:
errors.append(f'完整正文缺少要求原意或边界:{key}')
for source in row.get('source_comment_ids', []):
if source not in evidence or source not in text:
errors.append(f'来源缺失:{key}/{source}')
if row.get('source_document') and not (DOCS / row['source_document']).is_file():
errors.append(f'来源文档缺失:{key}')
for name, expected in read(DOCS / 'source-manifest.json').items():
path = DOCS / 'sources' / name
if path.parent != DOCS / 'sources' or not path.is_file():
errors.append(f'来源文件缺失或路径异常:{name}')
elif hashlib.sha256(path.read_bytes()).hexdigest() != expected:
errors.append(f'历史来源已改变:{name}')
coverage = read(DOCS / 'coverage.json')
coverage_ids = [r['requirement_id'] for r in coverage['items']]
if coverage.get('baseline_version') != baseline['version']:
errors.append('对照表版本过期')
if len(coverage_ids) != len(set(coverage_ids)) or set(coverage_ids) != known:
errors.append('对照表遗漏、重复或包含不存在的要求')
for row in coverage['items']:
if not row.get('status') or not row.get('next_step') or not isinstance(row.get('evidence'), list):
errors.append(f'对照项缺少状态、证据栏或承接:{row.get("requirement_id")}')
changes = read(DOCS / 'changes.json')['entries']
if not changes or changes[-1].get('version') != baseline['version']:
errors.append('当前版本没有变更记录')
for change in changes:
source = evidence.get(change.get('approval_comment_id'), {})
if source.get('author_type') != 'member' or source.get('author_id') != baseline['owner_id']:
errors.append('需求变更未关联白栖知的原始评论')
if change.get('approved_by') != baseline['owner_id'] or not change.get('summary') or not change.get('impact'):
errors.append('变更记录缺少批准者、说明或影响')
if not change.get('affected_ids') or not set(change['affected_ids']) <= known:
errors.append('变更影响编号缺失或无效')
if base_ref:
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_./-]*', base_ref):
errors.append('非法比较引用')
else:
valid=subprocess.run(['git','rev-parse','--verify',base_ref+'^{commit}'],cwd=ROOT,capture_output=True)
if valid.returncode:
errors.append('比较引用不存在;不能跳过版本检查')
else:
def previous(name):
result=subprocess.run(['git','show',f'{base_ref}:docs/requirements/{name}'],cwd=ROOT,capture_output=True,text=True)
return json.loads(result.stdout) if not result.returncode else None
old=previous('baseline.json')
if old:
old_changes=previous('changes.json')['entries']
if changes[:len(old_changes)] != old_changes:
errors.append('历史变更记录不能改写或删除')
previous_manifest=previous('source-manifest.json')
current_manifest=read(DOCS / 'source-manifest.json')
if any(current_manifest.get(k)!=v for k,v in previous_manifest.items()):
errors.append('历史来源必须保留;后续来源以新文件追加')
if baseline != old:
if baseline['version'] == old['version']:
errors.append('需求有变但版本未更新')
if len(changes)<=len(old_changes):
errors.append('需求有变但没有追加用户授权记录')
old_approvals={c['approval_comment_id'] for c in old_changes}
if changes[-1]['approval_comment_id'] in old_approvals:
errors.append('新需求变更不能重复使用旧批准评论')
if handoff:
data = read(Path(handoff))
for key in ['baseline_version','read_commit','requirement_ids','deliverable','acceptance','deferred','requirement_changes','verification']:
if key not in data:
errors.append(f'交接缺少:{key}')
if data.get('baseline_version') != baseline['version']:
errors.append('交接使用过期基准')
if not re.fullmatch(r'[a-f0-9]{40}', data.get('read_commit','')):
errors.append('交接未记录实际仓库提交')
refs=data.get('requirement_ids', [])
if not refs or not set(refs) <= known or len(refs) != len(set(refs)):
errors.append('交接要求编号缺失、重复或无效')
for key in ['acceptance','deferred','requirement_changes','verification']:
if not isinstance(data.get(key), list):
errors.append(f'交接字段应为列表:{key}')
if not data.get('deliverable') or not data.get('acceptance') or not data.get('verification'):
errors.append('交接缺少交付、验收或验证说明')
for change in data.get('requirement_changes', []):
source = evidence.get(change.get('approval_comment_id'), {})
if source.get('author_type') != 'member' or source.get('author_id') != baseline['owner_id']:
errors.append('交接需求变更缺少用户来源;先更新正式变更记录')
return errors, len(ids)
if __name__ == '__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--handoff', help='JSON交接文件')
parser.add_argument('--base-ref', help='与已获取的目标分支比较版本及历史来源')
args=parser.parse_args()
try:
errors, count=check(args.handoff, args.base_ref)
except (KeyError, TypeError, ValueError, OSError) as exc:
print(f'需求材料无法验证:{exc}', file=sys.stderr)
sys.exit(1)
if errors:
print('\n'.join(errors),file=sys.stderr)
sys.exit(1)
print(f'需求结构检查通过:{count}项;来源和覆盖完整。仍须人工核对授权语义与实际体验。')
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
.vite/
+35
View File
@@ -0,0 +1,35 @@
CyberGril S1 前端(HEL-748)
独立 React / TypeScript / Vite 工程。Python 核心保持原样;不是新的后端。视觉基于获批 HEL-746 R2 的 Token、组件样式和两张参考静帧,页面由真实 React 组件实现,没有嵌入原型 HTML。
```bash
cd web
npm ci
npm run dev # 仅本机,终端给出 URL
# 普通入口 / 说明真实服务未接通;点入口或访问 /?demo=1
npm run typecheck
npm run lint
npm test
npm run build
npm run preview # 仅本机预览生产构建
```
Node 22+ 推荐(本轮 Node 24.16.0 / npm 11.13.0 验证),锁文件已提交。不需要 API Key、模型或 GPU。没有运行时 CDN 请求。
首次进入演示需填写名字、生辰、关系并确认。创建保存在浏览器 `cybergril.demo.v1`;刷新保留同一位档案。没有切换或清空重建入口。`cg.theme` 保留主题,`sessionStorage` 的 `cg.draft` 保留交流草稿。资料只在当前浏览器/站点 origin;它不代表服务器已创建、记忆已入库或后台仍在生活。保存不可用时会报错,不伪装成功。损坏资料会显示恢复说明,不自动覆盖。
演示场景入口可载入固定的三天/长记录、模拟离开返回、主动消息、断线和操作失败。只有明确 `?demo=1` 才有该入口,不能切换隐私规则。首建后生活默认空白;固定样例须主动载入,不自行编造离线生活。
本地演示 JSON 可导出、恢复同一位档案;恢复需输入“恢复”,先下载恢复前数据。它不含真实数据库和媒体文件,不能称为后台完整备份。素材导入只校验演示 JSON 的结构与标识,媒体格式/时长/人物一致性均未检测,清单中的 URL 不会加载。可用示例在 `public/demo-material.json`。
浏览器测试:先构建,再在已安装 Python Playwright 和 Chromium 及其依赖的环境执行:
```bash
python3 web/tests/browser_check.py # 在仓库根目录
python3 web/tests/import_check.py
python3 -m pytest -q # 现有 Python 核心,仓库根目录
```
脚本在随机 loopback 端口同步测试静态产物,结束关闭浏览器和服务;截图写入 `docs/frontend/screenshots/`(不进入前端产物)。没有持续预览服务或部署。真实手机、Safari、真实麦克风/摄像头、ASR/TTS、后台生活和口型均未测试。
接线边界、交付与测试记录见 `docs/frontend/IMPLEMENTATION.md`。
+5
View File
@@ -0,0 +1,5 @@
import js from '@eslint/js';
import ts from 'typescript-eslint';
import hooks from 'eslint-plugin-react-hooks';
import globals from 'globals';
export default ts.config(js.configs.recommended, ...ts.configs.recommended, {files:['src/**/*.{ts,tsx}'],languageOptions:{globals:globals.browser},plugins:{'react-hooks':hooks},rules:{...hooks.configs.recommended.rules}});
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN" data-theme="moon"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark"><title>CyberGril · 一扇亮着的窗</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
+3328
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
{
"name": "cybergril-web", "version": "0.1.0", "private": true, "type": "module",
"scripts": {"dev": "vite --host 127.0.0.1", "build": "tsc --noEmit && vite build", "typecheck": "tsc --noEmit", "lint": "eslint src", "test": "vitest run", "preview": "vite preview --host 127.0.0.1"},
"dependencies": {"react": "18.3.1", "react-dom": "18.3.1"},
"devDependencies": {"@types/react": "18.3.20", "@types/react-dom": "18.3.6", "typescript": "5.8.3", "vite": "6.3.6", "vitest": "3.1.2", "eslint": "9.25.1", "@eslint/js": "9.25.1", "typescript-eslint": "8.31.1", "eslint-plugin-react-hooks": "5.2.0", "globals": "16.0.0"}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Some files were not shown because too many files have changed in this diff Show More