Compare commits

..
Author SHA1 Message Date
总工andmultica-agent 0b804a49ef HEL-905: clarify verified baseline vs registration commits in milestone table
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:41:25 +08:00
总工andmultica-agent fdfa3424b7 HEL-905: register final verified head 03cec26 in follow-up package milestone table
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:41:12 +08:00
总工andmultica-agent 03cec2679e HEL-905: unified review handoff + follow-up package honesty fixes (first-line blocker, register_asr scope, ledger jq boundary, mic-interrupt T0 time base)
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:40:57 +08:00
总工 7cb73429f7 HEL-905: integrate HEL-904 backend rework (recoverable register-failure mapping, call-window voice pin) 2026-10-28 01:14:35 +08:00
总工 7c387db0a0 HEL-905: integrate HEL-903 preserved frontend rework (author source 51669aa, 11 files byte-preserved) 2026-10-28 01:14:32 +08:00
总管andmultica-agent 088ac621dc HEL-903: retain author log bytes without newline conversion
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:11:54 +08:00
总管andmultica-agent 3efecced3f HEL-903: record exact source preservation and four requirement checks
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:11:41 +08:00
总管andmultica-agent 51669aae6d HEL-903: preserve visual author voice status, call voice and recovery fixes
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:10:48 +08:00
施工员2号andmultica-agent 07dda2bfa2 HEL-904: backend rework — recoverable register-failure mapping, worker-state corruption docstring, call-window voice pin closes capture/worker race
- register(): local metadata write failure -> LocalRecordFailed -> 503 Chinese
  message with the cg- id (shared registration NOT rolled back, refresh list to
  recover, never re-register); register_asr journaled BEFORE local persist so
  failed registrations still count in the resource ledger
- write_worker_voice(): docstring now states the whole-file rewrite on
  unreadable/corrupt state (other keys preserved only when parseable)
- capture->worker race closed with a session-immutable-snapshot equivalent:
  _capture_voice holds the captured voice (no await gap); apply_selection
  defers its worker-state write while any call is live; open_session releases
  the hold on failure/cancel; VoiceSessionService.on_session_settled releases
  on explicit end, end-into-debt and TTL expiry; response voice == the voice
  the worker actually reads for that call, mid-call changes affect the next
  call only; overlapping-call residual documented honestly
- tests: hel902 fault-injection case 1 rewritten for the new contract
  (recovery + restart + 409 gate kept), 4 new deterministic Event-gated race
  tests (worker read delayed past dispatch, concurrent apply, failure/cancel
  cleanup, next-call voice), zero sleep-based assertions
- docs: contract §2.2/§2.5/§3 updated; r5 followup package tightened (head
  milestones d167ba9/b09a907/pending-review, register ASR vs call ASR split,
  call-ASR counting means + honest pending blocker, single-timebase frame
  silence measurement, D1 female_soft reference audio pinned by sha256,
  recycling checklist, shared-TTS snapshot/restore, git-revert is not
  recycling, caps count failures/retries/outbound-no-response)
- evidence: pytest 380/380 (376 baseline + 4), verify chain 12/12, ruff clean,
  requirement checks 4/4 (origin/main and b09a907, with/without handoff)

Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 01:09:19 +08:00
总工andmultica-agent b09a907747 HEL-902 unified offline review: integration branch verified (pytest 376/376 + chain 12/12 + req checks 4/4), fault-injection suite for register/session/journal half-states, frontend preservation re-verified 8/8 + author logs consistent, one must-fix (services wording) + five suggestions consolidated, real-test approval package prepared (caps stay 0 until approved), requirements README baseline-line erratum v1.11->v1.12
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 00:37:37 +08:00
总工 d167ba927c Merge remote-tracking branch 'origin/hel-879-assembly' into hel-902-integration 2026-10-28 00:20:10 +08:00
总管andmultica-agent 37b4e690cf HEL-900: record byte preservation and requirements verification
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 00:18:06 +08:00
总管andmultica-agent 701442d4d4 HEL-900: preserve visual director custom voice frontend unchanged
Co-authored-by: multica-agent <github@multica.ai>
2026-10-28 00:16:59 +08:00
施工员2号andmultica-agent ce7279e68f HEL-899: custom voice via reference audio on the unified API — reuse .12 panel zero-shot registry (server-side proxy, cg- namespaced IDs never collide with the 11 builtin voices, panel stays the only voices.json writer) + CosyVoice fresh-synthesis preview (unknown voice pre-checked: no silent default fallback) + persisted selection/manifest/usage journal in instance data space with atomic writes and full rollback; session-open captures the effective voice (selection if valid else worker-file value else default, D1 domain untouched when user never chose) pinned per call, mid-call changes land at next call at the earliest; explicit allow_asr gates FunASR transcription, preview/ASR journaled, preview writes no chat/memory/receipt rows; zero new deps (raw-bytes upload endpoint), zero real calls in tests: 18 new cases (formats/size/duration refusals, collision+concurrency, panel down/reject/lying, tts down, rollback, restart persistence, call pinning, identity/shared-state untouched) + offline chain script 12/12, suite 370; req governance v1.12: CG-010 boundary updated to custom-voice-first per user comment 01a17de1 (verbatim evidence + changes + manifest), checks pass vs origin/main and base 8dc84f8 with handoff-HEL-899
Co-authored-by: multica-agent <github@multica.ai>
2026-10-27 23:34:00 +08:00
77 changed files with 10735 additions and 48 deletions
+1
View File
@@ -1,3 +1,4 @@
__pycache__/
*.pyc
web/node_modules
.venv311/
+11
View File
@@ -25,6 +25,17 @@ agent_name: cybergril-voice-agent
# CosyVoice TTS 健康探测(TTS 不在就诚实拒绝开通话,不假装能说话)
tts_health_url: http://127.0.0.1:9880/health
# ---- 自定义音色(HEL-899)----
# .12 语音面板(8090,https 自签):音色列表/zero-shot 登记的唯一写入者,
# CyberGril 统一 API 服务端代理调用;浏览器永不直连,凭据不外发。
panel_url: https://127.0.0.1:8090
# CosyVoice 合成入口(试听现合成;/health 的热加载名单用于通话开启时校验)
tts_url: http://127.0.0.1:9880
# 共享 voices.json 只读回看(补全 ASR 自动转写的参考文本,供备份清单);不配则留空
registry_file: /opt/voice-agent/cosyvoice/voices.json
# CyberGril 专属 worker 的音色状态文件(worker 会话入口读一次;通话中改选最迟下一通话生效)
worker_state_file: /opt/voice-agent-cybergril/voice_state_cybergril.json
# 浏览器接入 token 有效期(秒):只够加入房间,短时限
token_ttl_seconds: 300
# 会话登记有效期(秒):房间早空/挂断后登记自然过期
+1 -1
View File
@@ -1,6 +1,6 @@
# CyberGril 需求入口
当前完整基准:**v1.11**。批准需求继承办法的主卡评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
当前完整基准:**v1.12**(HEL-899 起 v1.12;本行此前长期写 v1.11,属入口勘误,基准与变更记录本身自 v1.12 起即为准确,未回退)。批准需求继承办法的主卡评论:`01a1635b-b47c-79ed-ac66-9408c7905af9`。
所有新任务、续办、设计、施工、审核先读完整当前需求,再读本次相关来源。只有白栖知明确修改的要求才可变更,阶段范围不能覆盖产品要求。
+4 -3
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"version": "1.11",
"version": "1.12",
"approval_comment_id": "01a1734c-1159-7a44-a81f-3105f1b8d98d",
"owner_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"scope": "完整要求;v1.11在v1.10的59项上新增成熟方案优先复用执行约束,原产品与验收边界保留;不代表已选定底座、授权迁移或功能通过。",
@@ -118,10 +118,11 @@
"category": "产品",
"topic": "连续语音与文字",
"conclusion": "用户主动开启语音后自然连续交谈,支持开口打断她;结束语音后停止收音,同时保留文字聊天。",
"boundary": "响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。",
"boundary": "响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。音色入口以可自定义音色为准:在网页上传或录制参考音频→试听→应用到同一位她的后续通话,直接复用已装TTS的零样本克隆;不以仅选预设音色替代,先前参考包试听方案不采用。本轮通话测试授权总管自定音色、最长2小时/3次通话;自定义音色交付不等于用户已验收音色,也不改变换声音只换发声、不重建伴侣、不改出生底色/名字/关系/聊天与记忆的边界。",
"source_comment_ids": [
"01a15750-2cf9-7f23-9269-0f150d2c5e61",
"01a15750-e061-733b-9d80-38459d306b97"
"01a15750-e061-733b-9d80-38459d306b97",
"01a17de1-2892-78be-a422-eb6a11ffebfd"
]
},
{
+10
View File
@@ -108,6 +108,16 @@
"GOV-006"
],
"impact": "原59项保持;不预设保留自研底座,也不指定任何最终开源项目;未授权产品迁移、依赖安装、现网部署或改变资料/隐私边界。"
},
{
"version": "1.12",
"approval_comment_id": "01a17de1-2892-78be-a422-eb6a11ffebfd",
"approved_by": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"summary": "CG-010音色边界更新:以可自定义音色为准(网页上传/录制参考音频→试听→应用),直接复用已装TTS零样本克隆;不以仅选预设替代,参考包试听方案不采用;本轮通话测试授权总管自定音色、最长2小时/3次通话。",
"affected_ids": [
"CG-010"
],
"impact": "只改音色入口与测试授权;换声音只换发声、不重建伴侣、不改底色/名字/关系/聊天/记忆的既有边界不变;交付与阶段通过不等于用户已验收音色;不新增付费服务、不改变部署与资料边界。"
}
]
}
+10
View File
@@ -39,3 +39,13 @@ v1.7:把原始31项、已确认视觉和后续用户裁定整理到同一入
> 记住我的原则,能借鉴成熟方案的就直接借鉴,从零创造的难度太大需要的是一个专业的团队,不适用于我这个不专业的人加上没有决策性的AI的组合;另外我之前让你看的我跟另外智能体的交流内容希望你提取值得借鉴的地方
旧记录未明确规定通用内核必须优先复用成熟实现;现新增GOV-006,减少从零研发和长期自行维护,不因沉没工作量而默认保留自研内核。原59项不删减。交流材料提炼为候选机制,具体技术栈、底座、情绪映射及迁移范围未获批;不将本次文档记录当功能通过或现网施工授权。
## v1.12:音色入口定为可自定义音色
来源评论:`01a17de1-2892-78be-a422-eb6a11ffebfd`(2026-10-27 15:01:10 UTC,回应总管阻塞通报 `01a17d45-f14e-7b62-9850-646e040c7318`)。白栖知原话:
> .12已恢复,另外以上一条让我解压附件包试听,我不明白是什么意思,是让我听预设音色吗?如果是我觉得你的设计不够合理,我想要的是可以自定义音色,我的.12上安装的TTS是支持自定义音色的。至于最长2小时,3次通话的测试,我同意,音色你自己决定就行
旧边界(CG-010/D1 路线):音色按"先在现有中文女声里挑、都不满意再做零样本克隆"的条件路线推进,本轮以参考包编号试听为交付形式。新要求:**音色入口以可自定义音色为准**——在网页上传或录制参考音频→试听→应用到同一位她的后续通话,直接复用已装TTS的零样本克隆;不以仅选预设音色替代,参考包试听方案不采用。另明确:本轮通话测试授权总管自定音色、最长2小时/3次通话(操作授权,记 HEL-879 测试执行,不进需求边界)。
影响范围:仅 CG-010 音色入口与测试授权;换声音只换发声、不重建伴侣、不改出生底色/名字/关系/聊天/记忆的既有边界不变;CG-010 其余条文与原59项不动。工程交付或测试通过不等于用户已验收音色,也不得把"用户已授权自定义音色"改写成"用户已选定某支音色"。原话记录见 `sources/comment-evidence-custom-voice-20261027.json`(施工员2号按平台原话逐字核对录入)。
+1 -1
View File
@@ -1,5 +1,5 @@
{
"baseline_version": "1.11",
"baseline_version": "1.12",
"items": [
{
"requirement_id": "CG-001",
+5 -3
View File
@@ -1,9 +1,11 @@
# CyberGril 完整当前需求 · v1.11
# CyberGril 完整当前需求 · v1.12
完整要求;版本1.10在v1.9(交流静图认可)之上,把白栖知已体验的对坐动态小样 v2 整体效果(演示层面)记入 VIS-011 与 CG-004 边界;不证明其余页面、真实功能或完整产品已验收。
v1.11新增GOV-006成熟方案优先复用;原59项与v1.10阶段验收边界保持。
v1.12按白栖知2026-10-27原话(`01a17de1-2892-78be-a422-eb6a11ffebfd`)把CG-010音色边界更新为可自定义音色:网页上传/录制参考音频→试听→应用到同一位她的后续通话,直接复用已装TTS零样本克隆,不以仅选预设替代;先前参考包试听方案不采用。本轮通话测试授权总管自定音色、最长2小时/3次通话。该项交付不等于用户已验收音色。
## 阅读与效力
用户明确的新决定覆盖旧决定并记录来源;完整当前记录优先于任务摘录、技术方案和旧仓库 SPEC。无明确用户裁定的冲突先查来源,不擅自取舍。
@@ -84,9 +86,9 @@ v1.11新增GOV-006成熟方案优先复用;原59项与v1.10阶段验收边界
用户主动开启语音后自然连续交谈,支持开口打断她;结束语音后停止收音,同时保留文字聊天。
边界:响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。
边界:响应速度、回声消除和打断效果需用现有语音服务实测,未承诺固定毫秒数。音色入口以可自定义音色为准:在网页上传或录制参考音频→试听→应用到同一位她的后续通话,直接复用已装TTS的零样本克隆;不以仅选预设音色替代,先前参考包试听方案不采用。本轮通话测试授权总管自定音色、最长2小时/3次通话;自定义音色交付不等于用户已验收音色,也不改变换声音只换发声、不重建伴侣、不改出生底色/名字/关系/聊天与记忆的边界。
来源评论:`01a15750-2cf9-7f23-9269-0f150d2c5e61`、`01a15750-e061-733b-9d80-38459d306b97`。
来源评论:`01a15750-2cf9-7f23-9269-0f150d2c5e61`、`01a15750-e061-733b-9d80-38459d306b97`、`01a17de1-2892-78be-a422-eb6a11ffebfd`。
## CG-011 · 主动联系范围
+57
View File
@@ -0,0 +1,57 @@
{
"baseline_version": "1.12",
"read_commit": "8dc84f88e9d5e961e17b246f24c23eaf526b012e",
"task": "HEL-899:把现有 CosyVoice 参考音频零样本克隆能力接入 CyberGril 统一 API(网页上传/录制参考音频→登记 cg- 专属 ID→试听现合成→应用到同一位她的后续通话);直接复用 .12 语音面板 8090 的 zero-shot 注册与 CosyVoice 热加载、9880 /tts 合成,不训练新模型、不重造克隆引擎、不自建第二写者;本卡只做后台/协议与零真实调用自测,前端按合同接视觉总监,真实试听放统一审核",
"requirement_ids": [
"CG-006",
"CG-009",
"CG-010",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"AM-001",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005",
"GOV-006",
"DEL-001"
],
"deliverable": "src/cybergril/voice/custom.py(CustomVoiceService:8090 面板代理=共享 voices.json 唯一写者、cg-+8hex 服务端生成 ID 不撞既有 11 支、WAV 头服务端复验 RIFF/WAVE≤15MB≥2s、显式 allow_asr 才走 FunASR 转写、试听先验存在拦住 CosyVoice 未知音色静默回落 default、原子落盘+失败整体回滚、选择/元数据/资源流水持久在实例资料空间重启读回、备份清单 manifest);router.py 五个新端点(GET/POST /api/voice/voices、POST /api/voice/preview、GET/POST /api/voice/selection)+ /api/voice/session 响应新增 voice=本次通话捕获音色(选择失效诚实回落 default;未选择不代写 worker 文件不替 D1 定音色);session.py 会话记录 voice 字段+/health 名单读回;worker 语义不变(入口读一次→通话中改选最迟下一通话生效、绝不半句换人);契约文档 docs/voice-custom-contract-HEL899.md(前端可直接实现的请求/响应/错误/录音格式/部署与回滚);config/voice.example.yaml 增补(panel_url/tts_url/registry_file/worker_state_file,env 可覆盖);零新依赖(登记端点原始字节体+查询串,特意不引 multipart 库);tests/test_voice_custom.py 18 项 + scripts/verify_hel899_custom_voice.py 12/12;治理:v1.12(CG-010 边界更新,来源评论 01a17de1-2892-78be-a422-eb6a11ffebfd 逐字录入 sources/comment-evidence-custom-voice-20261027.json + manifest/baseline/coverage/changes 同步)",
"acceptance": [
"完整最小合同成立:列出可用声音(builtin 11 支+custom 标记、选择/试听状态、备份清单字段)、登记参考音频(服务端生成 cg- 专属 ID,不可能撞名覆盖既有 11 支,撞名重试不覆盖)、试听生成(每次现合成 WAV 24k/mono、无缓存不拿旧音频冒充)、读取/应用专属音色(换声音只换发声)全部经统一 API,浏览器不直连 8090/9880,凭据不下发",
"当前 session/chat 业务不分叉:worker/统一聊天链零改动;通话开启捕获有效音色并写入会话记录与 worker 状态文件,通话中改选不影响本通话、最迟下一通话生效(测试断言会话 voice 定住)、不打断或误认原轮次/receipt(receipt 链路未动)",
"原子与失败诚实:登记失败(面板 400/5xx/断连/谎报成功)不留下半条可选项、无元数据残留;应用失败整体回滚(选择状态与 worker 文件都回旧值);服务不可用一律 503 中文文案;ASR 仅在 allow_asr=true 显式准许且参考文字为空时发生(面板 FunASR,非 LLM),每次记 register_asr/preview_tts 流水可核算",
"试听不写聊天/记忆/生活/回执(四表计数零变化测试断言);自定义音色未试听不得应用(409),内置 D1 候选不强制;未知音色 404 且 TTS 零调用(前置拦住 CosyVoice 静默回落 default);未知选择通话开启诚实回落 default 不冒充",
"持久化与身份不变:选择/元数据/试听凭据重启读回(重建服务同批文件测试);换声音不改 companion/bazi_core/chat_message(逐字节对比测试);通用助手 voice_state/persona_state/既有音色零写入零删除(桩端点计数零);无人经 API 选择时不代写 worker 文件(D1 域不替用户定)",
"零真实调用:全链 fake 面板/TTS/LiveKit 传输,零真实 LLM/嵌入/ASR/TTS、零模型下载/安装、零共享音色实写、零部署、零现网触碰、未访问 4080、未动 HEL-879 现场"
],
"deferred": [
"真实 CosyVoice 试听/真实 FunASR 转写/克隆相似度:统一审核卡在获批环境实测,复用合法剩余额度,不另开账绕 HEL-879 上限(最长2小时/3次通话授权属总管测试执行,不在本卡)",
"前端入口(上传/录音/试听/应用 UI 与入口呈现):视觉总监按 docs/voice-custom-contract-HEL899.md §2 契约接线;本卡未动 web/",
"自定义音色删除与完整备份/恢复(CG-009/R6):本卡最小合同不含删除(失败/多余登记不影响使用),备份清单字段已就位,恢复=按清单重新登记(面板无导出接口)",
"D1 最终音色决定仍归白栖知/总管;本轮测试音色已授权总管自定",
"R5 整体真机验收与 R1–R7 完整要求继续承接;自定义音色交付不等于 R5 完成或整产品完成"
],
"requirement_changes": [
{
"summary": "v1.12:CG-010 音色边界更新为可自定义音色(网页上传/录制参考音频→试听→应用,直接复用已装TTS零样本克隆,不以仅选预设替代,参考包试听方案不采用);影响仅 CG-010 音色入口与测试授权,换声音只换发声等既有边界不变",
"approval_comment_id": "01a17de1-2892-78be-a422-eb6a11ffebfd",
"record": "sources/comment-evidence-custom-voice-20261027.json + changes.json/changes.md v1.12 + baseline.json/coverage.json/current.md 同步"
}
],
"verification": [
"PYTHONPATH=src .venv311/bin/python -m pytest tests -q:370 通过(基线 8dc84f8 全量 352 + 本卡 18:tests/test_voice_custom.py,全 fake 面板/TTS/LiveKit httpx.MockTransport + 临时文件 + 隔离 registry,零网络零真实模型调用)",
"scripts/verify_hel899_custom_voice.py:12/12 全过(真实 app+router+service,fake 传输:列表→无准许拒ASR→显式登记→未试听409→试听现合成→应用→会话捕获→通话中改选定住本通话→下一通话生效→capabilities.voice=false→重启读回选择/元数据/试听凭据)",
"ruff check src tests scripts deploy:本卡代码 0 错(既有 deploy/hel835_guard.py 的 SIM115 遗留不变,非本卡)",
"python3 scripts/check_requirements.py --base-ref origin/main 与 --base-ref 8dc84f8(实际基准):含本 handoff 通过(v1.12 升版+新来源+变更记录合法,历史来源与变更未改写)",
".12 只读取证(SSH 零写入零服务触碰):/opt/voice-agent/web/server.py 实读核对 upload 校验(名字规则/15MB/WAV magic/32000字节/空参考文本→FunASR 转写/失败删 wav/voices.json 原子写)、/opt/voice-agent/cosyvoice/server.py 实读核对 mtime 热加载与 /tts 未知音色静默回落 default、voices.json 11 支音色清单——契约按实际源码写,不按记忆",
"未验证如实声明:真实 CosyVoice 合成与 FunASR 转写未调用(TTS 9880 停机中且本卡零真实调用);浏览器端录音上传未实现(前端合同已交);.12 未部署、未改任何现网配置/端口/数据"
]
}
+56
View File
@@ -0,0 +1,56 @@
{
"baseline_version": "1.12",
"read_commit": "ce7279e68f64319c080f7ef150cb86c892b81ecb",
"task": "HEL-900:在已认可的设置界面接入自定义声音——网页上传或录制参考声音→登记→试听实际合成→应用给同一位她的后续通话;按 docs/voice-custom-contract-HEL899.md §2 接 HEL-899 后台(agent/2/652da8a82e50 @ ce7279e),只改 web/,零真实调用",
"requirement_ids": [
"CG-006",
"CG-009",
"CG-010",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"VIS-003",
"VIS-004",
"VIS-008",
"VIS-010",
"UX-003",
"UX-006",
"AM-001",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005",
"GOV-006",
"DEL-001"
],
"deliverable": "设置面板新增「她的声音」分区(沿既有设置结构、组件与两套主题 token,不另起视觉方向):web/src/voice/custom.ts(同源 API 客户端:GET/POST /api/voice/voices、POST /api/voice/preview、POST /api/voice/selection;浏览器解码任意可解音频→16kHz 单声道 16 位 PCM WAV 原始字节体上传,参数走查询串;≥2 秒/≤15MB/静音/解不开在浏览器先拦;allow_asr 仅在参考文字为空且用户明确打开开关时为 true;试听响应须为合法 WAV 才可播放;Recorder 仅由点击开启、只录音频、停止/取消/离开/音轨中断即释放、20 秒自动停、迟到的麦克风授权立即关闭);web/src/manage/VoiceSection.tsx(现在的声音与生效时机、上传/录音二选一、参考原声与「她用这个声音说的·刚刚现场合成」分开标注的主题化播放条、名字/参考文字/转写开关、登记、可用声音列表、试听→浏览器 canplay 确认可播后才能应用、每类请求一代 AbortController + 进行中引用防重复、切换/重试/离开/卸载释放请求与 objectURL、失败保留输入);ManagePanel 接入分区、未登记的参考声音计入未保存离开确认、能力标签随服务器实际状态;components.css 追加分区样式(只用既有 token,两主题与手机断点);离线 mock tests/hel900_mock.mjs、浏览器驱动 tests/hel900_shots.mjs、单元测试 tests/hel900_custom_voice.test.ts",
"acceptance": [
"用户可在网页选择本地音频文件或主动点「开始录音」得到参考声音;产物为真实 PCM WAV(mock 端按 RIFF/WAVE 头核验 16kHz/单声道/16 位、时长≥3 秒、Content-Type audio/wav、原始字节体),改扩展名的非 WAV 在浏览器被拒且零上传",
"参考文字可填写;留空时须用户明确打开转写开关才传 allow_asr=true,默认不代开;已填写参考文字时一律 allow_asr=false;转写结果回显",
"参考原声与实际合成试听分开标注(「你给的声音,不是她的声音」/「刚刚现场合成·不是参考原声」);试听每次重新请求,非 WAV 响应拒绝播放;浏览器 canplay 确认本次新试听可播后「给她用这个声音」才可点;切换声音、重试、离开时中止旧请求、停播并回收 objectURL,迟到的旧试听不出现",
"读取并显示当前实际选择;应用成功后显示新声音并说明从下一次通话生效、已接通的通话不中途换声;换声音文案与请求均不触及名字/底色/关系/聊天/记忆;不读写通用助手状态",
"失败如实:未配置/无通路/登记服务 503/TTS 503/422/409/断网/拒麦/麦克风占用/非安全来源/录音取消/停止/离开/重复提交/旧异步结果晚到各有中文说明;失败保留参考声音与已填内容;重复点击只发一次登记且不中止已发出的上传",
"录音仅由用户点击开启、只请求音频不请求摄像头;停止、取消、切到其他分区、关闭设置、音轨中断后真实音轨全部 ended;打开分区本身只读列表、不申请麦克风;演示入口只显示说明卡、不挂载该组件(代码条件 real && open,未做演示入口浏览器测试)"
],
"deferred": [
"真实 CosyVoice 试听、真实 FunASR 转写与克隆相似度、真实麦克风与真机(iPhone/Android/Safari)录音解码:留总工统一审核在获批环境与额度内实测,本卡零真实调用",
"后端待补核项(总管已列给统一审核):真实 worker 通话开始读音色的时序、登记服务端成功后本地落盘失败/断连的半状态、ASR/试听实际用量与既有限额的衔接;前端不替后台修实现",
"自定义音色删除、完整备份/恢复(CG-009/R6)后续卡;当前界面已写明暂不能在此删除",
"交谈页未显示「本次通话所用声音」(POST /api/voice/session 的 voice 字段);如需在通话中提示,可后续小卡补",
"设置「外接服务」分区仍沿旧文案显示语音识别/合成「尚未接通」,与本卡新分区及 HEL-878 语音接通不一致,未在本卡改动,交总管决定是否另行修正",
"D1 最终音色决定仍归白栖知/总管;R5 整体真机验收与 R1–R7 完整要求继续承接,本卡交付不等于用户已验收音色"
],
"requirement_changes": [],
"verification": [
"web: tsc --noEmit 0 错;eslint src 0 错(仅既有 ChatPanel.tsx:240 警告,非本卡);vitest 137/137 通过(基线 118 + 本卡 19:WAV 编码/头校验、48k 立体声→16k 单声道、过短/静音/解不开/过大拒绝、API 合同与错误透传、离线零请求、allow_asr 规则、非 WAV 试听拒绝、中止的试听不返回结果、409/503、Recorder 仅点击开启/停止保留/取消丢弃/迟到授权立即关闭/20 秒自动停/音轨中断/拒绝与非安全来源/重复点击单开);vite build 通过",
"浏览器:node tests/hel900_mock.mjs 8900 + node tests/hel900_shots.mjs <msedge.exe> <outDir> 8900,headless Edge + Chromium 假音频设备,83/83 通过;月光/雾光 × 电脑 1440×900/手机 390×844 主流程截图各 6 张及边界截图(文件上传、断网、登记 503、旧试听丢弃、409、拒麦、离开确认、未配置/无通路/服务不在)",
"python -I scripts/check_requirements.py --handoff docs/requirements/handoff-HEL-900.json 通过;本运行环境无 git,--base-ref origin/main 比较未能执行,改以与 ce7279e 源码包逐文件哈希比对确认 docs/requirements 除新增本交接文件外零改动,--base-ref 检查留统一审核在仓库内执行",
"未验证如实声明:假麦克风是浏览器内置提示音不是人声;试听是 mock 生成的音调不是 TTS;未接真实 8090/9880/FunASR,未部署,未触碰 HEL-879 现场与共享音色注册表,未访问 4080;Safari 与真机未测"
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"baseline_version": "1.12",
"read_commit": "d167ba927c1b07e4771219707c744e90051837d9",
"task": "HEL-902:统一离线审核 HEL-900 前端(hel-900-preserved @ 701442d/37b4e69)与 HEL-899 后台(ce7279e),整合 HEL-879 真实窗口修复(hel-879-assembly @ be149cd)到独立分支 hel-902-integration,零真实调用、零部署、零依赖下载;并备齐真机补测申请包",
"requirement_ids": ["CG-006", "CG-009", "CG-010", "CG-013", "CG-014", "CG-015", "CG-016", "CG-018", "CG-019", "CG-020", "CG-021", "VIS-003", "VIS-004", "VIS-008", "VIS-010", "UX-003", "UX-006", "AM-001", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "GOV-006", "DEL-001"],
"deliverable": "独立整合分支 hel-902-integration(701442d+be149cd 无冲突合并,不改写母线/旧现场/PR#11);总工统一审核报告 docs/rework/hel902/REVIEW.md(含后台故障注入新增 tests/test_voice_review_hel902.py 6 用例:本地元数据落盘失败可恢复不逼重复注册、响应断连重试留可回收孤儿条目、开房 worker 写失败诚实 503、worker 状态损坏行为如实记录、流水写不进时应用 fail-closed 409、选择写失败整体回滚);真机补测申请包 docs/rework/hel902/r5-followup-package.md(时窗/逐类上限/停机方法/参考音频来源与 cg- 共享新增回收/旧服务保护/HEL-879 遗留量测并入);需求入口 README 基准行 v1.11→v1.12 勘误(授权非视觉小修,基准不回退)",
"acceptance": [
"统一 head 上实际复跑:pytest 376/376(370 基线+新增6)、verify_hel899_custom_voice.py 12/12、ruff src/tests/scripts 通过、需求检查 4/4(origin/main 与 ce7279e 各带/不带 handoff-HEL-900)",
"前端保全独立复核:8 文件 blob SHA-256 与保全清单 8/8 一致;作者 ZIP 重下校验 SHA-256 一致;36 截图全部有效 PNG 且视口与 harness 定义一致;作者日志(vitest 137/137、lint 0错1既有警告、build、浏览器 83/83)与 handoff 声明逐项相符",
"前端逐行静态复核与合同 §2/用户原话 01a17de1 一致:原始 WAV 字节+查询串、浏览器真转 16k mono PCM16、大小时长静音拒绝、allow_asr 显式门、试听必合法 WAV+canplay 后才可应用、参考原声与现场合成分开、请求代际清理、录音轨迹释放、演示入口不挂载零请求、CSS 仅用既有 token",
"后台重点补核:capture→写 worker 文件→dispatch→worker 入口读一次的顺序锁定;共享注册表仅经 8090 且 cg- 命名空间不碰 11 支内置与通用助手状态;ASR/试听流水仅核算、硬闸仅 LLM/嵌入(如实,未称硬闸)",
"问题清单集中一轮:🔴外接服务文案按实际能力改写(交视觉作者);🟡登记本地落盘失败映射文案、孤儿条目说明、worker 状态 docstring、会话页显示本次音色(结论:需要)、capture-dispatch 串行化;承接 CG-009/R6 删除与备份、R5 整体验收、R1–R7 完整功能"
],
"deferred": [
"真实登记/可选 ASR 转写/真实试听/应用与下一通话验证、克隆相似度、真机(iPhone/Android/Safari)录音路径:待主卡批准补测包后执行,未批前真实 cap=0",
"HEL-879 遗留:打断后约1秒停声毫秒级量测、文字打断语音方向、真实浏览器/设备麦克风路径——并入同一补测窗口,量测方法已写入补测包",
"前端独立复跑(tsc/eslint/vitest/build/浏览器两主题×电脑/手机、演示入口零外发断言):本机依赖被清理且禁下载,需一次性 npm 同锁文件安装授权或随真机窗口在 .12 执行",
"自定义音色删除、完整备份/恢复(CG-009/R6)后续卡;R5 整体验收与 R1–R7 完整功能持续承接;PR#4/PR#8 等仍无合并或替换正式界面授权"
],
"requirement_changes": [],
"verification": [
"pytest tests -q 376/376(py3.11.16 复用既有审核 venv,零安装)",
"python -I scripts/verify_hel899_custom_voice.py 12/12;ruff check src tests scripts 通过",
"python -I scripts/check_requirements.py --base-ref origin/main(及 --handoff docs/requirements/handoff-HEL-900.json、ce7279e 对照)4/4 通过",
"前端与浏览器未在本机复跑(环境缺依赖且本卡禁下载安装),以作者日志+逐行静态复核+保全哈希复核作证据,如实标注未验证范围;真机项全部列入补测包"
]
}
+57
View File
@@ -0,0 +1,57 @@
{
"baseline_version": "1.12",
"read_commit": "b09a907747f9836c4aea04016757321d28b7cb6b",
"task": "HEL-903:HEL-902 统一审核后的原作者有限返修(只改 web/ 与本卡交接/证据)——外接服务语音两行按 /api/voice/status 如实区分状态;通话栏显示 session 响应 voice 固定的「本次通话声音」;登记后无明确结果(500/503/响应断连)时提示先刷新列表看编号条目,列表标出缺本地元数据的 cg- 条目。基于 origin/hel-902-integration @ b09a907,保留 HEL-879 真实窗口修复与 HEL-900 作者源码,不推写统一母线、不回退 ce7279e,零真实调用",
"requirement_ids": [
"CG-006",
"CG-009",
"CG-010",
"CG-013",
"CG-014",
"CG-015",
"CG-016",
"CG-018",
"CG-019",
"CG-020",
"CG-021",
"VIS-003",
"VIS-004",
"VIS-008",
"VIS-010",
"VIS-013",
"UX-001",
"UX-003",
"UX-005",
"UX-006",
"AM-001",
"GOV-001",
"GOV-002",
"GOV-003",
"GOV-004",
"GOV-005",
"GOV-006",
"DEL-001"
],
"deliverable": "web/src/voice/session.ts:readVoiceStatus 把 GET /api/voice/status 分成 none(无接口)/off(未配置)/ok(已配置且合成健康检查通过)/down(已配置但健康检查未过)/unknown(读不到)五态,voiceStatus 改为由它派生、行为不变;VoiceView 新增 voice(本次通话固定声音,开房前 null,服务器未给或不合规为 {id:null}),只在拿到本代 grant 时写入,结束/失败/迟到旧请求一律随 view 复位;callVoiceOf 只接受列表可容纳的 id。web/src/chat/ChatPanel.tsx:在既有通话条加「本次通话 · 声音名」(default→默认声音、缺字段→声音未说明,名字只按该 id 查列表,查不到显示编号,不用设置里的当前选择),title 写明到本次结束不变、设置里的新选择下一次通话生效。web/src/manage/ManagePanel.tsx:外接服务的语音识别/语音合成两行改为进入分区及点「重新读取状态」时读 /api/voice/status(仅真实入口),按五态如实说明,识别一行写明没有单独健康检查;配置框、按钮、说明卡与文字检查提示随之更正,视觉观察仍写尚未接通。web/src/voice/custom.ts + VoiceSection.tsx:VoiceError 增加 uncertain(已发出后断连、5xx、成功响应体读不全),登记遇到时提示「可能已经登记上了,先刷新列表看 cg- 编号条目,不用再登记」,提供「刷新列表」并把再登记改为「确认没有,再登记」;刷新后对比失败时的自定义列表,指出新出现条目或说明暂无新条目需稍等;列表把无本地元数据的 cg- 条目标「只有编号,缺本地资料」并加说明。4xx 与离线未发出仍为原明确提示。不新增删除入口、不自动删声音。components.css 追加通话条声音标签、状态行配色、编号条目说明与手机按钮换行(只用既有 token,两主题)。测试:tests/hel903_voice_rework.test.ts(11 例)、tests/hel903_mock.mjs、tests/hel903_shots.mjs;tests/hel898_agent_left.test.ts 期望视图补 voice:null。",
"acceptance": [
"外接服务:语音合成在 ok/down/off/none/unknown(500)/unknown(断连) 下分别显示「已配置 · 合成服务健康检查可连接」「已配置 · 合成服务这会儿暂不可用」「未配置 · 这台服务没有语音配置」「这台服务没有语音状态接口」「状态未知 · 这次没读到语音状态」;语音识别不声称有自己的健康检查;任一状态下语音两行都不出现「尚未接通」或「已接通」;读取状态不申请麦克风;说明写明「可连接」不代表整套语音通话已验收",
"通话:开房后通话条显示 session 响应 voice 对应的「本次通话 · 名称」;通话中另一端改选声音,本次通话显示不变;挂断后标签消失、麦克风释放;下一次通话显示新选择;响应缺 voice 字段显示「声音未说明」而非设置当前选择;default 显示「默认声音」;更早一次通话迟到的 grant 不覆盖新通话(单测)",
"登记:服务器已收下却回 500、或响应中途断开、或 503 时,提示可能已登记并先刷新列表;不自动重读、不自动再登记(上传计数验证);点刷新列表后能指出新出现的 cg- 编号条目并标「只有编号,缺本地资料」;若无新条目则说明稍等再刷、确认没有再登记,确认后再登记一次只发一次;参考声音与已填内容在失败后保留",
"回归:既有上传→登记→试听 canplay→应用流程、换声音只换发声的说明、录音点击开启/停止保留/取消释放麦克风、通话字幕与结束清理均保持;演示入口打开外接服务、她的声音与交谈零 /api 请求"
],
"deferred": [
"后台新错误映射(本地元数据落盘失败 OSError→503/422 中文文案等,HEL-902 §5-2)由另卡处理;本卡先兼容既有 500/503,后续统一整合时按合同补核前端文案",
"语音识别没有独立健康状态接口,外接服务只能如实写「没有单独的健康检查」;如需显示识别服务真实状态需后台另行提供",
"编号条目的删除/回收(经 8090 面板自有删除)、自定义音色删除与完整备份恢复(CG-009/R6)继续由后续卡承接,本卡不新增删除入口",
"_capture_voice→worker 读文件的秒级竞态(HEL-902 §5-6)属后台;前端显示的是服务器声明的本次捕获值",
"真实 CosyVoice/FunASR/LiveKit、真机(iPhone/Android/Safari)与 R5 整体验收、R1–R7 完整功能继续承接;HEL-901 仍停放;本卡不部署、不合并"
],
"requirement_changes": [],
"verification": [
"web(复用原作者 HEL-900 工作区已存在的同版 node_modules,package-lock SHA256 F950DE58…7A27 一致,经目录联接使用,零下载安装):tsc --noEmit 0 错;eslint src 0 错 1 既有警告(ChatPanel fullButton,非本卡);vitest 148/148(基线 137 + 本卡 11);vite build 通过",
"浏览器:node tests/hel903_mock.mjs 8903 + node tests/hel903_shots.mjs <msedge.exe> <outDir> 8903,headless Edge + 假 LiveKit(tests/fake-livekit.js)+ Chromium 假音频设备,90/90 通过:月光/雾光 × 电脑 1440×900/手机 390×844 各 18 项(外接服务、应用声音、本次通话声音固定、下一通更新、字幕、挂断释放、登记 500 后恢复),状态五分支、缺 voice 字段/default、响应断连与 503 恢复、录音释放、演示零请求",
"对照截图:同一 mock 跑 b09a907 原样构建(产物 index-C8UHROe1.js 与 HEL-900 作者构建同名)before 模式三张:外接服务、通话条、登记 500",
"需求检查:python -I scripts/check_requirements.py 与 --handoff docs/requirements/handoff-HEL-903.json 本机执行;本运行环境无 git,--base-ref origin/main 与 --base-ref b09a907 均无法执行,改以与 b09a907 源码包逐文件 SHA256 比对确认 docs/requirements 除新增本交接文件外零改动,--base-ref 四组检查交总管在仓库补跑",
"未验证如实声明:假麦克风是浏览器内置提示音;假 LiveKit 不是 WebRTC;试听是 mock 音调不是 TTS;未接真实 8090/9880/FunASR/LiveKit,未部署,未写共享音色,未访问 4080;Safari 与真机未测"
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"baseline_version": "1.12",
"read_commit": "b09a907747f9836c4aea04016757321d28b7cb6b",
"task": "HEL-904:HEL-902 统一审核后的后台有限返修(HEL-899 原作者)。三项:①登记在8090成功而本地元数据落盘失败→503中文映射(编号可恢复、共享登记不回滚、不逼重复登记),ASR流水先记再落盘;②worker状态文件损坏整体重写的实况补入docstring/合同,正常可读文件仍保留其他键;③修通话捕获音色与worker实读竞态:以会话不可变快照(通话窗口持有)保证响应voice=worker本通话实读声音、通话中改选只影响下一通,覆盖失败/取消/挂断失败转欠账/TTL到期结算,确定性Event门控无sleep;④补测申请材料收紧(仅材料不执行)。零真实调用、零部署、零下载、不动web/与共享worker、不新增依赖",
"requirement_ids": ["CG-006", "CG-009", "CG-010", "CG-013", "CG-014", "CG-015", "CG-016", "CG-018", "CG-019", "CG-020", "CG-021", "AM-001", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "GOV-006", "DEL-001"],
"deliverable": "独立分支(自b09a907)后台返修:custom.py登记失败映射LocalRecordFailed+ASR流水先记、write_worker_voice损坏实况docstring、通话持有hold/release+apply暂缓写、router捕获登记/失败取消释放/503映射/settle挂钩、session.py on_session_settled结算(挂断成功/转欠账/TTL到期);tests/test_voice_review_hel902.py第1例按新契约改写(含重启恢复与409守门断言)、新增tests/test_voice_rework_hel904.py确定性时序4例;合同docs/voice-custom-contract-HEL899.md §2.2/§2.5/§3更新;docs/rework/hel902/r5-followup-package.md收紧(head里程碑表:d167ba9=整合提交、b09a907=审核head、本卡head待统一复审登记,登记ASR与连续通话ASR区分、连续通话ASR计数手段与待补阻塞如实声明、停声量测单一时间基准帧级录音、参考音频明确D1包female_soft段含sha256/长度、回收清单与不未经确认保留测试垃圾、共享TTS启停快照恢复、git回退不代替共享回收、上限含失败口径与硬闸/监看界线);docs/rework/hel904/REWORK.md返修记录",
"acceptance": [
"登记成功+本地元数据写失败:503中文文案含编号、明确共享登记不回滚、刷新列表可找回并试听/应用、不逼重复登记(tests/test_voice_review_hel902.py改写例:含重启按编号label=编号继续用、新音色未试听409守门不放松、11支内置与通用助手零改写)",
"worker状态文件损坏实况:docstring/合同如实注明整体重写仅剩voice,不再宣称任何情况下保留其他键;正常可读文件其他键保留(既有用例继续通过)",
"竞态收口:确定性Event门控测试把worker读时点延迟到dispatch返回之后、并发应用新声音后——本通话worker读到=响应voice=旧音色;挂断结算后下一通capture用新声音;挂断失败转欠账、TTL到期、dispatch失败、请求取消四条路径都释放持有、应用随即写透;无sleep凑测试(tests/test_voice_rework_hel904.py 4例)",
"补测材料:仅材料不执行,候选上限只收紧不放宽,一切失败/取消/重试/出站无响应计入口径;LLM/嵌入=程序硬闸与其余工程监看界线写明;连续通话ASR无程序计数手段处如实标待补阻塞;真实cap=0保持"
],
"deferred": [
"真实登记/可选转写/真实试听/应用与下一通验证、克隆相似度、打断停声帧级量测、真机麦克风路径:待主卡批准补测包后执行,未批前真实cap=0",
"本卡后台修复与前端另卡(外接服务文案、会话页显示本次音色)需原总工一次统一复审;通过后登记最终已验证head,此前任何head不得当作执行依据",
"连续通话ASR分钟级音频时长与连接重试的程序计数手段:待补阻塞,窗口前由总管在.12验证LiveKit/worker侧指标可执行性,不通过则窗口不得启动含连续通话步骤",
"前端同锁文件npm安装复跑:单独待批项,本轮不执行",
"自定义音色删除、完整备份/恢复(CG-009/R6)后续卡;R5整体验收与R1-R7完整功能持续承接;本卡通过≠自定义音色用户验收,更≠完整产品完成"
],
"requirement_changes": [],
"verification": [
"pytest tests -q:380通过(基线376+新增4,py3.11.16复用既有venv零安装),全量含HEL-899 18项与总工故障注入全数通过",
"python -I scripts/verify_hel899_custom_voice.py:12/12(含通话中改选定住本通话、下一通生效)",
"ruff check src tests scripts:通过",
"需求检查4/4:--base-ref origin/main 与 --base-ref b09a907 各带/不带本handoff全部通过",
"未验证(如实):真实worker读时点、真实dispatch/房间、打断停声与真机路径——全部留在获批补测窗口;重叠通话不同音色的残余竞态如实记录于合同§2.5与REWORK.md,不宣称消除"
]
}
+30
View File
@@ -0,0 +1,30 @@
{
"baseline_version": "1.12",
"read_commit": "7cb73429f7a0585bed17873179a627c890336c70",
"task": "HEL-905:HEL-903 前端返修与 HEL-904 后台返修的统一离线复审(总工)。在自己独立分支 hel-905-unified-review@7cb73429 集成前端088ac62(原作者源码51669aa,11文件逐字节一致)与后台07dda2b,不重出架构、不另起视觉、不重复派工;一次审完三处前端修正(语音合成五态与ASR无单独健康检查的诚实说明、演示零API外发、session voice固定本次声音)与500/503/断连恢复流(先刷新找cg-编号条目、缺资料标注、不自动重登记/不自动删除)、后台通话持有方案的失败/取消/挂断转欠账/TTL结算路径、以及补测包材料可批性。真实cap=0,零部署、零下载安装、不发真实请求、不合并PR",
"requirement_ids": ["CG-006", "CG-009", "CG-010", "CG-013", "CG-014", "CG-015", "CG-016", "CG-018", "CG-019", "CG-020", "CG-021", "VIS-003", "VIS-004", "VIS-008", "VIS-010", "VIS-013", "UX-001", "UX-003", "UX-005", "UX-006", "AM-001", "GOV-001", "GOV-002", "GOV-003", "GOV-004", "GOV-005", "GOV-006", "DEL-001"],
"deliverable": "统一复审结论(本卡评论)+集成分支 origin/hel-905-unified-review @ 7cb73429f7a0585bed17873179a627c890336c70(merge 前端088ac62与后台07dda2b于 b09a907 之上,历史正常保留)。独立核验:11文件SHA256与原作者改后清单逐一一致;前端三处修正与恢复流按源码逐行复核(callVoiceOf只认严格id、default=默认声音、缺答=声音未说明不猜、失败/结束复位OFF、late grant被stale/gen挡住;演示模式全部效果守if(!real)不发任何请求;uncertain分类422/409/离线=不确定false、5xx/发送后断=不确定true,主按钮换刷新列表、从不自动重登记);后台持有hold在capture写文件后无await间隙登记、挂断成功/挂断失败转欠账/TTL到期/dispatch失败/请求取消五路释放、Event门控无sleep。总工独立复跑(复用既有venv311,零安装):pytest tests 380/380、verify_hel899_custom_voice 12/12、ruff src tests scripts 通过;需求检查4/4(origin/main与b09a907各带/不带本handoff)。前端构建/单测/浏览器因web/node_modules缺失未独立复跑,如实列为待批项,不以作者148/148、90/90冒充",
"acceptance": [
"集成核验:7cb73429含088ac62与07dda2b两个先祖、基点b09a907;author-manifest.json 11文件改后SHA256与统一head工作树逐一一致(含handoff JSON共11项match 11/11);前端仅作者源码,总工未替作者改视觉方向",
"前端三处修正复核:readVoiceStatus五态(none/off/ok/down/unknown)+ASR行如实写「没有单独的健康检查,以实际通话为准」;演示模式services/voiceStatus效果全守if(!real)零API外发;session voice只取本通grant严格id、命名查找按id匹配、跨id答案不显示、结束/失败复位、下一通用新选择",
"恢复流复核:uncertain(5xx/发送后断/体不完整)→提示先刷新找cg-新条目、刷新前后集合比对指认新增项、idOnly条目如实标注「只有编号,缺本地资料」并说明可试听可用、无删除入口、绝不自动重登记或自动删除;Chromium自动重发登记POST的服务端非幂等风险如实保留(重试另立新cg-编号+共享注册表孤儿条目,合同§2.2如实声明,不宣称前端提示已解决服务端幂等)",
"后台通话持有复核:capture写文件后同步登记持有(无await间隙);挂断成功、挂断失败转清理欠账、TTL到期_prune、dispatch失败、开房请求在途取消五条路径均释放持有;期间apply只记选择不写worker文件,会话响应voice=worker本通话实读;重叠通话较新capture重写文件的残余如实记录于合同§2.5,顺序用例通过不称竞态根除",
"总工独立复跑:pytest tests -q 380/380、python -I scripts/verify_hel899_custom_voice.py 12/12、ruff check src tests scripts 通过(venv311=Python3.11.16复用,零安装零下载);需求检查4/4通过",
"补测包核对(材料,不执行):连续ASR精确计数仍为待补阻塞且包内如实声明;包首行不声称材料齐备;停声量测单一时基、female_soft参考段sha256、8090新增cg-清单与回收、共享TTS快照恢复、失败计入用量等逐项在案;候选上限未超904已收紧值"
],
"deferred": [
"前端独立复跑(同锁npm安装、tsc/vitest/build、浏览器两主题×电脑/手机、录音释放/字幕/取消迟到/演示零外发):web/node_modules缺失,本轮禁止安装,列为待主卡批准项;作者148/148与90/90仅为作者证据",
"真实登记/试听/应用、真实worker读时点、打断停声帧级量测、真机麦克风:待主卡集中请批新窗口后执行,本轮真实cap=0、未部署、未触旧服务与8090/4080",
"挂断失败转欠账与TTL到期释放持有时,远端房间删除落地前的极端未入场worker仍可能读到后续写透值:与重叠通话同类残余、每worker仍整通话单一音色,建议下轮在合同§2.5残余段一并补写明(非阻塞)",
"连续ASR分钟级时长/连接重试的程序计数手段:仍为待补阻塞,窗口启动前须由总管在.12验证LiveKit/worker侧指标可执行性",
"CG-009/R6删除与完整备份恢复、R5真机与整体验收、R1-R7完整功能继续承接;本卡通过≠自定义音色用户验收,更≠完整产品完成;旧HEL-879窗口封账永不重开"
],
"requirement_changes": [],
"verification": [
"git:7cb73429为origin/hel-905-unified-review head,merge-base核088ac62/07dda2b均为先祖、基点b09a907未回退、母线与PR#11未改写",
"SHA256:docs/rework/hel903/preservation/author-manifest.json全部11项与统一head工作树一致(独立脚本核对match 11/11)",
"venv311(Python3.11.16)独立复跑:pytest tests 380/380、verify_hel899_custom_voice.py 12/12、ruff check src tests scripts 通过",
"需求检查4/4:--base-ref origin/main 与 --base-ref b09a907 各带/不带 docs/requirements/handoff-HEL-905.json 全部通过(60项)",
"未验证(如实):前端构建/单测/浏览器未独立复跑(缺node_modules,不安装);真实语音链路全部未动;补测包材料审读基于文档与源码,实际量测留获批窗口"
]
}
+2 -1
View File
@@ -15,5 +15,6 @@
"comment-evidence-mature-reuse-20261025.json": "38ca076e26477e89cd161eaadb0fb00b0ee7bb5a26de2bd6ab41de52faaae79e",
"mature-reuse-principle-20261025.md": "a2a75e071d3af5db722727bfaf8083bb60d07dcdd74e90d1e1374c1aafd2064d",
"other-agent-discussion-20261025.txt": "42cd97029d73eace110e49e0566343dfeb25fe4f0633a10b78b126289938aaeb",
"comment-evidence-full-experience-20261026.json": "75dd55b8755bdad217b44c3f516c9372b046aae06dff511527014c7b209eccff"
"comment-evidence-full-experience-20261026.json": "75dd55b8755bdad217b44c3f516c9372b046aae06dff511527014c7b209eccff",
"comment-evidence-custom-voice-20261027.json": "ed72bd78218e1c0b4ca644bf933d6bdb2e3aa8cd2c0333f3410f6b60845bd3cb"
}
@@ -0,0 +1,10 @@
[
{
"id": "01a17de1-2892-78be-a422-eb6a11ffebfd",
"author_type": "member",
"author_id": "e5c6c938-db48-4ff9-954f-4fb8b063a132",
"content": ".12已恢复,另外以上一条让我解压附件包试听,我不明白是什么意思,是让我听预设音色吗?如果是我觉得你的设计不够合理,我想要的是可以自定义音色,我的.12上安装的TTS是支持自定义音色的。至于最长2小时,3次通话的测试,我同意,音色你自己决定就行",
"created_at": "2026-10-27T15:01:10Z",
"parent_id": "01a17d45-f14e-7b62-9850-646e040c7318"
}
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,810 @@
{
"base_commit": "ce7279e68f64319c080f7ef150cb86c892b81ecb",
"changed": {
"docs/requirements/handoff-HEL-900.json": {
"before": null,
"after": "297b5c257b5bf55b2e09c49ef8a9646f01060dd74b92433d8bd3e32c9ffce218"
},
"web/src/manage/ManagePanel.tsx": {
"before": "df0c968ec91c571124f9707c853a17587569448195745b4757d7afd753cfef3c",
"after": "a0e6a4a9028277b9adbed280d8958890fe6cd56a2375fd23072bd7719d3af878"
},
"web/src/manage/VoiceSection.tsx": {
"before": null,
"after": "de0cf6fa86e85a8b34ed79fccd2cdab4e9a2e66bea298ba2f03de2e7c4f797ec"
},
"web/src/styles/components.css": {
"before": "f93b875f6918d402aaaefeffdc52fdd908328fb8ac70766704e528c6a8bc154d",
"after": "653cbdafe876966a1fb320a438460837b10aa5f1cf204fbabb26e33810e3b848"
},
"web/src/voice/custom.ts": {
"before": null,
"after": "46bc0b6291e2a60cbd90704fade5c0f45aef009b817e388c7651495b372f2112"
},
"web/tests/hel900_custom_voice.test.ts": {
"before": null,
"after": "54be8b0437262528b034844ad66275e79ce53ef68d5dc679c53f3e6d2094ecd9"
},
"web/tests/hel900_mock.mjs": {
"before": null,
"after": "f120fe076d4b5ca6f1970f0f853bc3148e0fd668d8b53177b5c6d08a8771db30"
},
"web/tests/hel900_shots.mjs": {
"before": null,
"after": "d49905fd5475e4e0022456e75490a15ad0ed7fad2717ed290e539a1bfcf85ab3"
}
},
"files": {
".env.example": "c6551011203737a875feb87a24d267994cc17e2b2e576bb39bc9d9295fd8738a",
".gitattributes": "aeef7e1984486c112d00695aa73a4338cf4fbd81d618522c0f05e43441188066",
".gitignore": "b0c08e3eea2b01c059e6ff75846df875875627dd44a579851a0e371b49282bea",
"AGENTS.md": "40eae2dec1bddfe05318eeb76815806a3efa3e4ce23e3d3668132a7ee4cf61b7",
"README.md": "1ee70d262a155a7832b12e4339a95a916dd9ac1d20589ddf002ee53c7e325c13",
"SOURCE-BUNDLE-PROVENANCE.json": "829ed6ba821286b07033401c00a943b82b6e97f85b21e2b23a609ff6d7aaac51",
"config/character.yaml": "7b0279bfb0c9ac90e09fb58a482ebd15562b768d99d676976a128cbece7371c6",
"config/models.yaml": "baed2a8042c330823c08aba0ebcd95899e8d3a1b8446655854b24e2b62289eb7",
"config/semantic.example.yaml": "32173b89234abab76df0d4334fc25d4f2ad285fde001294a666ef4f6299b0199",
"config/voice.example.yaml": "c52848b27b5de230038149bd22e2b2b122b6bb598f114333c7667474960b1a42",
"deploy/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"deploy/cybergril-api.service.example": "afcac96b347e1ad6c9bb6f201c20ba2843d75e5612af9874d14c8a50f4f636d9",
"deploy/cybergril-hel874-8360.service.example": "bbd9abf6ac66bf50f4f9b96903ef7bb14331cfb1357a1968b298bbe1ddd0dc71",
"deploy/hel835_guard.py": "ee6c533d6b31643354f1e165d6315d7d8515560cf1509e05ec161ad0968c35ad",
"deploy/hel874-release.md": "8273e82ad6bae999e1c95201a4ae97765acbc1883d9881b2cc68844e6e6833a1",
"deploy/hel895-release-candidate.md": "40cf119e330e11b40e288edef6d03d36b0af92a5d1775736134ea7599441b189",
"deploy/mem0-stack.lock": "784bb70fdae8b31b1671a401f498bc726498873d1296151dc593ad730f64c484",
"deploy/voice/restart_cybergril.sh.example": "881327010f9c78bb258fa513a33900cb64274778af7e6818de7268fa8e497b3e",
"deploy/voice/serve_dual_8360_8361.py": "6cc4830df0d3a29347f943735a54855de6b2ae4e0501e7fc099680b56e7bb430",
"deploy/voice/worker.env.example": "7eae6da5539ae096d71e8e7c87cde2b9faeb236d6deae7e56c8295d7c1eb7657",
"deploy/voice/wss_bridge.example.py": "fa08f17bd828e016a24f7e16fab01b844dc0757b6436dea1443d551754baf4cf",
"docs/01-membrane.png": "ac3ea97a3a0237edd64752f8910eb91fa8fcf495d7e7e7985191d11ad628b993",
"docs/02-heartbeat.png": "57a2deaf723c878a7d6fc919fc8c3c73c35c08437f7ea996bc4b122c2e07240d",
"docs/03-memory.png": "624ed9b917471c5a6096246339995ae5682a472439504fe5c64e40098dc6c8b3",
"docs/04-title.png": "be2f63ec008f445ac056adcab3543d5e3a844d36553ba2adb2fe69534b847c97",
"docs/HEL-878-preservation-manifest.json": "0c5e9db95268452ffc95c4fd1a15705bca0a8c4711d77b7106316b0bdd689865",
"docs/HEL-878-preservation-review.md": "d5f28f1d57b792dc436f7ddb3c4b2640119d907ab11b53eb79aaa40776880b53",
"docs/SPEC.md": "a2506bd15ae3265ad5049bd750f5f17d88c0ae63a681073a4cf7f81b9d0fe24e",
"docs/budget-package-hel895.md": "ad8f01148a11cd7e97562bf2fca01623e481bbbb37b5b762a6a46dac5a76f071",
"docs/budget-plan-hel874.md": "799cb9569b60c64968b2bc676bb31749c06b7ea423ca32399585e1be36c27dde",
"docs/frontend/.gitignore": "405c7e9a52883194cd5b95a435948eda03961e5c399cb81bc67939d1681d3ff5",
"docs/frontend/IMPLEMENTATION.md": "60e4cb3e194e51e2e408a2546ca6ab30f6940c0c069e4d51d3a99523a2988ba9",
"docs/frontend/PR.md": "80edd7042d7032493e7adeb69dd106af8ab2db5beeb9377b2a7c80b44b68feae",
"docs/frontend/TEST-RECORD.md": "923610921e8a157949ccc78c992fa66b87b1bfd892d920898a2ceb1cb5a8836c",
"docs/frontend/browser-results-1440.json": "e52842e5979161a238f4912713a677fb7e0c4c6e73a4d8e13c5de945ce8a33b1",
"docs/frontend/browser-results-390.json": "95b81baab1bfee9aff8ac8cac553d80d9addb6c5cdd754104f4e81e2a61d91ff",
"docs/frontend/browser-results.json": "773c3dafb2a53a7450a40e8f577b094dd0529074e9783261492551c41e7804da",
"docs/frontend/import-results.json": "fc28b1b9d1b36e1fbeeb326e35689d6482ad793a55e0b7eae25b038af9f46714",
"docs/frontend/recordings/hel825-phone-nav-esc.webm": "9a3e1086a50e60f51d270a44f93f2dca02a605efa91057a7d738f8f7c34ebc77",
"docs/frontend/recordings/hel825-wide-nav-dialog-esc.webm": "6945fba64c0a674b646ab32f771d8430f4e1bb6994a1a42704dd29e4f82fce6d",
"docs/intro.html": "1b2784fa3b38f63307c9cc1b75a091784121eac3d1ffd92dba7b4a48eb888b3c",
"docs/preview.webp": "e36368b5a7753057f2382925831b55addf5c78d415079b9eb9de4fed6f1d92f4",
"docs/r1/DEPLOY-ROLLBACK.md": "f381ac058fb79211aaf4b8655701de8f2473db88735d943e5f4b659738837e5c",
"docs/r1/IMPLEMENTATION.md": "acdaa3f1c1dc1c6f3b1c1a049c0bb4fe2279e91af1acb9b831581c49e8710521",
"docs/r1/PR.md": "f08cfff4531eaf9c89ff944993468d19f9cb1b1f2b02c18b6947addbb417abe9",
"docs/r1/TEST-RECORD.md": "83b1ba9136f1ee1f48516575cb2f9f2a0e32d0595e4c51918726e7ad3fd0e542",
"docs/r1/browser-results.json": "710c04ec4d15559ad64baa5c2e0a29f6c03884b5f83b85cd09e536cdfcc7b9d7",
"docs/r1/controlled/browser-results.json": "5b3e1e53589bcdb969b93d20bae9f6ffacf7819334d585048d620a24c17e7e6a",
"docs/r1/desktop-history-moon.png": "1cb74ca298499abde7a0832e371849e29565e4532b5c0b2b2ebb8ddc981a17b9",
"docs/r1/desktop-model-error.png": "839b9b05f757e39421e8d3b15c192a1b1f213a879ff35ac81c6ce7178631048a",
"docs/r1/desktop-preview-moon.png": "ca8115ec27e70471585df2a5ce4d3f1e4b89e162f4cc4c351ae6f608c073e089",
"docs/r1/desktop-real-chat-moon.png": "cd2422457dcac56af1149f8d6bc3a0590bd5e37adbf3ad8015cc212e6dd6e05e",
"docs/r1/mobile-chat-fog.png": "7ac20f654c78fdf11fe5bb11e4731dbaf551e01227e284b855bd13cbf8832696",
"docs/r1/mobile-history-moon.png": "9168aa143c67469bfc00cdccaafd5ff59dd183f9526544cd4d0cb4f366517cda",
"docs/r1/mobile-relation-fog.png": "c1002c20e65a0967eca6ef1c2559755d0de9d1f04f66406f34ca86a302410557",
"docs/r2/CONTENT-REVIEW-HEL-830.md": "32ad98545aa8791998979120f3710cd5b0691fb8b85fd755c993586a7829a2e3",
"docs/r2/HEL-827-HANDOFF.md": "fcde52d27bbb77495f9b47f572b03042725a4885127f2b7ba36ed39529ab907d",
"docs/r2/HEL-830-HANDOFF.md": "dd392fdfca8d764be9db115689e16c3d87cd3710ecadd442c0b4d6ab5792a8a4",
"docs/r2/VALIDATION-HEL-830.md": "b80ee2d62dbde635fd1cd64b6660649055e36f7893c29308b9eae43367e0d1a2",
"docs/r2/VALIDATION.md": "ad7c6243b2208476d7ee8b740fa43e28606b8f7ac2419718a350d17d3555192a",
"docs/r2/model-smoke-HEL-827.json": "09f2242f1a8e4f637967c7ac3e232407e40b0fe5707f8cc57a76ac1e8a93ff84",
"docs/r2/model-smoke-HEL-830.json": "4c83baff50ee2038925f50d41ff4ea8ddaf68e3883c49931a79c3c44621d3a21",
"docs/r2/pytest-HEL-827.txt": "8e8a03f39e1a8b3062766de3d746dd8e63144a617231dfe1a7c33c4ebc21bdea",
"docs/r2/pytest-HEL-830.txt": "c93e4c1a44bf30fd51b325e5510cbb1247bc4811408c99793cfbf8d499f3f43d",
"docs/r3/CONSTRUCTION-CARDS-HEL-832.md": "fd0f1f930a95f6500700107381dfa29e6f68cc2df0e305a200c9d1568c6a2b3d",
"docs/r3/DEPLOY-HEL-835.md": "2baf25cd13729a7583d9ef07b45ef1ed0a64db08358c359494509a78b94c001c",
"docs/r3/MANIFEST-HEL-834-source.md": "b98f1c7aa7c6924a3c6d3d86be6a6fdce447db3c60b944813493cd5fe122a45e",
"docs/r3/PLAN-real-followup-HEL-835.md": "39676418d5841f88c2bcc562300e1d108bcb1a3cda8801b82f80fb5a4cf1d993",
"docs/r3/PLAN-synthetic-retrieval-HEL-835.md": "c026477f1848253f6899e701b4bb6ab067c186791eccd66193a473b67ddd7c01",
"docs/r3/PROBE-runbook-HEL-835.md": "acdea88bb6bee26321519eebe596cc036231c0b793ddf8fb7bcb8de82b534a2d",
"docs/r3/REPORT-HEL-832.md": "4503f9e54ef09914616a5a84202169277f558aa4435e1a6f5fc967d50d9623ea",
"docs/r3/REPORT-HEL-834.md": "77740766ccfc3d0858e3f4af7660b54b36eef8c2e0841b38251baf8b0eb3f189",
"docs/r3/STATUS-HEL-833.md": "4c4c028d0c79906510c3a28f14b4bd81c21f990fa7f745294d983a453db5f45c",
"docs/r3/browser-HEL-834.txt": "fa2640e476c7ad754ee3d5132b4e830945cb909e3ae2c9140ec87517fbf9508d",
"docs/r3/browser-HEL-835.txt": "70741b740ef4e73f42c695c82f824488a777d41cdd43b47164a50dc6a6b46e3c",
"docs/r3/correction-proof-HEL-835.py": "0ed3da3f6ba132127decb2cfb97a7e7d05b9fbc81b7c9bc6e19cf11385eceabb",
"docs/r3/correction-proof-HEL-835.txt": "7dea0a5735a89a461fac48f72561188282f9a023dd6017cbf99d6ffe747c260e",
"docs/r3/e2e-HEL-835.py": "381fa13555786adf9ca542f11224b6939fe20f91760eaaa70788fefa11543923",
"docs/r3/e2e-HEL-835.txt": "5574437c36fb01e8baf167379c52e87444554b77b606ec19e9200b2ca5012474",
"docs/r3/fts-probe-HEL-832.txt": "8240648088d661575e5e4b8fb3eac2e0b359a1ad461614f9d3a792729b8cb9e7",
"docs/r3/guard-test-HEL-835.py": "e012f31edf597b69fab631c791faf4c21aa8e038705f76e1709c198305ec33fe",
"docs/r3/guard-test-HEL-835.txt": "298ff142518231ced38d63b49313554da72ccad81248ee4151233081e4001e5a",
"docs/r3/handoff-HEL-832.json": "e391ed66273bae849067f0d000469bc7e62c8559d349e2d99fab018162c8d6c8",
"docs/r3/handoff-HEL-833.json": "01a1df4de10d4b453ab1a45e334efba1554a8f59bffd9c58b77c419820184be1",
"docs/r3/handoff-HEL-834.json": "cde71b060455bdc5ebf0971dfc92f875182ae7e1aadf429439f5888e849a79a7",
"docs/r3/handoff-HEL-835.json": "881a35a7efaeeff1cf37bedab2002a43b0568c914ae99cbfe361460a653b5b6b",
"docs/r3/lint-HEL-833.txt": "82b3e6a6c090a57601d22943bd23fca9218d1031dbe5a7b754092f9a156b4f18",
"docs/r3/probe-seed-HEL-835.py": "52af2bce1a1745bc0c52e23c18ca43af2ce492fd4339bbbee0bdbddfe2264260",
"docs/r3/pytest-baseline-integration-HEL-833.txt": "9d0001187df1efa22add8cad53334914153ca78641f327aedd20a9ac11e10068",
"docs/r3/pytest-factory-fix-HEL-833.txt": "4023bff69febb0dafd1b4b50684b7fa6bd110bea1008934c1f86be0a45608454",
"docs/r3/pytest-factory-fixed-HEL-833.txt": "a9633c21c64d119b38d38318bafc6f01a0e2981700cbacf477ac3094bf0bfdd5",
"docs/r3/pytest-final-HEL-833.txt": "6d854a95805385d3ea0bf71799b6c0755d99eecece242b5d060336c50905f55a",
"docs/r3/pytest-r3-HEL-833.txt": "43c3af9e809b8242f8b3f7218db9a1857ac5b0c852e68eb7eb1238e95fa83dd3",
"docs/r3/pytest-r3-complete-HEL-833.txt": "e572da11882f4e474a135ecfe382ddbdf81b3ce0498ab0352e903a7d5bf18902",
"docs/r3/pytest-r3-final-HEL-833.txt": "5c5c3ab408f4a7facc22b878b2a5df2f5524f00fd39bbf26cc3e248351d130d1",
"docs/r3/pytest-r3-first-HEL-833.txt": "92b40ed9a75ccd1b150ac53e1f74952e15ae9be89c4ef58c2aa80262dbd7e70e",
"docs/r3/pytest-r3-second-HEL-833.txt": "5ed4e8608f1537f81a46a18453b092851c44c5ca8125f69a0c32155f15fbfa37",
"docs/r3/pytest-rerun-HEL-835.txt": "b6664da3662203e0a83a60ed86d011a1e8f04c5b919bbd3dda0281d6a4663d14",
"docs/r3/pytest-restored-HEL-833.txt": "f01bfba7ca8899a889f67218dec8718aaf13dd50c029071239a5d6c45dd0b842",
"docs/r3/pytest-restored-fixed-HEL-833.txt": "1c30ecd3b900aa1b62f3dcb76ca7154e0395e357119559a294f1f8a5c77122cf",
"docs/r3/requirements-HEL-833.txt": "6f91ccbac356f222d7b5f776f50f830da3de37196651d0c04162ecf8b590ba05",
"docs/r3/requirements-HEL-835.txt": "18c86cac189e8e5b3a8718c57d075aa823124afb9337b71fb577c089283a38b3",
"docs/r3/web-build-HEL-833.txt": "7dff1478eb043cad384184db8b1a638e1e6d1b7faefd6b109427cf8c2e22110e",
"docs/r3/web-build-HEL-834.txt": "a85d8e2d9253f115ca9045f43c4c55bde3fa8e3b3797fef84b264fbbe695103e",
"docs/r3/web-build-HEL-835.txt": "c754dde832fc925848e644bac96beafa77d4a9f75aa4ae0e2e360a75feae8765",
"docs/r3/web-lint-HEL-833.txt": "1c2b79600d1b8317501cbd26dcb3c4a931f1e9faa25d1a79017907bf88688d12",
"docs/r3/web-lint-HEL-834.txt": "2813d6a1d44e721a2365b7208e3a02dfc2bac27c7c6d187493c7312926cefffe",
"docs/r3/web-lint-HEL-835.txt": "ee0f22dc391a5a537aed68e83127c9dbd432ac6e4015b9e6e4ad31da00f98ab7",
"docs/r3/web-test-HEL-833.txt": "407ba2dd1bf999497aa2195d25f879404f90c48b8248ee53c08f2df1fbffdeef",
"docs/r3/web-test-HEL-834.txt": "8f5ec1193c72425c02dedd90d23ad23bed133b43020e38b92d0d5f76d1e2ef21",
"docs/r3/web-test-HEL-835.txt": "f51c9ab4f7558471822326dc6d4874a04123abd892c43eefc39c44aaf3609c82",
"docs/requirements/README.md": "9b1fe8e94c74950ed671c381ebd0b9e254673b4f4cc226978153a8cd16549e73",
"docs/requirements/baseline.json": "6cf523a70929c30f13605492df06dfe56b8cd92af23345412a98affd18aaa0c6",
"docs/requirements/changes.json": "a38222d0c4018abab44f1a7aa47028b6e5446376a6483b4de7f031b3e2f926b5",
"docs/requirements/changes.md": "eb967e418e608a42491b2df4f9ff52cd7c98316b7eb1b7e42874c9b5eec673de",
"docs/requirements/coverage.json": "05e17f85781fb9c01e6cf60786f26236e436a7c3730de864e0d77ceb9b79965b",
"docs/requirements/coverage.md": "228fcd71faa5c509b19143f8c871efa764f8c0e9e5ac627e33f3428c407f6658",
"docs/requirements/current.md": "7bbc122a24d2964f12cb557aff8d33997199267fb8142cbf9047254491c11a10",
"docs/requirements/discussion-takeaways-20261025.md": "e2ee666e5193b237a739a8d022a82bbb868cd144f8f4e1fe34f0e3981f94cf7d",
"docs/requirements/handoff-HEL-697.json": "2ddfca357e170e864d482ca469da0129c1cf26ef6b437ee24b8a3d34947b4510",
"docs/requirements/handoff-HEL-811.json": "07a596b9328224dd621c9d1d4b428f2d76ea5dc81c173d9d7a55c0b70ee8d8be",
"docs/requirements/handoff-HEL-816.json": "4577524f25db9b301a3bc68fb970d31e4320cae11e4354d23881bb134223f642",
"docs/requirements/handoff-HEL-821.json": "7f3be159f017a9600708c7d0ff2809c67bd2811498f0c232d040a9601b78862f",
"docs/requirements/handoff-HEL-822.json": "8753bd145ae4ed0d045d0d4f6160101e200b4f1071601228ef2c0b43db1a5303",
"docs/requirements/handoff-HEL-823.json": "5d6337ad15e82538b8b284b4aa7889579a1b3fc89029af221ffdf35d8f0a0fef",
"docs/requirements/handoff-HEL-824.json": "302fd163a35f296d64cecbd521d84b2de525d5bddf27bc1a9dbb9f7e969f6f1d",
"docs/requirements/handoff-HEL-825.json": "60e2c4f87ee1c2a814e934337a2a48801fcef7750cfcc9134578f6c77238d898",
"docs/requirements/handoff-HEL-827.json": "b1e625dac3163d09967c7c8d43de2f0927becefac535f58844ee6045fa2b1f6a",
"docs/requirements/handoff-HEL-828.json": "89f05d82366835a425d0fc5db9bce9a7b600dd0a782608f2b0a494f1d9ffe054",
"docs/requirements/handoff-HEL-830.json": "16fa3048878b08dafc2a8ca02e8d3db417530a1678c5cb31a20987ad5b684de6",
"docs/requirements/handoff-HEL-857.json": "382392235666d735655e241cb4bd3c8208e15b6fdc2427b86de30b9d472064a9",
"docs/requirements/handoff-HEL-858.json": "68c1975f89a926822321c4159d7fe60d6a8569cf382321290aa23fc05b47b538",
"docs/requirements/handoff-HEL-859.json": "388c620e99367a24e499efb01361a04c8dfdcafd36cf575f0a69d28eb79cd09f",
"docs/requirements/handoff-HEL-860.json": "1222921c61630b072f58ee1c643a4da02721d592199b34bd0806d8c5be4e58d7",
"docs/requirements/handoff-HEL-861.json": "d604581abc60ff553fe62ccfe4d98c8b8532da948f8f33ea2bd53333fa7961ff",
"docs/requirements/handoff-HEL-862.json": "d08fda4d2c4f2ef0ca9fd8c4e265cef85b5d520e40da6a322a2d099648b94acc",
"docs/requirements/handoff-HEL-863.json": "30d5322a2673d6d4d71bb07153d3d974a636af1f04f96f31e85a7c082b568c86",
"docs/requirements/handoff-HEL-864.json": "909213044fa7a75f8043c95bb071636ff376200a124e4d1300f8ff65bbec802e",
"docs/requirements/handoff-HEL-865.json": "1e361d2b7b2a66f56f88da021fda5a8432308c855975b0613cf35c9da525852d",
"docs/requirements/handoff-HEL-866.json": "539759490fb6f1795b5a325632e3720a7332c06640038fbbc958eca11df59324",
"docs/requirements/handoff-HEL-867.json": "3eb2fd69e5231e257967160195bcc3ee93975f27c339d3eefe4f295e0efd818e",
"docs/requirements/handoff-HEL-868.json": "dd609e7828b8ff20c13a6b938fce40d77f38aa30b978bd451b468b2169d5db0c",
"docs/requirements/handoff-HEL-869.json": "8954ea038f375934732adc2cfbe60e75eaa23969b4fb163bca29512d6520b7b4",
"docs/requirements/handoff-HEL-870.json": "71baea0fb2cec30b7ff40d3382fa749b0c46213ad34ac64d02456ac1c0527bdc",
"docs/requirements/handoff-HEL-874.json": "73cd0a585b86c2f3b03d7b4bdca65f933065efc0c55e82fdef550d1fed253a9f",
"docs/requirements/handoff-HEL-875.json": "984f362ba3cb42434c53194d479c1dfe4b03113261a74b46e7e6ff599e8ce5ae",
"docs/requirements/handoff-HEL-876.json": "df18ad67b29ab72b9ac9a2928d88b2ab25d4ae0975b3fc2c41802f97ddd5e8cd",
"docs/requirements/handoff-HEL-877.json": "238dc7f98fe7617051a7d121175f450cf9fbe8aaeb2153e86fc9ea5daa93fc1e",
"docs/requirements/handoff-HEL-878.json": "258cf2935f5aafc20cd7a80a9550ae062024849ff39c078068f371692bbaa516",
"docs/requirements/handoff-HEL-879.json": "ce8aad74a3abebefb1f4b4b0d72b9bcd93a9286f8ebb446fc9359957df5896f6",
"docs/requirements/handoff-HEL-880.json": "9506c3c5c1278b090d1e3eaf0f6191290af6114031e1f0e1ec514efeb74fb275",
"docs/requirements/handoff-HEL-882.json": "8901262dc1714b4f6264c2f375e7b12e977653ed5ad80a5efacf48754c1c1089",
"docs/requirements/handoff-HEL-885.json": "caf97d45ef8c23777942ed504d5ca6d3411ab5b86fda58c5ba308e31ffb80d32",
"docs/requirements/handoff-HEL-886.json": "5e965ee60fba01a4e7ebde534c76177aa509421aea31e33beb83d01387b7413b",
"docs/requirements/handoff-HEL-889.json": "bc28ee707a433d28f4efef9c33088781f98e1fe323f0c228f9444789d37363d3",
"docs/requirements/handoff-HEL-894.json": "932251b76b952705a97e5b4259c0bb5596d8b51347c4c91ee082146a4a3fa3fa",
"docs/requirements/handoff-HEL-895.json": "afb68a2db07dbf791a59e7878a646bd6cc30e26c8d94943551eb769024a79bbf",
"docs/requirements/handoff-HEL-896.json": "9b3f7533e4ead1ffbca2b502c3be35223648314e74aac907f29c38cb5b8b4955",
"docs/requirements/handoff-HEL-897.json": "56cf0e97b5035ccfaaff3f7dd219ca30352df600fa4c9684eb690e4e44731bc9",
"docs/requirements/handoff-HEL-898.json": "af31e5dca084900b4b8e61adaeeb6868a377f74ba1a5afb57219c4ff783adb31",
"docs/requirements/handoff-HEL-899.json": "580ad2fa309d1bfeb78a4e295205f9d2e270654933a09d8a939be291e2a0408c",
"docs/requirements/handoff-HEL-900.json": "297b5c257b5bf55b2e09c49ef8a9646f01060dd74b92433d8bd3e32c9ffce218",
"docs/requirements/implementation-verification.md": "874a6a712fb1c5781c4b9e0dd31f831d42cd44603dec61c67c6d502d3c833819",
"docs/requirements/mature-reuse-handoff.json": "7b3a78495ff71b3dbc7498552b086d19c5f1b774388f02be78e7a0b225e40989",
"docs/requirements/source-manifest.json": "6b3e5cc6836c881a915b812164e7abcc1092b8f19f0f791686ab9324f5578aa6",
"docs/requirements/sources/.gitattributes": "1e1b7f6dd91450faec143a53cdc10ad708093a09d0ee5660ed1436abeed33e6b",
"docs/requirements/sources/approved-frontend-spec-R2.md": "d95edfe36dd08d4282176f5e7615750ddb5baa75d3a542b83ed3f9d8b24e02c8",
"docs/requirements/sources/comment-evidence-custom-voice-20261027.json": "ed72bd78218e1c0b4ca644bf933d6bdb2e3aa8cd2c0333f3410f6b60845bd3cb",
"docs/requirements/sources/comment-evidence-dynamic-sample-20261023.json": "fe7528a2b68aca99968734b200d9d27a17d85f5110e60c6f52d7c569755c260d",
"docs/requirements/sources/comment-evidence-full-experience-20261026.json": "75dd55b8755bdad217b44c3f516c9372b046aae06dff511527014c7b209eccff",
"docs/requirements/sources/comment-evidence-mature-reuse-20261025.json": "38ca076e26477e89cd161eaadb0fb00b0ee7bb5a26de2bd6ab41de52faaae79e",
"docs/requirements/sources/comment-evidence-shared-space-20261023.json": "e56299db726e64fbeea9e07d352066ef2b578889cc9a33cf1789e3234862130d",
"docs/requirements/sources/comment-evidence-visual-role-20261022.json": "ecf30eb323129dca2a0d9c2ff2e0163884ee94d3fc7828b0540cd45f03103797",
"docs/requirements/sources/comment-evidence.json": "d08ca2359e16d048fdc46b4b5ceee536d3937bbdb7b16e42598b831e508f2370",
"docs/requirements/sources/design-acceptance-v1.md": "21f76eb69da11e8b2f3a5addb2f7303c1d9f766953595924232582b428013b12",
"docs/requirements/sources/dynamic-sample-effect-acceptance-20261023.md": "f5e9fde65a58ebc365de47569053ad7c30ce77ae82bb78773cc070e3cbffd90e",
"docs/requirements/sources/frontend-architecture-v1.md": "eb05f1707a932014d613072e8c5d95bc8d6a0281390dc93c000d63851ecbbc86",
"docs/requirements/sources/governance-proposal-approved.md": "ff356076d682edad3df54e8257799000127bdbb478d1fbc43d37886a28d65c09",
"docs/requirements/sources/integration-plan-v1.md": "454af1d4a3b0360d33aea9c0e97f738b320968ba7be68e012a5fcec6f6ed0169",
"docs/requirements/sources/mature-reuse-principle-20261025.md": "a2a75e071d3af5db722727bfaf8083bb60d07dcdd74e90d1e1374c1aafd2064d",
"docs/requirements/sources/other-agent-discussion-20261025.txt": "42cd97029d73eace110e49e0566343dfeb25fe4f0633a10b78b126289938aaeb",
"docs/requirements/sources/product-decisions-v1.6.json": "ccc5c50c5604385208eb054caac01e27fc63367dac7731e1be9775a1e5cf46ab",
"docs/requirements/sources/shared-space-static-acceptance-20261023.md": "9aec04ab40e40ce275c75104f004cd3d1473ff8fbef428767afbbe5fafd09cb9",
"docs/requirements/sources/visual-confirmation-v2.md": "6e0229aaf693f215cc9dcc647be694aeafb3770683b21bb7e3e30291af60255a",
"docs/requirements/task-handoff-template.md": "8ee4d6d4c237e8a2c134ba751426975b0e24f03bfd6ac048b80642c69b59e445",
"docs/requirements/task-migration.json": "a5131adbdf14a79df01992d2cbbc4b7aea5b8b377c7115a80cedb04073f9bc7d",
"docs/requirements/verification.json": "7da6c2732cf28d46f8deaafc7ec571b8fc37bbd7efec0126ba339e51bbe12ace",
"docs/reuse-hel847/mature-reuse-plan-v3.md": "584d92cfe67e064df958d8cf8cd66ab49bf54b7426a3abbc208f279b2690256c",
"docs/reuse-hel847/mature-reuse-plan-v4.md": "351e210133783bc5431414ed887b78a897cc09dd6a3cbf4869046417d0f1c795",
"docs/reuse-hel847/openclaw-reuse-plan.md": "5df74412afdff07ee1ab65a96ccb39c4c06f1df7b59651ab8d5ab6470d77546a",
"docs/reuse-hel847/probe-result.json": "de08b408dd632ce672aff2c090d8afc8b125e462ff26e5564df674f784f7db59",
"docs/reuse-hel847/probe_recall.py": "12a7ce7898e3e2541910ce68eb01ef5de6bb2a654b8c762fc76f7c6c64efb98d",
"docs/rework/REVIEW.md": "2cb3425b9df96c62fd81daf0ab22a5d7016de6b74cffdc6bef49a0658d40f464",
"docs/rework/appearance-fog.png": "0e4ca6e32ec31bc63910924b9cc34850caf8dc831b63255bc58d2cd2f493db19",
"docs/rework/backup-fog.png": "590b6206e4ab837f02ae524e726748d3e11dc8c62506ffcf642fd971dd1ba28e",
"docs/rework/birth-calendar-help-moon.png": "765648e39fb33966117f255772c492bb4a510f00279c1a4e2e81173eeae78305",
"docs/rework/birth-preview-moon.png": "de9264431b010e3c856e8436bf228e016d289c4151b62972d49316045bf7c899",
"docs/rework/browser-results.json": "6018d335c81995f7f3a2226183f52a558e9984fe67d25ad63c331d676ca2ea27",
"docs/rework/city-fog.png": "fbab999af93a61a35da2b994d5f78e4a3129289fe749390d3d3e71083f72093d",
"docs/rework/compact-first-press-390-420-moon.png": "a3e84420b022984ed2af6e34c220b649bcd0408f6dd704093144b5e20cf44b86",
"docs/rework/compact-first-press-390-430-moon.png": "8e6b217c1734cf9aaaee616ff9d6c6e7c503ccaa11ad001f5469643321c139bc",
"docs/rework/demo-voice-390.png": "115a5c6af987609469f860ec111c59d64892cf4e618693cfc8367d19f716f2cd",
"docs/rework/existing-birth-fog.png": "e05f816d01a29642457e2f9c38dddbe7f4d4e2c2cf241b94ac96dfd5ba705694",
"docs/rework/existing-growth-fog.png": "65a30346ee685b8249e1dbed5217b15b5422ac9e0453a64417fd68c991cb0e24",
"docs/rework/full-history-fog.png": "d95eb4e69c98450d0b20e982eee49eababa4f998ef3b51bd76549e14d3f6d02b",
"docs/rework/full-history-moon.png": "db0fe19658abff84ced08744fa4a3e443dcaf8ddbb495862077d552e2484ac3b",
"docs/rework/hel874/README.md": "cf65d13af9c8d6967fa815fafcd1a289ebd3a866018d925b549b64c697973d93",
"docs/rework/hel874/erratum-hel882.json": "7914d30ddcfe8f32e58ec181b78a9c0b60674f6b3b0d1975d0cc21a235cb52c1",
"docs/rework/hel874/real-sdk-after.json": "5f342b3eea44339eeaf6d90b001ef39575899ca5e3ef854054cad2a6c68eb9e1",
"docs/rework/hel874/real-sdk-before.json": "277f57bc4dd9ba556e65f25b1a042a6f5cbc940ad1e7170316885e3e752ae2d2",
"docs/rework/hel879/browser/real-boot.png": "5610d244e72ef0e5e675a6fe48fc36bda5e797657a49ea70db79355b563a612f",
"docs/rework/hel879/browser/real-browser.json": "f046bd865790120897602baf7179d51045ea3cceeec45acfeabc2acc7ff1a1b1",
"docs/rework/hel879/browser/real-card.png": "366c80949935b1de5dc16df718e8e4126c873f172dd2f0f0bfdd6893fbd608bc",
"docs/rework/hel879/browser/real-gap.png": "c1a7709ad49b82db1fdb86e814f61abf2c573c6ff79f8fe16c98d779613ab8f0",
"docs/rework/hel879/browser/real-record.png": "7dfd5a631c2f9916c1ae35d2a7d60c280c4814132becea69e80af6c98059bd1c",
"docs/rework/hel879/browser/real-stage.png": "e2e5bac215e47a46e088cc5ed518e1f12ca7a70acc61df4397ae342680066b3f",
"docs/rework/hel879/d1-audition/README.md": "6358efdc81ba95fdafc209705a1674520de07e8711bd2efe58f1d93ba60d8c62",
"docs/rework/hel879/d1-audition/manifest.json": "66605e35df3ea8f73c8fde6f6efa3163cbb1359d65f48810b1c88be556b2f9fd",
"docs/rework/hel879/regression-close-ownership.json": "4fe1dcdb6aeaa514251b9fd8c72efe6d4654eeadcdfb0001786c1d6dee3a3a1b",
"docs/rework/hel879/regression-inflight.json": "bda736af33860f827df6024dfd45da1bc089c8afbf2756a0920d9592728d3ecf",
"docs/rework/hel879/regression-mem0-real.json": "da2d6bb865ec2abc8456c7749bcdd3ef3dbdce944f2aa6da2d4641128f28a4d0",
"docs/rework/hel879/regression-semantic-recovery.json": "e78976c1e2856988accf0df0727e52a7880f425e31d9f8984b1454f1d68a44cf",
"docs/rework/hel879/regression-shutdown-queued.json": "b069772ad232b1d8b4f29875c7e7bec9d133a061835e86db3986f7dc00ea7344",
"docs/rework/hel879/release-artifacts.json": "3212d6200d57bb5c1b43c584c8eda5ee25f37444b2b13bf4e02174f739f7bcde",
"docs/rework/hel879/release-check/hel882-release-check.json": "5ba134d5cced47431bb631803c5617a84236f099e06c90153a3d27d3e503d1ad",
"docs/rework/hel879/release-snapshot/selftest.json": "afa3fcfc8a76f4eb5ef8cad2e6578a49c89f39e1b83cacd4feb2284f831f0ec7",
"docs/rework/hel879/sdk-audit/sdk-audit.json": "5fc52280ba998f4d57ef8d4a6d5db8bfcc12c4b3e3ecef2aba79172082afaf67",
"docs/rework/hel879/sdk-audit/sdk-check-hel879-rework.log": "99f0c00dcfd7366c352780542bb169c5ce9df3e2883dc2a776cb5aec10a9042b",
"docs/rework/hel879/sdk-audit/summary.md": "28786b10f79109ce921791a1635d56338d755a49d3c371e84dc059b8173908c5",
"docs/rework/hel879/sdk-check-hel879-remaining.log": "95e0b29d63f32adb4551e7195dad44926f741ae3b8dae7c0f15600fd6242c9e0",
"docs/rework/hel879/sdk-check-hel879-timing.log": "32158bd3ead2d05e76c7b6d43ed9342a9b4df4bfe5be115f7065d6de312b5f5e",
"docs/rework/hel879/timing-fix/delayed-dispatch-after.json": "368771cc63b83f7a5cd202afc57eaa639856e69a2e32414432031b5aaa846040",
"docs/rework/hel879/timing-fix/delayed-dispatch-before.json": "16942e6d43535fb175c2c69283a680df2c139f86b820b1e3093fdcbca7bedce2",
"docs/rework/hel879/timing-fix/sdk-probe-after.json": "8dce4cafc9a463149c644e83eb7fc2e239d8fb95c4a9ba1885bdcfb0af7dc4cd",
"docs/rework/hel879/timing-fix/sdk-probe-before.json": "42ed372966e41ebf1bfdcddfea22c4193f4bcf24a35c09accdd1db1cea1119d4",
"docs/rework/hel879/timing-fix/summary.md": "3b888256ce8bbd8b1994a3f9eeae59d0d35d3d6d5121664141ac2559e4b667e0",
"docs/rework/hel879/unified-review/final-review-20261027.md": "7c73a5d00a8d12e875f6e6b280cdc0264971d7f0b11e3b2a588da324f2f49690",
"docs/rework/hel879/unified-review/frames/sidecar.json": "6793b779e4469943f9ddaca78cb21eab1e8f5466521896b4f397792e863f78b4",
"docs/rework/hel879/unified-review/frames/worker-frames-raw.json": "c2bb153888d5f94957a0f6dd1cc4efcc6e12c658c75f5a1029ce141207e606b5",
"docs/rework/hel879/unified-review/logs/requirements-fc905fe-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel879/unified-review/logs/requirements-fc905fe.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel879/unified-review/logs/requirements-main-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel879/unified-review/logs/requirements-main.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel879/unified-review/r5-facts-20261027.md": "5bf2b54c45d2c3730801afd0db3d0246362278dc49a3274266b8760451371c69",
"docs/rework/hel879/unified-review/r5-test-package.md": "82b1f7fadab74ab1aea1f336af84c2f96f03c22c9abf5faabcd68d4216dfa7c8",
"docs/rework/hel879/unified-review/seq-job-analysis.md": "cf621542cf609083f8ce4597d98727c1eecc5b6d3442a8dfafa44bdf0d0ed7d3",
"docs/rework/hel879/unified-review/summary.md": "0b6f576351099185d74bc294c35343643d41a38f75fb6c2cedd8cf23cfbcd2a7",
"docs/rework/hel879/unified-review/wss-bridge-smoke.log": "c2c6f0fefa84be22b06bd140a34916563995cb803bfd199767241f1beaa76f38",
"docs/rework/hel879/voice-seam-interfaces.md": "12a107bf05cb17262bb5c9a24614092c7967aa43c0c4c7ff10d43444536a3293",
"docs/rework/hel882/HEL-882-sdk-addon-stub-switch.json": "1fa6407d9cb9fd39ae9b576cf530c77891bf0e1c799033cb5a94b33d0ef86ec5",
"docs/rework/hel882/README.md": "a87fd204e50c56531700f176472d005f7e89868c420b5f7620fdefd84205eaad",
"docs/rework/hel882/addon_stub_switch_hel882.py": "eb8e3016c517abfc2ebd4e4b5fba5f7b4fbeade178dbae3f4c0bc09c3a48dcbb",
"docs/rework/hel882/manager-review-api-rerun.json": "d3c9cc125a6d4494dda7ab98bd0a7b6a049338ed4f8491f51cb7c59b168c9699",
"docs/rework/hel882/manager-review-sdk-rerun.json": "f30a0f95e21c306f948bf748e799e67b9f449bfa0ce575eff7868492f92febec",
"docs/rework/hel882/real-sdk-hel882.json": "8cce7b2e9574089e235961594cd7335863dafcaa2383f47143e5a2a4ee82f9c8",
"docs/rework/hel882/release-offline-hel882.json": "854f11fa19d78ebfe8bbf7e03f378ae4097dd244de0d91b38152ae0f3d543316",
"docs/rework/hel882/review_hel882_api.py": "bc58a1faad731dfbc637e78bfd9b5e8dcf8b1b6c088121b1254bc94631f23970",
"docs/rework/hel882/review_hel882_sdk.py": "0d27a64ca971006747ded0749bd156bbc5900bb327c3bf447b797cc005f8534c",
"docs/rework/hel885/README.md": "a6b90140b73a59ee0b019d7602e7da2e77313fe2b2af9af47527384ae1d5dfc6",
"docs/rework/hel885/manager-review-extra-rerun.json": "31a20ce30ba851734ffc8f1b318b1e2324d0c22f8a4e0198630b3e492363b891",
"docs/rework/hel885/manager-review-partial-rerun.json": "b7fb2ff659bc701ee3cad88aff49cde96f043eddbe53724624e97b3a4bbdcc22",
"docs/rework/hel885/real-sdk-contract.json": "e6850945a4b44cec7c3b31486995521ab4a82b4c0156f41d103efba350c98b56",
"docs/rework/hel885/recovery-real-sdk.json": "6e870e0356162293687ee68bfb50afa6d11043e03e7028d018b2087c82eba1f2",
"docs/rework/hel885/release-offline-hel885.json": "c31cb1119541a70df93f1342d9d8ad12e196a8a5ffed768904ba03bc4770c5fd",
"docs/rework/hel885/review_hel882_extra.py": "8153c198a2a926916c593fa5369abda9eb8f6f7ccac7dc8e26b273cdaa6497a2",
"docs/rework/hel885/review_hel882_partial.py": "1f8f72e55e13218b92857f8a89bac95c7a83c139b844f1ebb8cefbc22b27de03",
"docs/rework/hel886/HEL-885-inflight-baseline-78b6267.json": "1f8c0edfbc88482fcd2c6186b70fb482d537ed3951ffb795f5e885d5c69a51fc",
"docs/rework/hel886/HEL-886-inflight-rerun.json": "f0fdd0056c7c569efb8103f2c82170ba2ecd6c2d608c1e6a5ad0065b5f8d59d6",
"docs/rework/hel886/contract-hel874-rerun.json": "e4c66881f0c19d39edbc16ede18f53151bec333bf3e6aa6b773d3f9a3d706f8f",
"docs/rework/hel886/inflight-real-sdk.json": "5803ad65587489b6d2382902b70922537c47a8d233a041ac043c7060f1fa3741",
"docs/rework/hel886/manager-review-extra-rerun.log": "8f46c743ce7be4668c48fc91f2dd2031bb1ae4bf4c2c8586e61dd16ea1bedde8",
"docs/rework/hel886/manager-review-partial-rerun.log": "3531914e4218a4d7528f7b50a845b778d0bdf41c1266b210f44428e80de494be",
"docs/rework/hel886/original-verbatim-after-fix.log": "753944017e39033874b2a026b31ef2e9c4e0137502847b29da1c0f08d8817a21",
"docs/rework/hel886/recovery-hel885-rerun.json": "db162f62e3cce19404045028a65fe1ef1b4f6823e2590a3898d36dba8d00af0d",
"docs/rework/hel886/release-offline-hel886.json": "671e73775e313264a31bb369ca4ce023130f800e74dcc56561c59a00abc4e3d9",
"docs/rework/hel886/rerun-inflight-fixed.log": "8727ba7711137e6b57e7e1baff6cb9cd5e7b531a1860fdd96afabc4fca6f15d5",
"docs/rework/hel886/review_hel886_inflight_rerun.py": "d806c9a884314a0646d7d263f533f33daba6707cc040ba59fd7f73c91534c785",
"docs/rework/hel889/HEL-889-edges-rerun-fixed.json": "1e2da91b0f5af7e0e9f20000be9e0510d776dcbe1189775ded59c9a69e8a84b2",
"docs/rework/hel889/README.md": "0c71977bb34d7687ea53269edce597827bb550a136d6ba6b6961989c4b354ab0",
"docs/rework/hel889/contract-hel874-rerun.json": "7d535f3128fb06915f38f6b00244b2faeaaa368ab04e6e8d2b2b967fe92052c3",
"docs/rework/hel889/contract-hel874-rerun.log": "63a94f494dfb25f15f834a8927b7897c9dd5cd4647b12485dd78bfb318cb609b",
"docs/rework/hel889/edges-real-sdk-hel889.json": "24ed3a2be6aca29ab6e9a9cccc5b9f468745237ed9d45105e4259df7094dd25a",
"docs/rework/hel889/edges-real-sdk-hel889.log": "4f6ac192eb7bd952d5560ce1ff6ef879940bb6d25bb42aa0e9a6d8ab1d0dbf41",
"docs/rework/hel889/edges-rerun-fixed.log": "35da33b2f898a177b0bb781532e4b8fc6da7c5a5d57d87649f5a85d8d697319a",
"docs/rework/hel889/edges-verbatim-before-fix-f1b50ad.json": "622db8e329e710c56949a6e938b0eaab926f22a5377ac1496c718e268734d63b",
"docs/rework/hel889/edges-verbatim-before-fix-f1b50ad.log": "d561d2a1e617e9837354bb9a0617c710e279ef83d386b401bc4751c8fcdaa238",
"docs/rework/hel889/inflight-hel886-rerun.json": "429a419b4329fda60f249a83e30eb8820ba8681b769c8e11ef910d0fe34a0d9c",
"docs/rework/hel889/inflight-hel886-rerun.log": "51132b0edfa1707d32a926fc1d76cb467c6aebcab82f0de23211aa4fae757bfa",
"docs/rework/hel889/inflight885-verbatim-after-fix.log": "1bc0a7063fe271c670a945121cdf307ec5ea5e96d8dc9bf1ca211895ff7c50c6",
"docs/rework/hel889/manager-review-extra-rerun.json": "31a20ce30ba851734ffc8f1b318b1e2324d0c22f8a4e0198630b3e492363b891",
"docs/rework/hel889/manager-review-extra-rerun.log": "8f46c743ce7be4668c48fc91f2dd2031bb1ae4bf4c2c8586e61dd16ea1bedde8",
"docs/rework/hel889/manager-review-partial-rerun.json": "193040328c630fed808a42507714a214e2db6e5a88042ed68b8fcbb655a50560",
"docs/rework/hel889/manager-review-partial-rerun.log": "6948e3ff7e268d3b940fed813030ad0b3e1f488b0374957e2d6bce00d0c3ca3f",
"docs/rework/hel889/original-verbatim-after-fix.log": "2b2bf6d1a53b31803f9274b6c83bf18a80be13d04c071336822f36e8ee935b7d",
"docs/rework/hel889/recovery-hel885-rerun.json": "e2d603b777bb949aa9cbc1ffab027b004ed9ebcdc292c705c0b74c9c6767597b",
"docs/rework/hel889/recovery-hel885-rerun.log": "193512e7acc2df77a4cc1649515ca5fa0b963c165b733babbcafe50fbc4ff349",
"docs/rework/hel889/release-offline-hel889.json": "a91e67b5492b9aa196fbc99a1a18605396441a2534cddf7ea14b22dd22bf14b5",
"docs/rework/hel889/release-offline-hel889.log": "6adda1a9d16e458c02d6a9b6e9cd65b9aebfdc81d826aeff55f8c1df837580d7",
"docs/rework/hel889/review_hel886_edges_rerun_hel889.py": "2d9805f4f2bb210877ea03106012068d657e3367f4ca5769439beef53a3dfc69",
"docs/rework/hel894/README.md": "f7f6ffc21313507a1ca3d8c45f330f4229f15d50c274ae9252e5b4e28226c39a",
"docs/rework/hel894/before-ceb0f35.json": "6bdd1f12de0b74bb281ab47c969945bc384671fa2ef92d6e7c548967079ec824",
"docs/rework/hel894/before-ceb0f35.log": "cc5aa75523def5975476fcd45825d518914e886b46e75748b871bdddfc9adfb7",
"docs/rework/hel894/close-ownership-real-sdk.json": "35edac5f922f8c1bc556ce109e0cb0dd44aabcb7864728b14c4f6c14ba1bc03c",
"docs/rework/hel894/close-ownership-real-sdk.log": "d152d5d69231a05eb73620316db9493528eaac474451126a0e7696a4f2fdf432",
"docs/rework/hel894/contract-hel874-rerun.json": "c1ebe639712ccb76c54f53e531d7fb5689a2bfb94eb2a0220e0a9b2bc3fc8b76",
"docs/rework/hel894/edges-rerun-hel889.json": "2b5fd2dd6aa60c9b1e3a0b1b4ebb62776a006dae1b89b2099f8b99b65e30034b",
"docs/rework/hel894/inflight-hel886-rerun.json": "16fd3f06e38c55d16cd49eee36d505950c4d6df42c5d88f7fddfdf08e468966d",
"docs/rework/hel894/manager-review-extra-rerun.json": "31a20ce30ba851734ffc8f1b318b1e2324d0c22f8a4e0198630b3e492363b891",
"docs/rework/hel894/manager-review-partial-rerun.json": "b0ffe7b2a765ce230641780a11be32ac933e3079ca981db8c32fccb3d273a761",
"docs/rework/hel894/recovery-hel885-rerun.json": "072838353bf06aa0eac3c8cfa9a653aa1aba2c6e40cf4fa78250f35eed848b35",
"docs/rework/hel894/release-offline-hel894.json": "a01c5dcb054981cd2e966d809ab04861b678109eb901e7c627dd3417649c5885",
"docs/rework/hel894/shutdown-queued-hel889-rerun.json": "2dc2e61073fba8f128ecedba8bf5c6040580a936ccfab00677d78352def33007",
"docs/rework/hel894/verbatim-after-fix.log": "562eb581b196c64b3d379f733f2bca500042d5b39d84027620afe9e0e61f95f2",
"docs/rework/hel895/http-pagination.json": "6d7a24e89fca3ae0dcb4b3ed4ccdbad05315c428467c286273c5a443f9ee1811",
"docs/rework/hel895/real-boot.png": "71f15746f8e2c9086980b1b0937a88b14ea6f409d67ea2e87ca3f89bb34f7788",
"docs/rework/hel895/real-browser.json": "bafb54812a41406c346082287eff8fbad69edfd12521a58dba544cd8a3ec22cf",
"docs/rework/hel895/real-card.png": "d29907914917ba499138c8aed562c5ce14e7b8819b37d168f65ac05e959d09b6",
"docs/rework/hel895/real-gap.png": "4ac193acaaf2cc4f6b84e5b6c3ceb83b95ad69dade965fc1b1237373fa092409",
"docs/rework/hel895/real-stage.png": "0007a60053b154c2c4b7754b0389e69566174df22e50b66cdbdcd4e73d953f1f",
"docs/rework/hel896/README.md": "2e80276baee4bf1bc4836293b74aa3c2803a39bbb4d3a1fd0651bb6a3c5c7f7c",
"docs/rework/hel896/browser-before/hel895_real.mjs.original": "d7f682aa268f78576974b2ab515ca87aa630a27aa88ccf954c5c85da67abe777",
"docs/rework/hel896/browser-before/real-boot.png": "9e8f192db66acb7e12fdbb68e197878b4fe355076adf5d68c7c4f66182ce8f60",
"docs/rework/hel896/browser-before/real-browser.json": "bafb54812a41406c346082287eff8fbad69edfd12521a58dba544cd8a3ec22cf",
"docs/rework/hel896/browser-before/real-card.png": "51475111fce910ce7df91a45274c8522249013bb8a5f0691070b3ea31e89bb74",
"docs/rework/hel896/browser-before/real-gap.png": "045c0f025b17780828d12f7f742521151cdf2a26eb88a1f26a2221f9ea59d1bb",
"docs/rework/hel896/browser-before/real-stage.png": "c7c4996d36e4542f0d6075a1faa999f06dac96e2c8baabd9bff5fa1bcb248ef2",
"docs/rework/hel896/browser/real-boot.png": "574bdef19cb49b9767d760e17806e9b4be4c74531c9165b1f10108ef6a23bc2e",
"docs/rework/hel896/browser/real-browser.json": "f046bd865790120897602baf7179d51045ea3cceeec45acfeabc2acc7ff1a1b1",
"docs/rework/hel896/browser/real-card.png": "eeb3b7713ca9950e42fd7dad33ea7322fe31fa4ff2457c8278055403fa571a70",
"docs/rework/hel896/browser/real-gap.png": "623ddc39640623356fdd3db5b1fbec633bacb55d6264866fc12df9d3859f3ebe",
"docs/rework/hel896/browser/real-record.png": "8925684798fcb879058d15b25cd681920ffb3bf2f51f680f4f057e86beaec8f6",
"docs/rework/hel896/browser/real-stage.png": "e73e8bc79d03c2dfa8d446031c661e78597c6626ff3845cd0ca7a9ce3fbd4846",
"docs/rework/hel896/browser/run-commands.txt": "fe9554e7bc876041b9aac92a434cd48a98746a4f54aed29cbc4e60a73d12708a",
"docs/rework/hel896/budget-split/budget-split.json": "2306bf31bf46c0b7ef69589dcd33bef67988789ec7866c938244140f5597986e",
"docs/rework/hel896/http-pagination-rerun.json": "936c07b50d7487e7121c88c8c64e0b440ffd406966e705acc46504c801f253af",
"docs/rework/hel896/regression/verify_close_ownership_hel894.json": "1cf311a752222c9f9e8247e207abcd54a02ec74f1f3aaa267de1e7071519dc6a",
"docs/rework/hel896/regression/verify_inflight_lifecycle_hel886.json": "0306d9aa599bd21c71e942522323285dd7fca7f975cc876b8d58ae183730ae36",
"docs/rework/hel896/regression/verify_mem0_real_hel874.json": "4b0a3f75848d017c9ecc9e8b15d080fad40320280a7af41bfc4f5909ec05c519",
"docs/rework/hel896/regression/verify_semantic_recovery_hel885.json": "2ca8109bb8e18d2e42341c9f0002c99f2a18bebaae55e106c87440e5946d056a",
"docs/rework/hel896/regression/verify_shutdown_queued_hel889.json": "51ce6516daf0f1625bc41be587e28c2f7c7ece7918083ad3ab32e61be19aa156",
"docs/rework/hel896/release-snapshot/selftest.json": "8cd9e006c26c24307b58707bd83bba785a4f6438e9f3d922fbb7318d3b0f4121",
"docs/rework/hel896/sdk-audit/sdk-audit.json": "16447ddd5a0fdd5d42c60c55544666ea9d729c66d3b2d72d088d845c92aaf947",
"docs/rework/hel896/sdk-audit/summary.md": "28786b10f79109ce921791a1635d56338d755a49d3c371e84dc059b8173908c5",
"docs/rework/hel897/author-MANIFEST.md": "b77c014ba496ce1d0d25d7af7166fdf5d2b6ee9f88dd55bef0b8dc5d399faaeb",
"docs/rework/hel897/preservation-review.md": "da576648238dfac4b656976f8ddab0a73dca9b835f8f8df3c2a331c2265b8365",
"docs/rework/hel897/preservation.json": "f4b42d482dcc075112e49775b75dd42d98dba587db9caf499cc4bbc1ce4b853a",
"docs/rework/hel897/verification/baseline-voice.log": "bdac37ef3f11862f97e4203e5f89398cc3733f94614e038628d2764fff118ec9",
"docs/rework/hel897/verification/build.log": "ae7afd2c0039c63fae3a98d1e6f9e2297f6571daa5db6e9dc5ede1b39b29bb5a",
"docs/rework/hel897/verification/lint.log": "cd386c675832086bd4e971d9013795802169abdc5f17ed5685bec83cd3e62a17",
"docs/rework/hel897/verification/requirements-0-878.log": "390db0ca70a614ce52762e0c58f761ff9c352d3aa7f33d33e44220473a91b43f",
"docs/rework/hel897/verification/requirements-0-897.log": "a28d30af3b4008b7dcf00a8368c56be4fe9ca90a5dca597665dedb31ede63130",
"docs/rework/hel897/verification/requirements-0-plain.log": "bdc7c0adceb6a4167ed86405cca65372102be43fe14aadfffef649d00d3bccb5",
"docs/rework/hel897/verification/requirements-1-878.log": "d047c9640b88fc2bae9935b1edc673fa78b1f395e2cd7273a970f64559260328",
"docs/rework/hel897/verification/requirements-1-897.log": "e7850ddeba1c4599986f0baa681c03a05483b50242366c24929d77b9903671e6",
"docs/rework/hel897/verification/requirements-1-plain.log": "23ce0f8507fea249ca04266ff2446e1ddc6cd548507cbc5a593b5cbee195ccc9",
"docs/rework/hel897/verification/requirements-2-878.log": "1841b81cea75ee5c9c1f7805f15a9097064803a415f34baf487cc636dd3e1247",
"docs/rework/hel897/verification/requirements-2-897.log": "fd6c1d457a39b7dd139e904013b1294ca1fba3963873957c99e59065e0c181e0",
"docs/rework/hel897/verification/requirements-2-plain.log": "dfcfb121a77a5e61f8c86bf85ceb3f552599a2d2208dae7e03d142747289d4ec",
"docs/rework/hel897/verification/requirements-3-878.log": "4b74e9de0e9c3bf5c677b6190886f225ee5e030e08e46b75c317bdcfc3c8daaa",
"docs/rework/hel897/verification/requirements-3-897.log": "83329b71f5a62662dccf6f55a8818ab4c756a6608fc57f345cf66f2eddad325f",
"docs/rework/hel897/verification/requirements-3-plain.log": "5ef5db73083d4072020ea7f77a06977266263cefb46aa3a49488f5e8792390b3",
"docs/rework/hel897/verification/typecheck.log": "d33b2ad7a4c36716723e149dda586ca9ce7e7330f24a096f4763bcc5d52c4bfa",
"docs/rework/hel897/verification/vitest-all.log": "718c853381bf572be1dd716eb747adf095ffefc9a97d847c06946ee4a09a17a4",
"docs/rework/hel898/HEL-898.patch": "9e653a45bcee4e1927c797bb94869e259d98298188485c03b5bfb42a221fea37",
"docs/rework/hel898/MANIFEST-HEL-898.json": "f9744e3513129df9c23a86c3dfa934ade47e3d4b3e53ad7adbc0ef706121811b",
"docs/rework/hel898/author-logs/before-fix-voice.log": "1d19bb00239c89466a1e7fd893ee002a492e0c666ef211042f17968107cecd32",
"docs/rework/hel898/author-logs/browser-hel878.log": "cb4d8e2f1b7f75b4e36bf94e053ae3ac7d297b66cf7c479018a67e211e48cde3",
"docs/rework/hel898/author-logs/browser-hel898.log": "438e5a87615ec3005040fa2a0946244fa6c4d083a9629160d8243a3e0c09dbe8",
"docs/rework/hel898/author-logs/build.log": "f2e1d0c89c8a81d808dab307f891221d99277960a55b9ef96c0577b43bf692ad",
"docs/rework/hel898/author-logs/lint.log": "1f1b4b564f7342fa50ed2a44b0c1fb110476efc42b04e1ec9880053b3c5fd940",
"docs/rework/hel898/author-logs/typecheck.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"docs/rework/hel898/author-logs/vitest.log": "9979ce6e9e813c8953ae6ce5440e9934f497848ffc51ee865f729bb643498a46",
"docs/rework/hel898/f92a86f-rework/HEL-898-f92a86f-rework.patch": "ba1aa445ff7e3be6c4f1ae60866c38a793f06d564f2edc39c21d1fa1142b4c14",
"docs/rework/hel898/f92a86f-rework/MANIFEST-HEL-898-f92a86f.json": "082fc1b0c655c4a24f9a8cbb2307dbc2a28decccc47169fdfecd9357b13c7e08",
"docs/rework/hel898/f92a86f-rework/README.md": "ababbfd5f2809feba7b075083bc89ecdfa487d737db4050e0c951b8ef7eb75de",
"docs/rework/hel898/f92a86f-rework/author-logs/before-fix-vitest.log": "1b38231602e232a10661b1da4abbb08071a0c67aa0d33a224212ae2e2c9429dc",
"docs/rework/hel898/f92a86f-rework/author-logs/browser-hel878.log": "cb4d8e2f1b7f75b4e36bf94e053ae3ac7d297b66cf7c479018a67e211e48cde3",
"docs/rework/hel898/f92a86f-rework/author-logs/browser-hel898.log": "9c24c70b57e87478a45cc28f98f1bcd360259d5c4f8108559cc54d2a6e184bdd",
"docs/rework/hel898/f92a86f-rework/author-logs/browser-unified-after.log": "f6d27faa14e3cb5fb7d4e6726476d1e1b6cede8da7953b8d4a8915677a676943",
"docs/rework/hel898/f92a86f-rework/author-logs/browser-unified-before.log": "be9e6e9b1095c42c593b7227479e8b865fd2f3f69ea5044ed41e1396749a9a53",
"docs/rework/hel898/f92a86f-rework/author-logs/build.log": "ea00bf64f258dee412fa16c3f91f2f8eda0f7d508d9ff4cccc4c699373341ffd",
"docs/rework/hel898/f92a86f-rework/author-logs/lint.log": "b91a312f6a7093ab71938a5000bd7e74d495dced85b4cca806d1c07cf5d0322b",
"docs/rework/hel898/f92a86f-rework/author-logs/mock-hel878.err": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"docs/rework/hel898/f92a86f-rework/author-logs/mock-hel878.out": "aa3283544f6aaf179ec8889b8e5b4f40140d9d931734f68f4fcda4236fe431b7",
"docs/rework/hel898/f92a86f-rework/author-logs/mock-hel898.err": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"docs/rework/hel898/f92a86f-rework/author-logs/mock-hel898.out": "8f5c505be5b4a53a1004146453981eaca882443b9ee5493f6ef75b316ec166ca",
"docs/rework/hel898/f92a86f-rework/author-logs/typecheck.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"docs/rework/hel898/f92a86f-rework/author-logs/vitest.log": "8e8956c2eaa5efeb3bb98f54df75fc7d0e620551bbc3989ff58bf558516e8320",
"docs/rework/hel898/f92a86f-rework/manager-checks/b0dcb17e1acd970f706763efe0fcb7062596039a-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/b0dcb17e1acd970f706763efe0fcb7062596039a.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/browser-launch-failed.log": "ce337e1d74ce2a15a29e4fd13a5ba55be2dbc306f7e4ac98f65183c1d6c8730c",
"docs/rework/hel898/f92a86f-rework/manager-checks/browser.log": "212a347da8189c405357caac49551848dd742c68f969f96cdae89f39302f98c6",
"docs/rework/hel898/f92a86f-rework/manager-checks/build.log": "f99c37ce20446d893da9034b6c94657fe1a780ae53c94278aeb02c20da98f4c0",
"docs/rework/hel898/f92a86f-rework/manager-checks/f92a86f5d8f839338311cdee08179049de34eb20-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/f92a86f5d8f839338311cdee08179049de34eb20.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/lint.log": "a6d2f20be6af0732befc1f4253f56785ddce8d4efa8fef542d12d229e715567f",
"docs/rework/hel898/f92a86f-rework/manager-checks/origin-main-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/origin-main.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/seq-renumbering.json": "7fd2bbe038be6cff322782819af1123b1f12aadf99fcfd2d676f2bacf1c5152b",
"docs/rework/hel898/f92a86f-rework/manager-checks/test.log": "2a4061596e1c77ce27669bbf90a70edd5995b2fae18f056c55dbd9f261a731f0",
"docs/rework/hel898/f92a86f-rework/manager-checks/typecheck.log": "d33b2ad7a4c36716723e149dda586ca9ce7e7330f24a096f4763bcc5d52c4bfa",
"docs/rework/hel898/f92a86f-rework/preservation-review.md": "36219963433b4f286e46dcf831da9c1df88f4d940ecc4b4f5a7d15b44e15ce46",
"docs/rework/hel898/f92a86f-rework/preservation.json": "c1b07b77d250d034bfe72e806b8a85f20eb4d6c4a0e1b08f3fd98afb8e0e92cb",
"docs/rework/hel898/manager-checks/build.log": "606ca426f708ce742d8febb2610a455a179e1b22038fd4f322fbbeb882785450",
"docs/rework/hel898/manager-checks/fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/manager-checks/fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/manager-checks/lint.log": "bde4108ccd7f12e3e499d9aa3489710f250169aa5cb709d446dffaf15e35d54e",
"docs/rework/hel898/manager-checks/origin-main-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/manager-checks/origin-main.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/manager-checks/test.log": "955c26e7260e552317f01c95ed40984ff460ba06cd710f9a12f231144d3ea760",
"docs/rework/hel898/manager-checks/typecheck.log": "d33b2ad7a4c36716723e149dda586ca9ce7e7330f24a096f4763bcc5d52c4bfa",
"docs/rework/hel898/preservation-review.md": "7b30452cc64fd7468c6be941e6a42ea4411d73678787d81059f93e7eb2d88654",
"docs/rework/hel898/preservation.json": "9b34b4520de79e5473a171c7789c506219a432e6f4fcb6e305816fb830f59f0e",
"docs/rework/life-empty-fog.png": "1bbf2570e4a8800f7608dc6640f7b9e44821f399823ce9443294b26a0b28427d",
"docs/rework/live-gateway/browser-results.json": "30be349c389d080901de6a8670eb46782db3fd49db523a44649c6d40fcddfe36",
"docs/rework/live-gateway/desktop-history-moon.png": "e3659bca0e1022477921eda7f68be797b6478b3aabee66b49ea10535cacd7728",
"docs/rework/live-gateway/desktop-model-error.png": "1b379403df9aa951d63bafc93d6e5672aba24d210a1141fcbd7b0328558f3929",
"docs/rework/live-gateway/desktop-preview-moon.png": "d5019e895c42e30598b41419920e2ab6d21963135abe14ee0d168445f6771fff",
"docs/rework/live-gateway/desktop-real-chat-moon.png": "19b7ff408d00db160acf85f8219795e506041e5c6d6bd617395296241eabb316",
"docs/rework/live-gateway/mobile-chat-fog.png": "9bf5e16b0a9627ff6f083a6037a1eb28c33ea096e3704f8a4cb099140d212587",
"docs/rework/live-gateway/mobile-history-moon.png": "5378c9acba14c1f0f355679f46be44e767a9b6ae72fc3715b4f3dc33e28028a4",
"docs/rework/live-gateway/mobile-relation-fog.png": "19dc0bd378732568a0d3e4c48ed11f26bff7e10184f4e27949636d2b11ccc8bd",
"docs/rework/long-end-360-640-fog.png": "4c837b6743263ede73fe47ee73ab74d9545fbb2a7628cbea135ca7a2f1ebec2a",
"docs/rework/long-end-360-640-moon.png": "486266d4ae75bb1750f265c014b6564a9db29403595a2384d80426f2c8574cc5",
"docs/rework/long-end-390-420-fog.png": "8e86a38399aeb4099886ca3a5d44fe1d36bb64292259cc32da4f64aa5f5fa50c",
"docs/rework/long-end-390-420-moon.png": "eb71bbf0ecc3c0bfea52946e220142f91cc79b542534bc1d0fbdbf17953dfda0",
"docs/rework/long-end-390-844-fog.png": "89b9d157d0a3cb357c50d58f38ecfc692b9c356eeeaef30b081aec34f9f0b43f",
"docs/rework/long-end-390-844-moon.png": "3f9415a261915cee3f16e88f66e49142a1cc39fecb05a282677d61661b559b7f",
"docs/rework/long-line-360-640-fog.png": "75cf566114a26e4df2faa12970f9677f075b04c0865629cc99116aa1841718e9",
"docs/rework/long-line-360-640-moon.png": "3b49df960565dff19b46240144381d3891afd8d8e6d358c827600b7a4c357e92",
"docs/rework/long-line-390-420-fog.png": "9771dc93ca1387918a82ebf214130925ec4dcbf80459a63a7694c37e991c67d6",
"docs/rework/long-line-390-420-moon.png": "63442ed8846d4d7ba4e4c0e7e9b65397916982fd6c028010e90482969f34a072",
"docs/rework/long-line-390-844-fog.png": "af30bbde4ad69a07cd74c16c1f042fea3aeb21e496dda6fd90436f2a2c3eba26",
"docs/rework/long-line-390-844-moon.png": "c4573c80e40f9eabcec85232d5d3b8a4f48c64341fbf7d6609f6bef723f4d09c",
"docs/rework/long-start-360-640-fog.png": "fda738f17b21e708a22742c1378cd598d1705cdb71abad3c1128e56791a85ea9",
"docs/rework/long-start-360-640-moon.png": "9d89286b79fe1b8a8ab811701b3997b15d613c9ae1af2a0c1c19ae1072db6cb1",
"docs/rework/long-start-390-420-fog.png": "4ac8dc957c6479d5d41632ef954ba18d1e211ececa4572a04af1a25379d1fa9d",
"docs/rework/long-start-390-420-moon.png": "19d72e5aa42fe4144bc616181e5cb63535f0853580cbf9a9df1d617335cd1256",
"docs/rework/long-start-390-844-fog.png": "566a267f4680bf9e0c6a5bf8a31567266870b5dfdc289149cb17746982ee870b",
"docs/rework/long-start-390-844-moon.png": "3e7feca3c35a6a4e93730a98495c350545ecde343b0fb8f2d5c6d4392678d5f3",
"docs/rework/memory-groups-empty-fog.png": "0f090db810e1dce89380331d5a4373849b1f8432bd065f7e96aabcf42a8098b4",
"docs/rework/motion-enter-0.png": "84cd16febbfef374e2b1337343c0877234e964a6748e8a446d0448496d50f3b5",
"docs/rework/motion-enter-200.png": "0f8785c46e25cf716166f5631aa317401c29a239894e8ac8c06ba07815027ca2",
"docs/rework/motion-enter-80.png": "05dd14acb0a9b9fcea7910a94540895c2dbdcf3798969a6c416d22f46537765b",
"docs/rework/r1-regression/browser-results.json": "c5d47431753028968dfaf767ae324ffa38f13df898fc4ddb3e0931fa5614e8fa",
"docs/rework/services-fog.png": "3d97b02f0098f91e5d7d32c973bbc2fbc2c15287874bc18579bc2c2a0e912e67",
"docs/runbook-hel870.md": "149f57b77b9bba00d7b38c1359540d4adca3cedf313602a2da96159aafe3b006",
"docs/runbook-hel877-voice.md": "1ca96ccf4df50b90b338443d2cdca0181d5ac316216e95611625e5172763eaed",
"docs/voice-custom-contract-HEL899.md": "bf81216b2742e5948ccc4d2184b1a2f2f61303908a128dd7bbc4f6a1c35b03b3",
"docs/voice-r5-readiness-HEL876.md": "0ba8e2bbf51659221762235fc7766e247291186b8ebda3c28f94bfc8780cade0",
"docs/voice-subtitle-contract-v2.md": "fbe77f71b6a6efc69c24d0e4bdbf74d49df13d076870ee24ba1085faf19a2ca4",
"prototypes/hel-799/PROVENANCE.md": "0d03344a0b4fe7324ee5b931b4c1062aeda4c35caa8380c753c20730ccb1ff86",
"prototypes/hel-799/README.md": "8269d688ffbda2dd34608778204f5074079c254dbf69072e7a2fe78e793f3093",
"prototypes/hel-799/build.ps1": "8a5d8cba2c60eb0b4999a482635c317cf8f1df54bb295eb5fca4b5f9464b43a1",
"prototypes/hel-799/handoff-hel-799.json": "812d4eab8f1b4f57b8378ff0a1f810e8b2dce5342cbfc686cf430c03bd2e1452",
"prototypes/hel-799/index.html": "826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e",
"prototypes/hel-799/records/comment-evidence-shared-space-20261023.json": "e56299db726e64fbeea9e07d352066ef2b578889cc9a33cf1789e3234862130d",
"prototypes/hel-799/records/shared-space-static-acceptance-20261023.md": "9aec04ab40e40ce275c75104f004cd3d1473ff8fbef428767afbbe5fafd09cb9",
"prototypes/hel-799/reference/hel-798-static/assets/her-room.jpg": "51ca450a454c108d304feee2745070fbbbd3232863096126badf279ee53a925a",
"prototypes/hel-799/reference/hel-798-static/assets/her-talk.jpg": "0b9e91b58d75afb836efdad3d8e8f0aab0c44df64b2c4ddd59d011fd499aeb33",
"prototypes/hel-799/reference/hel-798-static/hel-798-compare-pr8.png": "6ec7b3d454af4404eafb0b0fa86c17baa10e6aa622456c2ef8689d9011bec764",
"prototypes/hel-799/reference/hel-798-static/hel-798-face-to-face-annotated.png": "8d5092b722d653d0217ed758635d0be61b70a5ebdc79da449a280a8bed43d52d",
"prototypes/hel-799/reference/hel-798-static/hel-798-face-to-face-moon.png": "a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a",
"prototypes/hel-799/reference/hel-798-static/index.html": "8ee3fba1e62409f06d0ee8b42cee735a1d5eb64965ea3a027b997352bcce3d7c",
"prototypes/hel-799/src/ambient.js": "6a9b5edf204364a31e855fa3554643715b91d1b2ef88175a0bc2be2cb0792e27",
"prototypes/hel-799/src/room.css": "a3daf8ac189969e8dab1de8f8139bf6fc2426b5c15d14d332f40532b3fe50515",
"prototypes/hel-799/src/scene.js": "d51302195996d6ec75c2044dfa01ec81ad2f17b11676f143ede965f0d282e2f6",
"prototypes/hel-799/src/shell.html": "53b468896e6b8d722fc8ded1d7a3f83b8f8e6ab0b027d7d0b0a2ba4bec70b292",
"prototypes/hel-799/src/talk.js": "55af813358f83c5e0f99948056489b4387138c1464c51bafaa1359ec6072f73a",
"prototypes/hel-799/tools/cdp.ps1": "e7b872ee6915bbfd4c6bb528c7b5593f25e60bde0bc66c79f3c63f0efddd22fb",
"prototypes/hel-799/tools/contact.ps1": "f91761ec80dc44d1746238667da47df291f43f7bb14d6137002d73777612fd0c",
"prototypes/hel-799/tools/record.ps1": "b6ed8344b125e739664c2a31b735d18238577f5d591ec9ad3e011bb20be99d37",
"prototypes/hel-799/tools/snap.ps1": "ecb8dc017bfcb3883942ac7b01e160037c8261749ddc5759b4048d85ad6020e6",
"prototypes/hel-799/tools/strip.ps1": "fe37c21270feeb64f810aef8f6aead21bdc3b79fc57ed99cff8435e35896817d",
"prototypes/hel-799/tools/verify.ps1": "796739f6d079cc500d6221a6155cc1fb4545f246c3cb753007b8d4b0b969d557",
"pyproject.toml": "7b803e2f08b61f4cf40e5bfd83138d5de883ef2101a20094ae74052213dab45b",
"requirements.lock": "fb0218790cc3208ea7695afb7ff98b186f169e13d01f3c97d482ed7edcee8cb6",
"scripts/check_requirements.py": "6fd1143aafa7ec146b8f33396af6ec83566ba678c66c50987887b4c929a62b1e",
"scripts/gen_hel877_sdk_check.py": "c6e6cabcdc1df75d79e98ca1b90fbf6983f868598a8432f37c584b69110a819e",
"scripts/gen_hel879_timing_probe.py": "a5d22ed39cff0cf7c0ae3670f081871a7eb739be08f3d39a9aa5d6e543251927",
"scripts/gen_hel879_unified_frames.py": "4ad231ec3ca8a5e87b6500102dd634df71101737b2a0399ff20b4ad8946f8112",
"scripts/gen_subtitle_samples_hel879.py": "a6c391a7ef18e61335d1dce029ff8e98c852b8634ade0cdaaed27437ff46c391",
"scripts/r2_backfill_persona.py": "48d75f4b1ab7d43628df0449074f1063874756996c4a4bf6aefd19a237dc7411",
"scripts/r2_model_smoke.py": "e5eb2bc7b428c021d87466c71eee381f634537188b9b94fb25a84f93e1eb2e5c",
"scripts/verify_hel876_voice_seam.py": "f6db5aa96bdf000331efcec07beb4f9b74c592fbf4a2eb29190f49f527a2a197",
"scripts/verify_hel877_voice_chain.py": "81ba6259f6b139d3f6e4e76135308fc307a6666d946e5ce592924ff819349f80",
"scripts/verify_hel899_custom_voice.py": "45f53dfb31fb41461b342c79a31fcbed3ae3af34e9f4ad28876f6756ec0dcc03",
"spike/hel857/README.md": "39083ad6365f0f3d59f67ed67c1917b9fdef82ea8a8dba2d9c645b4c572ac7d8",
"spike/hel857/__init__.py": "ad18d8bb81710d642d592b60cffeaf73edba5a825fff55015f4bf9a4ecf85cc8",
"spike/hel857/__main__.py": "0c067efd5fcc616f2b7d81f06a65f4c92eddd07c8eefb98fce250fc5efdc7243",
"spike/hel857/ab_child.py": "38bab11fb4cb5bbc515cb79a3e457dbe42ae579ebdea84ff49791e957263f280",
"spike/hel857/ab_preflight.py": "4b153e9bb7faa1cd1a92bba978ad8b29960656e7999e5a26e858ad5534e923ca",
"spike/hel857/ab_scenarios.py": "0f9d7865a3a9bf78334b567d30979f4e4e0e638d4adb309087834843b1555aa8",
"spike/hel857/audit859.py": "f6bb5123b19ba8d4ef751be2c86c8f0aa5715371223609bae58708344c73b6c6",
"spike/hel857/audit860.py": "ce17214437abac7eb4d7e76a44d40482c7ba17285cdae507cb97f4754c4efb89",
"spike/hel857/audit861.py": "e6db2c4110ddf01105b048753a416b98aea175ff6602e4121a018a6010c56164",
"spike/hel857/audit862.py": "5cd66d49cafa8a40e8b78a5cf9a64d538bc5352688149f1a8712cf791fd6324c",
"spike/hel857/audit863.py": "4696fa67a1ba5abd7be7873442187cfeed47ea20ad821c82b66eb2bab3265c94",
"spike/hel857/audit864.py": "c25449c609385124b51fd3d3b8406ee32c898c18505b5aef58049a140ee89159",
"spike/hel857/audit865.py": "63a0c0fab527536c7ee51a2f54b1c892152710a1d4e9ea66ca1583dcbbda25ff",
"spike/hel857/audit866.py": "d106ee648589ff67e6a3735041e4a7c4acbfc7704b95d88f3233d2b8df7bab53",
"spike/hel857/audit867.py": "f8aeb2f3c3eb5589aefb0111fea2a8feda272ee62e530a53e509eb78259c0471",
"spike/hel857/common.py": "6e7450b506bd13e8bba2744e0d93557c6679a207d4639eeeca3d9eda1a479da5",
"spike/hel857/fixtures.py": "b8d680c1fc818da469015e2354f7910f1472a4413e111c6dcc3ddce081ab180e",
"spike/hel857/ledger.py": "70dfd3fad1ee912463659f6b3ce0baf4ce78c618baf6efb09c4fa644ad63e08a",
"spike/hel857/ledger_child.py": "9eeb977aa3fd2e747edf5592f712541bdbbec015d5a0ea814c258a65c613eeef",
"spike/hel857/ledger_fake_test.py": "b6166499506f5bd75ab1d5c9bcc937e0fdd571ea1d7b0b8a21d6bebfd4c47210",
"spike/hel857/plan-next-HEL-869.md": "10ae07336a30929d86a5bbee2974f354a8d6147a4e0b00b000989874d4dfe3f1",
"spike/hel857/recheck869.py": "6142cc4aff0a8001da85c6864558eac744a2bed9984a4e623fad25a10add586b",
"spike/hel857/resource_check.py": "98644760d80e97c4154c37f8da841e6154d540783182c191bdefb3c6926beb67",
"spike/hel857/results/ab_scenarios.json": "6745088341baafd14f73ce88ba7d8de3550b48122a1c90f439d192c055a5eef8",
"spike/hel857/results/ab_scenarios_run1.json": "c473cbee15e9d243cb0475cae36ae3077ccfdc660be615dd84e5da62a2c4f4b3",
"spike/hel857/results/audit859_postfix.json": "5a6a57191fd904c7226892fb1e69f4ea4dd0394169dbe8e1ed13922297f559cf",
"spike/hel857/results/audit859_prefix.json": "d651383754d036cc4a5f52a943d58e4acce9e7bc3558fd0bdd38de098997442c",
"spike/hel857/results/audit859_reuse860.json": "d6f0ddee989c67fa8922f6c2b81272039f08fac4e3369625dd36fdfb00a3a12f",
"spike/hel857/results/audit860_postfix.json": "31b3127d1f5e57738ecabc32fb7f8cd004af2d6cbd9d8ab8c262ff0f406349ee",
"spike/hel857/results/audit860_prefix.json": "5f4a7fa5a23548231cc022a01836f7f07bcf68cb385c98db38ed2f457d5efbe4",
"spike/hel857/results/audit861_postfix.json": "e6a346a54ab23c68e18aff18014577c53910c321dcf8ac6dd81fe0556933d826",
"spike/hel857/results/audit861_prefix.json": "57fff753d876a7f35589ce1e112a8aeea658d8eb5831050c2ba49155a63fe9b6",
"spike/hel857/results/audit861_reuse862.json": "5252430ca76d8ead326e86106cd7f7d336dde3644e16ba3157e78516929424e2",
"spike/hel857/results/audit861_reuse863.json": "a70a394efc27a59c62ba9774988838e001871037fd009705098ffb4eeda15834",
"spike/hel857/results/audit861_reuse864.json": "8d92cc3b187271ea7c2071b452f08da844bc6772e3952e121e4c40c6ab806305",
"spike/hel857/results/audit861_reuse865.json": "661f59ddcaedcba89aad251ee44eac960726bd40e1ee00c54daddc56b1fbbc07",
"spike/hel857/results/audit861_reuse866.json": "77f1dda2e2423e36196d60bf39a1fb925ef3635d3a2510ed69a49cd4b4f7b405",
"spike/hel857/results/audit861_reuse867.json": "72957a8a0420538930fe551579fdd4702195f134c28080aca05ecb5927a7d4bc",
"spike/hel857/results/audit861_reuse868.json": "789add169e08d6f1ea9826f1f8176cab451edb88adbe3b58d1054e176bddf836",
"spike/hel857/results/audit862_postfix.json": "7b735a07a705416a9f1ae85f7c801307a8b66a63cbad57c6e1e86bd4bfdac9e6",
"spike/hel857/results/audit862_prefix.json": "0f35e645bed678822d0d21e334e1fac1000a2b0d243a31b652ee4ba4b6c5365f",
"spike/hel857/results/audit863_postfix.json": "a89446b732321cec324f8da57bc3175ad87042b16de519c5b33198497e4e5fc4",
"spike/hel857/results/audit863_prefix.json": "c75f93e93933c218d539f4e1e0ce567f72f423dbd96537d68100ab28b3b3437d",
"spike/hel857/results/audit864_postfix.json": "d7d8374e5e7df46a52c32329b8fd10844011e37cb460515973bb7ad00a8c5e59",
"spike/hel857/results/audit864_prefix.json": "64d2f75dc13935e1b67a3d884c04d4c7efde69e004f4e331f689f972bd37d15f",
"spike/hel857/results/audit865_postfix.json": "3197245db1fd41a7fb6b5cf41441c0f22abc90ad489fe722805fa905a7c4d3a6",
"spike/hel857/results/audit865_prefix.json": "d72e3ac932ce2181ed9d37fd4987bd70dc2aede3853c79033a6485e5f8e9f4fb",
"spike/hel857/results/audit866_postfix.json": "05257d7d3fd1c486100ad1af650e92c84cfabca3614a488ab9db342136c56920",
"spike/hel857/results/audit866_prefix.json": "3cebddab4dab5c0f2ba4016da224908e6e840c1fbd2f586de253d208b230f7df",
"spike/hel857/results/audit867_postfix.json": "1ff0480bc1dfd3ccb014e111c7e98d0e0b62107b856a50f9c14e0b2ffd17a3fd",
"spike/hel857/results/audit867_prefix.json": "91ae2b39f8bd9456834adbfd6e10120123205bccec077f4014584d567af69711",
"spike/hel857/results/audit867_reuse868.json": "e65a98408f9b54a33bfcf22f749a17f2bb880729163f085049251b6c92ac65a2",
"spike/hel857/results/erratum_HEL-869.json": "8b1fe85be0e5b6291d4fc4c72f5f99afef63b270e719416995e6a3e3d457ea23",
"spike/hel857/results/gate0_ledger.json": "e2a7224a4a29ffcf0e54006d93db71ec14400d7a548a67167f023339cac02faf",
"spike/hel857/results/gate1_backend.json": "ceb0def6253609b0fd610386a5ccd67cb15233bd9aa021c6a42d72bbc7a1f94a",
"spike/hel857/results/gate2_privacy.json": "579ad8c1944598f92ca90ec622c578b9b8cd242f50e1337608f45114b4a91d77",
"spike/hel857/results/lock-notes.md": "44e89b7af34ef5db1d25d3916d2d254c10097e49e3d8940bfcf8e7ee69cc967c",
"spike/hel857/results/preflight.json": "4bae4229f2a6a3d6c46964dc76594831c90405cc7e8c12bd6d5edd2dc482c1a5",
"spike/hel857/results/recheck_HEL-869_rawretention.json": "0872c3abf7f40e6a0776d8b8603f7078f54444389963738048215ed74f512d41",
"spike/hel857/results/resource_manifest_pre.json": "08675786695fd7a32fc21a38e72f0b8708a0edb55d4efb02ba2438b6851a362d",
"spike/hel857/results/retest_HEL-868_S1.json": "b613d1e6a8cf3386c0a963692e9d40143b28820fad1b96ece92efc6656e504d8",
"spike/hel857/results/retest_HEL-868_S2.json": "0e5f9be91b88582cc2044b1fb56860a34be1be6ef6f9fff13268a512302ffb2a",
"spike/hel857/results/retest_HEL-868_S3.json": "2fe60d0c4a290c1f7e1027953f0898b0d2bba009196cf91a98857cf9d4947386",
"spike/hel857/results/retest_HEL-868_S4.json": "fc9138388defeefa28706573ce89b05ee3788efe6835caebdaba09680c36e14e",
"spike/hel857/results/retest_HEL-868_S5.json": "fd162411ad19de84b3a4254202be130cf3f9af18c419baa8334fbe550541414c",
"spike/hel857/results/retest_HEL-868_S6.json": "b418f4f1535682110f9ad6ec733ee5cdfbb16e383dce603f43965b7953f0d431",
"spike/hel857/results/retest_HEL-868_S7.json": "9d5f0fa41fef1088183f73c03003dcd1e1c75bfd36aa1eb64d0650763928cf63",
"spike/hel857/results/retest_HEL-868_S8.json": "374fce83614d304911e5b7b833d55bc128b0df3617b3d841f7d1ca8ea3be2405",
"spike/hel857/results/retest_HEL-868_S9.json": "7255bef509190578e529523cb7f680a09460d170e885f04bc7f009269b9021d4",
"spike/hel857/results/retest_HEL-868_calls.json": "afd039315333e85fe2b831018a894643f3cf5ea112e15a37f87cf9fb4c5dccea",
"spike/hel857/results/retest_HEL-868_calls_stream.jsonl": "e80062f83cab4268e680bb45cbaa36f49e8f5bbcb99da9bed1c58633966a3bf4",
"spike/hel857/results/rework858_offline.json": "01b9d8fb9757605ed528bb9cc559b22098b4b7e599f4890240459d1bad877ef4",
"spike/hel857/results/rework859_offline.json": "0785228e2c0964bace4b95617dea6b1261c2cf5bd3923a0c61cfb8e7d5e754c5",
"spike/hel857/results/rework860_offline.json": "788f4090233d228f54ceeab99a3517870022e926dda160d7f5a9ae8fdc35c203",
"spike/hel857/retest868.py": "bbcb3bd6dfd0f560f25301d667e12a08793236df27ea985838e027cabb2f7022",
"spike/hel857/rework_suite.py": "4a249249344fe76088b5e11df30721d4354e21dea3ca6e544cdd59205600e44c",
"spike/hel857/step0_backend_check.py": "0d8bbc9e356e026f87b1800e877901d1dfa7e0db58b52f559d8f83b0b667d98b",
"spike/hel857/step0_child.py": "d43b2fc56e49578fcf75d038ed3140e679a7190561b180686b4c9d53fdb72d35",
"spike/hel857/stub_gate.py": "1c63195f95af9682750fe7a708e789e71402d919f2a33d31ce27e5a7d4ebcd45",
"src/cybergril/__init__.py": "28b475ebffa3d3a8e23c310ac95109d4806190d72f621100a3a8d026157553f7",
"src/cybergril/api/__init__.py": "cf114cbe3885a318da0c95d8d52720c7acad2f378c8c34d82d1ff3f985dec6dd",
"src/cybergril/api/__main__.py": "e0fb1851ae8f3e5c65397b9bbd81f011230bdab70b14720feaaa854a545513cb",
"src/cybergril/api/app.py": "54a7ed03fe0976996f4e6b68c4ee6b5db79a712301b5a275a13477fb718defcf",
"src/cybergril/config.py": "fef3da0512f91dbfe5d0ba0b72df512d33bae4482b63410631ed0fb934095893",
"src/cybergril/contracts/__init__.py": "de6de43999429d64c016b242e8b1c3a64dc67359a0694b86e87ea1783360b486",
"src/cybergril/contracts/audio.py": "fc2a4d783b921d4f0ce6b73d71a3c2742ab0b66112312c1016bedbb93e7a30ba",
"src/cybergril/contracts/capabilities.py": "cc0584d223dc8c035b412cfb6381e95d2c7d6accb07b06c14d918bfaa31d405f",
"src/cybergril/contracts/context.py": "95122b4f9e1be718d5f3ffb012c273e5acd33bf6a8f53aa9a5dc0b7a69bef000",
"src/cybergril/contracts/percept.py": "5076a620ac8e83697c34ea3142f02040e645642233cd713106a20dd516e6e2bd",
"src/cybergril/contracts/render.py": "6c3072da720e1dd57ac3245b8aa84da89d17bb6e024cd0ad2e85a2a179e942ab",
"src/cybergril/contracts/state.py": "bf67347a42f70a6b65576a137364765a2b9b59131f569e8ee0635d3f7d3c579b",
"src/cybergril/contracts/text.py": "566f61221a2059f7809c7c362f6d326f4553448a53d64b851813e39959db485f",
"src/cybergril/contracts/utterance.py": "271ac41a21e792b66a90db5380ca9452d46f92cb8c586d105570e9819fe005c8",
"src/cybergril/core/__init__.py": "b20a1f351aa4861eacdabfc85c40f40627ee114f50f80101c6f106ccf72ab2ea",
"src/cybergril/core/bazi.py": "ab6a827b19a70ea24d655cfd6ac8c5e541795c241390a8d5ad3d3e04bc1678d7",
"src/cybergril/core/birth_view.py": "8b9e67ceb42ad26df9b79ee120818bb51659bebaff9710fab496058cc5df5950",
"src/cybergril/core/mood.py": "20e6b3f8cf79b333d660197a83bb5af826cd12b6d5371c7eb4702ccac6d7a1fb",
"src/cybergril/core/mood_runtime.py": "bcb8a6818d738ddea60198b22b2295c1519e81cd269b53ffc10f0767151ad2cc",
"src/cybergril/core/projection.py": "574df3cb060699867396043e86d222a12ec0eab962f7c04b597ed1996cfb0243",
"src/cybergril/dyad/__init__.py": "fb4ee7bcd35ec72d4e9c32d4be1e013e708b988c2fa1abbdb59d695bd974ef25",
"src/cybergril/dyad/ledger.py": "63a0adef8ffef0c5e1b0c2c8aca23235cb618f1cde0ff4e4cc1fb0d5f7badcbe",
"src/cybergril/dyad/runtime.py": "7e26adea61db256c9d8c2f377e1b539e3452f668e20963442405c94af22149cc",
"src/cybergril/life/__init__.py": "f9e61e40ddd4cdbc520dc582e9197ba37ea5ac734ec6ad6eea3a7eea4eee0693",
"src/cybergril/life/activity.py": "3a0d0fce9e49f537f55d8db50af1d860c7a7c922bbac0be0f67d1723c3f671c5",
"src/cybergril/life/model.py": "2ce2360bcdabbe9de52851324ab04fa672ff644264982d8760812e7176f208ba",
"src/cybergril/life/persona.py": "b58999b1a9a50f0a0d222d549de5f6d97ae62dc243374444143b64feacaedecd",
"src/cybergril/life/public.py": "c3e85cf86c8acbbce2c5cea70f14f7f9cf0342378b3751da3495b6f29052e81d",
"src/cybergril/life/reach_out.py": "c430d0d0b855b5c2d3252fa75ed93988eac5f5cc1cd95044e27f89dc45bc4890",
"src/cybergril/life/runtime.py": "aa28cc8d7ea7bbec6107dd09f53ff62e1310e2b2e337cc86c0635bf653c94f2a",
"src/cybergril/life/sharing.py": "7fd16d4492aa270040a12f28905bf45848895a931104a487b152a24c672239b8",
"src/cybergril/memory/__init__.py": "4fd64b3bd074c9877d4b3feea7f970e6d5c5fc76ca3c2147b852fafeb4265fc8",
"src/cybergril/memory/episodic.py": "e680b3dc95d6e320a0690936f63f7814ecf334381608f7e2afb3d47c0b80b9e4",
"src/cybergril/memory/r2.sql": "e199d34a0f7c0c8737b59bebe9d7a566e4764b9b783096e5e8c5f595217ff4b5",
"src/cybergril/memory/r3.sql": "c9c7e59131b601e0532c8105eff505cc1365de156e50fbcace68ff620ed2ff95",
"src/cybergril/memory/r4.sql": "1bfb63fed14c01c33d499f49185992ca1c295b7bc8d4a4c8488753d93a9611b5",
"src/cybergril/memory/schema.sql": "3b420fc7f581f43f0409f93a4ba1f3c72296da74abbde8d82248bbc3c531c5ce",
"src/cybergril/memory/semantic.py": "b112bf309c52ab0b1182c10ae0cf4c342b8b52a1f3a0a30503f17119aa61f5de",
"src/cybergril/memory/store.py": "ba34f446709c298e544946a2a130a92e229ed0ecefcb3f38c6007d2075bff291",
"src/cybergril/providers/__init__.py": "27f8fc8fa1fa73b5516b97b52ecab1ea927f2cfa20dc221ca872198b2d7dda5b",
"src/cybergril/providers/asr/__init__.py": "56c89ede6111df2185f3d365890f4818f4e77f58d9b925d7f8c3ad19cc64ba2f",
"src/cybergril/providers/asr/base.py": "747c935d30fbc50552c8af2698cda4b96ab403e6983f1eb662c90b844fe33c91",
"src/cybergril/providers/asr/scripted.py": "4674aea0882c0dd54ddafe7ec501e09364668ac69074383abe25611a28c8176c",
"src/cybergril/providers/avatar/__init__.py": "e9c5fa66f32d5edec861779ca7376d98f80e1b22e23080f72f6b68c4577a839b",
"src/cybergril/providers/avatar/base.py": "bb6ed5de7e608cccfccbef8027a57ce419d3d244ee222ab40bf329e91423dffa",
"src/cybergril/providers/base.py": "2c70cc5ab6c59b51d8de6c49c298d09815ef00b18a967d3fd36fc193dde3a72c",
"src/cybergril/providers/embedding/__init__.py": "69cb8d4462f727b11180ba66e66ff9993335e55cb1e9a0c238e252c8fb26f6dc",
"src/cybergril/providers/embedding/base.py": "49bfd7614d5f703408aef4064464e2d6400fec116f9f6b5e82b486c58a2af083",
"src/cybergril/providers/embedding/openai_compat.py": "78ca1d03facdb79aaa37a54dbfd163fb192c9c88ffa25a244a2d82bf880602cd",
"src/cybergril/providers/llm/__init__.py": "641e66a647bf9dc01252d769b67596a0252b857a65954dd71662f8f1de7faf88",
"src/cybergril/providers/llm/base.py": "40eccf124f7cb4b360e9b6d9df89b0ada069cdc056a43572a3836e40d5c83f6b",
"src/cybergril/providers/llm/echo.py": "1d68e2e5a51f0639920f56626f42e2f19a1e0e9f5c6d0fad7c74ba53de2e6779",
"src/cybergril/providers/llm/openai_compat.py": "f809ca9cd160815c518bd7bd4c616c28b8ae7ff90c0818d0accaf99f24c62daa",
"src/cybergril/providers/tts/__init__.py": "5432b91bacff0beee5d11d4001c6e79aa20daefb9793450f87474931d7bfadb8",
"src/cybergril/providers/tts/base.py": "6fc19f9536801efeebac96cd689e156e0862b80b1a4ff1cf6559f61030c2da55",
"src/cybergril/providers/tts/silence.py": "04fd210f541bcafef2763053ed3e29dc2c28740dc092499aebfeb08114b845ba",
"src/cybergril/providers/turn/__init__.py": "326995f0240211df3bee0d74e83bd30593b2dd39142af1763ee8d04d18daca82",
"src/cybergril/providers/turn/base.py": "18077523dce912b65b2d8096f5e9d0d75241c0b5e8cc699701c614a162c77086",
"src/cybergril/providers/turn/heuristic.py": "15745e1c059361e5e860acf9dbe3e0a45cb242ba35abaecac7f1a9e447e8ead5",
"src/cybergril/providers/vad/__init__.py": "d2a0fb7fb4e6f751dc2c44894344b719d6b4622cf3417167e80109c6e64fae96",
"src/cybergril/providers/vad/base.py": "0265a10832a29baae349a105310775db6ea2b8421e99ee9d5d5d99ea9d50335f",
"src/cybergril/providers/vad/energy.py": "1962ad3ed0ff65387e93d68d3ab9ef98bc859f1cd755df5770ea503fae4fc468",
"src/cybergril/think/__init__.py": "47b1a45a41c16441621cda9df7499ee8c64128a35e4bcd897de7d6fa04fe84f3",
"src/cybergril/think/budget.py": "84c62821631bdf9802d483abf3bc8e041d52b9653ae216ba4a2ae1beb82c6bce",
"src/cybergril/think/router.py": "4a05601e5e587764708da17f41c617d0f1433f42794860ce5b09810fe4e41204",
"src/cybergril/think/scenes.py": "5a22383a13a52d34c767b8345ba170900e68b9d14fd5f68f0efb1b28f33e4aa0",
"src/cybergril/think/wiring.py": "3154c973ac2ba4c342bf547a93b37756f6519d9f24ab29a1c92b98fe97998c70",
"src/cybergril/voice/__init__.py": "7003c103ce0995806ec392939dd6db52f12b3e36f16560af2881b6bf70e1a786",
"src/cybergril/voice/chat_api_client.py": "702cb60e5640d236421da842df48142a118f8353852d975238702a7839c2f8cd",
"src/cybergril/voice/custom.py": "cb01ef929911e4e206b56f7141952365a7fbcbeadb03dc183b36fea1ffc93daa",
"src/cybergril/voice/livekit_wire.py": "8aead2faa0b50677a7d8bccfea52fe17f55a8c53b090a2bb9bd868e450ce9d32",
"src/cybergril/voice/llm_chat_api.py": "b6cae28d22b68a6de61273e160daddb21da12f5924dff2d3c00f4636a345a007",
"src/cybergril/voice/receipt.py": "cf4a2296fc38539dd0d6e6ac17f0a894b0a82da89e6e8ace110265b84d75f2b9",
"src/cybergril/voice/router.py": "b1db42d4a6979e92b239be5e318e75e14661d07847bccbf0230bba2cb6a2f2df",
"src/cybergril/voice/seam.py": "ea8ebde885453728b36bfcf46eec1bac3d14944e8549dceb3c27c329aab715fa",
"src/cybergril/voice/session.py": "63727cfec316bb5f8f086c458ae0bac82220ff70b515ccd70a1a9d7114c45483",
"src/cybergril/voice/stt_funasr.py": "e9c25aafe344c3432a13f17770d905c0239d72ce164b58443aacccc7013584b2",
"src/cybergril/voice/tts_cosyvoice.py": "7fa61424fb3ae5382dc9f27e881138953b69726c181084a6f693c141e7f6b5ba",
"src/cybergril/voice/worker.py": "dee6994fce5fef70f6ea0fb4659cda2e7d46d6b889485d5d2697426dddd949ae",
"tests/conftest.py": "5e94b5e9cb5804ab7f4e683e30f68cb7d38e2405b781e9cc790c9be023ddebd2",
"tests/r3_baseline_probe.py": "944c3a559023ed08adb68994714bea43865994c762d83e420767412a5874a720",
"tests/test_api.py": "cdddc3a657cd0595a1cdfeb9b40ddab3169c12db51e2dd8282cdbcb9ee8e3d23",
"tests/test_bazi.py": "0c6dac1649f8cdff102f0595a33915ea674cd98d5268bd80bdf1aa8133cce240",
"tests/test_birth_view.py": "90331ce8ba1cfbf319c5b9de38867c597233bbe30132a07f169791b92337c954",
"tests/test_contracts.py": "d65d467c4ea097068e60336703b7a04df5e2690d63bc6440837025ddd53ac35a",
"tests/test_hard_gates_hel882.py": "b031c8b586d3add4c329604c33774da33e411b641c9cfe12df485f3e16b6e3af",
"tests/test_mem0_backend.py": "220efe649a1a65e7a33767c99494c8bbc50a77cf0a0ac74871db57f73b2f8ceb",
"tests/test_mood.py": "ad057857584e1062369a30842c8eb2efe7bec699f32699db40090bd4d10404a6",
"tests/test_mood_runtime.py": "bcc6952037a54a0b3b323fa3df94c3d893811475043d5c9e83f2faed7c9a8d5c",
"tests/test_projection.py": "3acddc73bec5dc031db3821006b4fed0d1144cdb457d8ccb9dfca177d3e18a30",
"tests/test_providers.py": "de875f8990029bb800139ccd8d5e16adac74a7de40ecd42af5b98a2f2f6bf76d",
"tests/test_r2.py": "60c7bcb696332991546e61d0104010dd7f7441b7ac3dd8282706f50b5b8fc353",
"tests/test_r2_model_smoke.py": "91084082080b32dbce60cbbc8a752c8ada46c0eec71e5b85207a9d490c4a52e5",
"tests/test_r3.py": "4019c091936f4f5e013b90639da853e47f24fd76911f01eaf0018e51c5b62471",
"tests/test_r4_api.py": "94c149ddb31f07fe14b7422cb09b866358c8ecb3fb3e20284f5fb5d62abd3bcb",
"tests/test_reach_out.py": "31c86e68c2f29c2495ec9fdf5428cab953609430d452b047ccf6a6c76aee1bee",
"tests/test_readback_hel874.py": "100df58a193506d36106b33abed79e0c0f910b91591be3c147582266c9f952ce",
"tests/test_rework_hel885.py": "1b82624d4eb97a44edf7e5e5dd248622e821794d8adc010c7b684d2590584de0",
"tests/test_rework_hel886.py": "445230c3d578f3d0f5b01a8acc3f4463664c88f1e71320fb3be1a1c1bdb3bba8",
"tests/test_rework_hel889.py": "c2fda1a800100ee74b29aad66f0c9d10bfad54553981caa2fdae3503736c5861",
"tests/test_rework_hel894.py": "e7788a19d17b686bdab6f5e9aa960fafc52e3550c4d2bcfb8c3b870f822a8f81",
"tests/test_router.py": "56f2b3ecd29866db534db1731831861be00759fbe563f6e6e3ab76772449514c",
"tests/test_semantic.py": "4afcd8a2bc46b273423a113ec2166cbe0a56df7ba9afc967370a00a97de4be2f",
"tests/test_semantic_recovery_hel882.py": "fb08dc41fcacc3159109d7b68f5acc47ac80a14829aae76821dd6ade71a795c4",
"tests/test_store.py": "c2726ad5b00ec14d15aa379cf32d5379784b1daac649456427bc50ab79413a6d",
"tests/test_voice_chat_adapter.py": "ed0aaec68c96a191ea2c3f5e9f72dd79325191534df419e834ea9884babee9be",
"tests/test_voice_custom.py": "de78ac24fde7edeb3bf8120ffe1926f3298b8def21884ab60c9f6d5a2cb49236",
"tests/test_voice_rework_hel879.py": "906514ae50d3795552898e854cce86f37d62f8147679e77fdf40fc23960215a2",
"tests/test_voice_session.py": "0d9c366d69ac5224d4d606016a34ffa99ce1a5943ba9e293d8bb52ad50707deb",
"tools/audit_sdk_offline_hel896.py": "f88e9eaf3985f2dd4d72ca1b453fb2c1b822ba57197cf507e829c57a2beba0f1",
"tools/check_release_offline_hel882.py": "4818caedf2cde26bf5999d5836adfb227009b94b4b5fd59da53374123060cd32",
"tools/release_snapshot_hel896.py": "f1129015492fad4075c0fc798a999910537223aac5884f66e27b3495d0d0020c",
"tools/render_preview.py": "506a04919db24226477ebbc44e0ab344a6ce6ca75b2f3b81b566d43370c053ed",
"tools/seal_budget_hel879.py": "85b904899ccf725b38165bcbc6416da0ee13ac3f7e91d8203e53853648a5aa59",
"tools/verify_budget_split_hel896.py": "8c5bdee07eeba1a1fe2206621356e6153d84e16bd5e961ba91a532b6e64beba7",
"tools/verify_close_ownership_hel894.py": "84c5fa2b8f9dd7314d16c9dbae7cebb3d9b8b37c7f69bb3e2cebac08c83f41cd",
"tools/verify_http_pagination_hel895.py": "a73c81fb8381cde3e13f7d69ff17b81a96cc510a84c592cd4d1fd1f39cb028f4",
"tools/verify_inflight_lifecycle_hel886.py": "55d24905efefb293286f2a9a77f8d1c687af37927135de14483e5ff7c20e6408",
"tools/verify_mem0_real_hel874.py": "2c46b6ef306f96e1cc1791bb851cd316fbd3da472bd45e62bfaa84272be2bb84",
"tools/verify_semantic_recovery_hel885.py": "b63895afe1f211f566aab5441f8c5cf54a92e3758a3c7b1e7f0334dc2c9a0f28",
"tools/verify_shutdown_queued_hel889.py": "488fac844ee3b66a9cdadae6cb8ccff7e67271749777ae0b5a3404dab7c93f90",
"web/.gitignore": "f99ead27b34cbc6756d707e7e7ee7340eb9a71e02dfec2b40d5fb848dd3f4d4f",
"web/README.md": "f596492a443e7ae9972496a40db2f40c79f8667d4552628afa4718efbd29c726",
"web/VENDOR.md": "e11c139b5c7a6f86264439b03997bd89c79e40c994ac1db57f31717e83b660b6",
"web/eslint.config.js": "c59a6a7e9f0dbeb5c8b90a66d1405606a5b0f909fafae47ca8e6737a9a386982",
"web/index.html": "ecf71d805fa2d20a6e244ac1f6fc729a55a44dc24c4a7ac75f966179a51cf61d",
"web/package-lock.json": "f950de58b5e86380705d979029894e62b39aba451cdb349922b97b4177677a27",
"web/package.json": "f58c320d8b4d35c586016dc002d5e83a26ceeb05d8729552c831c39d0791336e",
"web/public/assets/her-room.jpg": "51ca450a454c108d304feee2745070fbbbd3232863096126badf279ee53a925a",
"web/public/assets/her-talk.jpg": "0b9e91b58d75afb836efdad3d8e8f0aab0c44df64b2c4ddd59d011fd499aeb33",
"web/public/assets/icons.svg": "8630691c7349dd33c1accf2331cb4c844ffd9b70465021b1c18bf8f5aea0588e",
"web/public/demo-material.json": "78fd67e8e4a680e8b3277db4a922360a83fa0be1ceee0cb257ba1c74bc6ede37",
"web/public/vendor/livekit-client-2.22.4.umd.min.js": "040db4c0db72e7f7599cd0e06b0f58f95e0082a395cdde410ca26224a79a0626",
"web/src/adapters/demo.ts": "be4593f70337e87dd4b3c0cb499cd73cb4dda1ee921d579d8bafe0b6cea53083",
"web/src/adapters/inbox.ts": "8e145b914af1247f066ebb3e99574d43303a915173e6206950657e462461c85c",
"web/src/adapters/real.ts": "5aa12ec2367c8505f663e52885138adf33ceeef4cfe2ae02d7d6402ff42864ee",
"web/src/adapters/receipt.ts": "cdd7f41d79c843886167947f05bdbed159919e288c6196c6d94d5080b821876d",
"web/src/adapters/sha256.ts": "c65b58368858ed13bff0fcb0710dc72c9cc5b112fd443dc6820fc66e62e08f22",
"web/src/adapters/storage.ts": "e06afb3f8f801c2a4ff5a5ea109f27f59ad2d3187c915d48d50fba3f68cc08be",
"web/src/app/App.tsx": "6d266a532c832fd8d93f447bf516158c17f517363155a47df7642cbc62ae1b90",
"web/src/app/RealApp.tsx": "8afb63b8a6b196e29ced1a8bcef541f1f80a10811f1dc0f965631a9f0c201f5e",
"web/src/chat/ChatPanel.tsx": "38c4912f43c01b7399f3d687e022b14135f5ad648cfd005ecc0b93637b055900",
"web/src/chat/lineStage.ts": "687835a7e3f6f3a9bfbff863e2cd7e1ea96c463d1bc35b9e9be2833cd3252da2",
"web/src/chat/reading.ts": "b35aa4b7c433f660929b2bb46f85d06ebbbde942621df5c2df8da6b3312f4dfb",
"web/src/demo/fixtures.ts": "17e3525a155a4d07f37f4dc4e557a259788e943e39ee7ceca1e6fbf6a272e81c",
"web/src/domain/id.ts": "b03e2c6a7120d1af5065a61103a0aa0679bdfedf3437ce31374d63df1fba8137",
"web/src/domain/model.ts": "88d76909fc771ca4572fd6097ead70beb347e6ba17b97eab913757a1bc193ec5",
"web/src/domain/privacy.ts": "3bb318fae0704f09d1bb657bf54c10dea6ce8b0847daaee36a8dbda22d9c00db",
"web/src/life/LifePanel.tsx": "a91fe08695a856c8e02b9a08b0c6c89427155243b52c300e7d6ce2ded3272192",
"web/src/main.tsx": "4af93f56bbcb00d8d3327ed9f64ab57288234838ed311a1a8c3e825d4ada46b6",
"web/src/manage/CreationWizard.tsx": "7c4e8945b3baf705c9fbc88ec8a50d3b4be289bd892789427061bbe97d396d5c",
"web/src/manage/ManagePanel.tsx": "a0e6a4a9028277b9adbed280d8958890fe6cd56a2375fd23072bd7719d3af878",
"web/src/manage/VoiceSection.tsx": "de0cf6fa86e85a8b34ed79fccd2cdab4e9a2e66bea298ba2f03de2e7c4f797ec",
"web/src/shared/Viewport.tsx": "2302c18e00019134184a70d038bb0bcaea4e9230ae8c83ee9da0b7c285590bc0",
"web/src/shared/motion.ts": "fb397e28411c59349efd75cb5aac1a2722f047ece2e9b7b305803a523576c9f6",
"web/src/shared/navigation.ts": "f4820f6fad24bcb5e89dc497562aacd52ef264608f0a77718cade3a9efc4cb25",
"web/src/shared/space.ts": "5d32075facc0a4e3a56577a126942cee85f724185afd4a43de5f1f85979a1fca",
"web/src/shared/ui.tsx": "6900ffc625d4db2e8ef4772f9371927f85bd47c7464257a36c78e2c49449183f",
"web/src/stage/Background.tsx": "0e36869e3b0600238fc380941ab355429996966fb1aa8bebfc7c5ef9cf807706",
"web/src/stage/Presenter.tsx": "d4cf57bd990bfcf94ca567a1a681339c31474696fe2a92dbc6af218781522a7c",
"web/src/stage/ambient.ts": "076a02ec6152d245d8be5a40cda364f298518cfd7e4a4c3315b4f245afe3a59f",
"web/src/stage/camera.ts": "f3fd15fc4546d0572e03c2f82ae39bb7d2bcc78f65e1d7a3646e297f28dc8128",
"web/src/stage/roomScene.ts": "f01befa8068d88f0f62b845bd4a3cc4421fd7a28d4f5e5ade9c11d0df95488e3",
"web/src/styles/components.css": "653cbdafe876966a1fb320a438460837b10aa5f1cf204fbabb26e33810e3b848",
"web/src/styles/tokens.css": "f44fd4f1bfa1183e19a819256bd402403cd0bf3f5ac09a65bb10879ea3cf3475",
"web/src/voice/custom.ts": "46bc0b6291e2a60cbd90704fade5c0f45aef009b817e388c7651495b372f2112",
"web/src/voice/livekit.ts": "0148fabff55c4d6112aca2b412c53280104442601d6f5e39349279298761293e",
"web/src/voice/session.ts": "2a9a8cd5f1b32fabc68151fee19561e88983b4f5b739da40806dbfeb7698d6e5",
"web/tests/browser_check.py": "b3f9dee4246717950304a49a3086dd3c64ae293c18f2a5e273bd3f576bba0656",
"web/tests/domain.test.ts": "649cedf11049ca2be64569a9b727b53baf4aa7824aede9234cb343511f96cc45",
"web/tests/fake-livekit.js": "fe6489d393aa686cbb527f04d397b3bea19b9e663a665b74369310719c12a917",
"web/tests/fixtures/hel879/adversarial-gates.jsonl": "febd098b07af47fed78b94cd69e7bcf5d6f173c84c2cb4b241dee25bbb8f8671",
"web/tests/fixtures/hel879/sidecar.json": "6793b779e4469943f9ddaca78cb21eab1e8f5466521896b4f397792e863f78b4",
"web/tests/fixtures/hel879/worker-interrupt.jsonl": "4263617779fb7b8467a8cda9eec17e021591edfa7606a7bcf739e4f004940e84",
"web/tests/fixtures/hel879/worker-no_receipt.jsonl": "2acd5b70da10062f6ddceb02f6648e16b4676f15fed3a5a3f0902291fdb7634c",
"web/tests/fixtures/hel879/worker-normal.jsonl": "bcba7ad2a07a63d3b444cc6cec2d0a5371d96c8567e3e5b9d5c290efe07757a1",
"web/tests/fixtures/hel879/worker-phi.jsonl": "6716f9b8a8556b5b8ff9fac024760838595314d5f00c786fb54862e59136b008",
"web/tests/fixtures/hel879/worker-proactive.jsonl": "9f4b5bfaf9ad76dce8546c470f80dd6fbe9d36248079e822e5323a1d30079a85",
"web/tests/hel825_shot.py": "75f6075ae3d3c493360797e7e2f592fa5f4701f99e99981b8b9674ce70e01c92",
"web/tests/hel825_tag_probe.py": "f8c2f51a0403adba71ac4c925700d248efad5b7ad8c3eda14ec81ff98ca3aca0",
"web/tests/hel834_mock.mjs": "303079e4811f7ea62d52ea95c95b1b225259db1e19bbba914e9c70de31ffbe5f",
"web/tests/hel834_shots.mjs": "cb1984c50bae5da46f370292efbba6bb04f7d861bda3af572f2b45733843bc2c",
"web/tests/hel875_mock.mjs": "00b17de26604c7ca0999da12139fd4006468ced3e0536b60d239ccae0f949cc6",
"web/tests/hel875_shots.mjs": "41b04fb9b9ee9569f39dd2dc57f694f5efcd2890124fbc6ced14e6d546878aa2",
"web/tests/hel878_mock.mjs": "4c8cf85102a0c5cecab47ad493c7a698073d4aa25f0d1c0ff598c032640a8d76",
"web/tests/hel878_shots.mjs": "33c338e42ed3417974c75eea54ea0319809af94d694e7928a84f287c74cb59b7",
"web/tests/hel879_frames.test.ts": "86b9cbb402e28661f37e7ad4e292ed4a6f8a3212921930bfc246fe26475d6fe8",
"web/tests/hel879_unified_shots.mjs": "1678eb9548b9427d1746aacb913c7f52246f9e0d2a87006f9d6fc411a862f82f",
"web/tests/hel895_real.mjs": "404f28bdb80abbbc2283c7fafa869728a36ab1677e32eb9c3fd6287ce92cde0b",
"web/tests/hel898_agent_left.test.ts": "727752e2cfa56618353ca9a95cb4f4a308fa3020a7fe6a210eb3f021a6654c9b",
"web/tests/hel898_shots.mjs": "e0fb3ade9f91d629c8be6c1aa611a185ba400510c3cc4a06c06e75869b814393",
"web/tests/hel900_custom_voice.test.ts": "54be8b0437262528b034844ad66275e79ce53ef68d5dc679c53f3e6d2094ecd9",
"web/tests/hel900_mock.mjs": "f120fe076d4b5ca6f1970f0f853bc3148e0fd668d8b53177b5c6d08a8771db30",
"web/tests/hel900_shots.mjs": "d49905fd5475e4e0022456e75490a15ad0ed7fad2717ed290e539a1bfcf85ab3",
"web/tests/import_check.py": "a7eea222bfdf823a2bcc2fe139780d1d44279b2629e81f2ef8c5c5353abab990",
"web/tests/inbox.test.ts": "649bc6e09c93427369aca63ce214ee3d288197d0f886bfced1696f41228b0ffb",
"web/tests/r2wiring.test.ts": "d497adceb2c5187d980acb8801dcedbb1b5c991148048b9138bcb33820642b6f",
"web/tests/r3memory.test.ts": "166b963bafed1ac00d76506f74dc554c4b9e24c3719a055e02b94e72e95f609e",
"web/tests/reading.test.ts": "55573c3cfeb613fb5c2990f8b5274f0c8c75fb25d7f723e05fec95761e6389b4",
"web/tests/real.test.ts": "f63d377c148c901f97d3d5104bc648d72f0301fc2f28652d39693e3ce2836cfd",
"web/tests/real_browser_check.py": "0f716011d3b25d9c9578aba4e2d4e10e5f38ddc572e54d4528fdaa8645e84ed0",
"web/tests/review878_late_cleanup.test.ts": "65261cd249a36693d0c6b06ce279de8a60aa74dd7baafad6999d2fec790f9935",
"web/tests/rework_browser_check.py": "aee2600a4672b6052ab9372a20d9342a47be637a7271af7aed421b73256567d5",
"web/tests/voice.test.ts": "360ac94641a64fe6dd9d83a9243d037afc789b034bcfbc2fb558bc604262e4a5",
"web/tsconfig.json": "08c0f187ee66aed52b329aa4a50ea298839b02c1aed4deb9387c81473cc5d157"
}
}
+17
View File
@@ -0,0 +1,17 @@
# HEL-900 原作者源码保全与审核交接
视觉总监交付评论:`01a17e1e-b803-728f-a86b-f0209afdce15`,附件 ZIP:`01a17e1e-8613-77bc-95da-d0a34433926e`。实际读取后台基点 `ce7279e68f64319c080f7ef150cb86c892b81ecb`,需求 v1.12/60 项;需求入口 README 首行仍写 v1.11,current/baseline/changes 已为 v1.12,此处如实记录,未擅改原稿。
源码提交 `701442d4d482981154c33147e950e63036ec6edc` 以 ce7279e 为直接父提交,只保全作者八个改动文件。改前、附件完整源码改后、提交后的 Git blob,均逐文件与作者清单 SHA-256 对齐(各 8/8)。没有代写或改动视觉代码,没有把本保全当作功能审核通过。
原补丁 SHA-256 `994d510231b4820baa4009a90ec4c8b5a560c41f44b88a6cef1a35b590094590` 正确,但本机严格 `git apply --check` 在 `components.css:991` 失败;原文件没有末尾换行。保全通过附件完整源码进行,绝不模糊匹配后悄改作者文件。`HEL-900-author.patch` 是原件;`HEL-900-git.patch` 是从准确父提交与源码提交重新导出的规范补丁,已用独立 Git index 在 ce7279e 上严格检查可应用。
总管独立补跑原版需求检查:对 `origin/main` 与 ce7279e,各不带/带 `handoff-HEL-900.json`,4/4 通过。原稿 handoff 原样保留;与后台原话及主卡 01a17de1 的自定义音色要求已核对。本机新检出缺前端依赖,因此未独立运行前端构建/单测/浏览器,不将作者日志冒称复跑结果。作者报告的 137 单测、83 浏览器、构建和 lint 记录只在 `author-logs/` 留原件;36 张截图在原任务附件保留,不重复塞入主卡。
提交身份:新检出没有配置 Git 身份,本次只在 commit 命令内使用仓库已有总管提交的姓名/邮箱;未更改用户全局、共享缓存或队员身份配置。
交统一审核:前后台接口和真实 worker 的会话音色固定时序,8090 已登记但本地落盘失败/响应断连的半状态,ASR/试听用量与硬限额、关闭录音/切换/迟到响应、实际两主题电脑/手机画面、演示入口零外发。设置外接服务“尚未接通”旧文案、会话 voice 显示和 CG-009/R6 音色备份/删除留明确结论与承接,不无声遗漏。
HEL-879 新提交 `3766531` 含真实链路必要修复,本分支仍保持作者准确后台基点,未改写 `hel-879-assembly`。审核人在独立整合分支纳入最新有效修复,保留两边成果,不退回旧母线。
HEL-879 本轮窗口已结束、3 次真实通话已用完,预算与服务已封存;剩余请求数不能换成新通话许可。统一审核当前只允许离线 fake/mock,真实调用、共享音色登记、重新部署及启动窗口须待主卡新的明确授权。下一轮申请应一起涵盖自定义声音与既有停声计时/文字打断语音/设备麦克风未验项,先交可审材料再集中申请。保留全部身份资料、旧服务、封账、运行时模型认证及 R1–R7 未完承接;不访问 4080,不合并 PR,不替换现网。
@@ -0,0 +1,85 @@
PASS mock is this hel900 mock
PASS moon-desktop: no microphone before anything is tapped
PASS moon-desktop: opening the section only reads the list
PASS moon-desktop: says when a change takes effect
PASS moon-desktop: recording state is explicit and the microphone is live
PASS moon-desktop: audio only, never the camera
PASS moon-desktop: stopping keeps the take and releases the microphone
PASS moon-desktop: the take is labelled as your reference, not her voice
PASS moon-desktop: register needs reference text or an explicit transcription opt-in
PASS moon-desktop: the upload is real PCM WAV (16 kHz mono 16-bit), raw body + query
PASS moon-desktop: the new voice is chosen for a preview; apply waits for it
PASS moon-desktop: a fresh preview plays and only then can be applied
PASS moon-desktop: the preview is labelled as her synthesized voice, not your reference
PASS moon-desktop: the preview actually plays in the page
PASS moon-desktop: applied — current voice shown, from the next call
PASS moon-desktop: no object URL or microphone left after closing settings
PASS fog-desktop: no microphone before anything is tapped
PASS fog-desktop: opening the section only reads the list
PASS fog-desktop: says when a change takes effect
PASS fog-desktop: recording state is explicit and the microphone is live
PASS fog-desktop: audio only, never the camera
PASS fog-desktop: stopping keeps the take and releases the microphone
PASS fog-desktop: the take is labelled as your reference, not her voice
PASS fog-desktop: register needs reference text or an explicit transcription opt-in
PASS fog-desktop: the upload is real PCM WAV (16 kHz mono 16-bit), raw body + query
PASS fog-desktop: the new voice is chosen for a preview; apply waits for it
PASS fog-desktop: a fresh preview plays and only then can be applied
PASS fog-desktop: the preview is labelled as her synthesized voice, not your reference
PASS fog-desktop: the preview actually plays in the page
PASS fog-desktop: applied — current voice shown, from the next call
PASS fog-desktop: no object URL or microphone left after closing settings
PASS moon-phone: no microphone before anything is tapped
PASS moon-phone: opening the section only reads the list
PASS moon-phone: says when a change takes effect
PASS moon-phone: recording state is explicit and the microphone is live
PASS moon-phone: audio only, never the camera
PASS moon-phone: stopping keeps the take and releases the microphone
PASS moon-phone: the take is labelled as your reference, not her voice
PASS moon-phone: register needs reference text or an explicit transcription opt-in
PASS moon-phone: the upload is real PCM WAV (16 kHz mono 16-bit), raw body + query
PASS moon-phone: the new voice is chosen for a preview; apply waits for it
PASS moon-phone: a fresh preview plays and only then can be applied
PASS moon-phone: the preview is labelled as her synthesized voice, not your reference
PASS moon-phone: the preview actually plays in the page
PASS moon-phone: applied — current voice shown, from the next call
PASS moon-phone: no object URL or microphone left after closing settings
PASS fog-phone: no microphone before anything is tapped
PASS fog-phone: opening the section only reads the list
PASS fog-phone: says when a change takes effect
PASS fog-phone: recording state is explicit and the microphone is live
PASS fog-phone: audio only, never the camera
PASS fog-phone: stopping keeps the take and releases the microphone
PASS fog-phone: the take is labelled as your reference, not her voice
PASS fog-phone: register needs reference text or an explicit transcription opt-in
PASS fog-phone: the upload is real PCM WAV (16 kHz mono 16-bit), raw body + query
PASS fog-phone: the new voice is chosen for a preview; apply waits for it
PASS fog-phone: a fresh preview plays and only then can be applied
PASS fog-phone: the preview is labelled as her synthesized voice, not your reference
PASS fog-phone: the preview actually plays in the page
PASS fog-phone: applied — current voice shown, from the next call
PASS fog-phone: no object URL or microphone left after closing settings
PASS file: a renamed non-WAV is refused in the browser, nothing uploaded
PASS file: a 22 kHz stereo WAV becomes 16 kHz mono
PASS asr: opting in enables register with empty text
PASS offline: says so, keeps the take, sends nothing
PASS 503: honest message, take still there for a retry
PASS double tap registers once; transcription only on explicit opt-in
PASS asr: the transcribed text is shown back
PASS late: an older preview arriving after switching is dropped
PASS bad preview: refused, nothing playable shown
PASS preview 503: honest message
PASS apply 409: message, selection unchanged
PASS cancel: microphone released, take discarded
PASS leaving the section mid-take releases the microphone
PASS coming back says the take was dropped
PASS denied: says why, nothing recording
PASS an unregistered take guards leaving
PASS after leaving, nothing of the section remains
PASS list unconfigured: honest card, no microphone, no 'adjustable' tag
PASS list no-route: honest card, no microphone, no 'adjustable' tag
PASS list panel-down: honest card, no microphone, no 'adjustable' tag
PASS panel back: retry shows the list
PASS no registration or apply was ever cut off by the page itself
83/83 passed
@@ -0,0 +1,9 @@
vite v6.3.6 building for production...
transforming...
✓ 59 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.49 kB │ gzip: 0.35 kB
dist/assets/index-CM9njpX-.css 87.82 kB │ gzip: 18.23 kB
dist/assets/index-C8UHROe1.js 337.92 kB │ gzip: 112.94 kB
✓ built in 736ms
@@ -0,0 +1 @@
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
@@ -0,0 +1 @@
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
@@ -0,0 +1,6 @@
C:\Users\Lucian\multica_workspaces_desktop-api.solsum.cn\hello-af23464d222e\hel-900-c267ddf947ac\workdir\src0\cybergril\web\src\chat\ChatPanel.tsx
240:100 warning The ref value 'fullButton.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'fullButton.current' to a variable inside the effect, and use that variable in the cleanup function react-hooks/exhaustive-deps
✖ 1 problem (0 errors, 1 warning)
@@ -0,0 +1 @@
hel900 mock on 8900 pid 14964
@@ -0,0 +1,21 @@
RUN v3.1.2 C:/Users/Lucian/multica_workspaces_desktop-api.solsum.cn/hello-af23464d222e/hel-900-c267ddf947ac/workdir/src0/cybergril/web
✓ tests/hel879_frames.test.ts (10 tests) 17ms
✓ tests/inbox.test.ts (13 tests) 20ms
✓ tests/voice.test.ts (45 tests) 437ms
✓ tests/hel898_agent_left.test.ts (7 tests) 93ms
✓ tests/hel900_custom_voice.test.ts (19 tests) 592ms
✓ reference audio becomes the WAV the server accepts > refuses what the server would refuse, before sending anything 519ms
✓ tests/reading.test.ts (11 tests) 41ms
✓ tests/r2wiring.test.ts (11 tests) 33ms
✓ tests/domain.test.ts (12 tests) 12ms
✓ tests/r3memory.test.ts (5 tests) 8ms
✓ tests/review878_late_cleanup.test.ts (1 test) 8ms
✓ tests/real.test.ts (3 tests) 11ms
Test Files 11 passed (11)
Tests 137 passed (137)
Start at 12:02:51
Duration 1.38s (transform 317ms, setup 0ms, collect 843ms, tests 1.27s, environment 1ms, prepare 652ms)
@@ -0,0 +1 @@
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
@@ -0,0 +1 @@
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
@@ -0,0 +1 @@
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
@@ -0,0 +1 @@
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
@@ -0,0 +1,52 @@
{
"base_commit": "ce7279e68f64319c080f7ef150cb86c892b81ecb",
"source_commit": "701442d4d482981154c33147e950e63036ec6edc",
"author_comment": "01a17e1e-b803-728f-a86b-f0209afdce15",
"author_attachment": "01a17e1e-8613-77bc-95da-d0a34433926e",
"author_zip_sha256": "15879ce644aa15af299c36649da6b9de2d9f123cec01abb57931f1a2374d74fe",
"author_patch_sha256": "994d510231b4820baa4009a90ec4c8b5a560c41f44b88a6cef1a35b590094590",
"changed": {
"docs/requirements/handoff-HEL-900.json": {
"before": null,
"after": "297b5c257b5bf55b2e09c49ef8a9646f01060dd74b92433d8bd3e32c9ffce218"
},
"web/src/manage/ManagePanel.tsx": {
"before": "df0c968ec91c571124f9707c853a17587569448195745b4757d7afd753cfef3c",
"after": "a0e6a4a9028277b9adbed280d8958890fe6cd56a2375fd23072bd7719d3af878"
},
"web/src/manage/VoiceSection.tsx": {
"before": null,
"after": "de0cf6fa86e85a8b34ed79fccd2cdab4e9a2e66bea298ba2f03de2e7c4f797ec"
},
"web/src/styles/components.css": {
"before": "f93b875f6918d402aaaefeffdc52fdd908328fb8ac70766704e528c6a8bc154d",
"after": "653cbdafe876966a1fb320a438460837b10aa5f1cf204fbabb26e33810e3b848"
},
"web/src/voice/custom.ts": {
"before": null,
"after": "46bc0b6291e2a60cbd90704fade5c0f45aef009b817e388c7651495b372f2112"
},
"web/tests/hel900_custom_voice.test.ts": {
"before": null,
"after": "54be8b0437262528b034844ad66275e79ce53ef68d5dc679c53f3e6d2094ecd9"
},
"web/tests/hel900_mock.mjs": {
"before": null,
"after": "f120fe076d4b5ca6f1970f0f853bc3148e0fd668d8b53177b5c6d08a8771db30"
},
"web/tests/hel900_shots.mjs": {
"before": null,
"after": "d49905fd5475e4e0022456e75490a15ad0ed7fad2717ed290e539a1bfcf85ab3"
}
},
"checks": {
"source_before_hashes": "8/8",
"source_after_hashes": "8/8",
"git_blob_after_hashes": "8/8",
"requirements_checks": "4/4",
"author_patch_git_apply_strict": "failed at components.css:991 (base file lacks final newline)",
"canonical_git_patch_check": "passed against ce7279e index",
"manager_frontend_build_or_tests": "not run; current checkout lacks dependencies",
"real_service_requests": 0
}
}
+72
View File
@@ -0,0 +1,72 @@
# HEL-902 统一审核报告:自定义声音前后台(离线)
审核人:总工。2026-10-27/28。零真实 LLM/嵌入/ASR/TTS 调用、零共享音色写入、零部署、零依赖下载安装。
## 1. 实际统一 head 与整合范围
- 整合分支 `hel-902-integration`,merge head `d167ba9`(本卡推送):
- `origin/hel-900-preserved` @ `37b4e69`(作者源码 `701442d` 直接以 HEL-899 后台 `ce7279e` 为父);
- merge `origin/hel-879-assembly` @ `be149cd`(真实窗口修复:LiveKit 服务端 token 携带 SDK 对等 video grants、Twirp JSON、自签证书材料与窗口证据),无冲突,未改写任何母线/旧现场/PR#11。
- 前端保全独立复核:`docs/rework/hel900/preservation/preservation.json` 八文件 Git blob SHA-256 与工作树逐文件比对 **8/8 一致**;作者附件 ZIP 重新下载校验 SHA-256 `15879ce6…d74fe` 与保全记录一致。36 张截图全部为有效 PNG(22 张 1440×900、14 张 390×844@2x),命名与 harness 视口定义一致。
## 2. 实际运行(本机可离线证明的部分)
| 项 | 结果 |
| --- | --- |
| 全量后端 `pytest tests -q`(py3.11.16,既有审核 venv 复用,零安装) | **376/376 通过**(370 基线 + 本卡新增故障注入 6) |
| `scripts/verify_hel899_custom_voice.py`(真实统一 app + fake 面板/TTS/LiveKit 全链) | **12/12 通过**(本机重跑) |
| `ruff check src tests scripts` | 通过 |
| 需求检查 4 组(origin/main 与 ce7279e,各带/不带 handoff-HEL-900) | 4/4 通过(本机重跑) |
| 总工新增故障注入 `tests/test_voice_review_hel902.py` | 6/6 通过(见 §4) |
**本机无法复跑、如实声明**:前端 `tsc --noEmit`/`eslint`/`vitest 137`/`vite build` 与浏览器 83 项本运行环境没有 web 依赖(node_modules 全部被清理,npm 本地缓存不完整,`npm ci --offline` 因缺包失败),而本卡禁止依赖下载安装。作者的 137/83/构建/lint 记录在 `docs/rework/hel900/preservation/author-logs/` 完整在库且与 handoff 声明一致(vitest 137/137、lint 0 错 1 既有警告、build 337.92kB、浏览器 83/83 PASS);本卡对这些只作证据采信 + 逐行代码静态复核(§3),不冒充复跑。前端复跑与两主题×电脑/手机浏览器实测列入补测包的可选预审项(需一次性依赖安装授权)与真机窗口项。
## 3. 代码复核结论(逐项对合同 §2 与用户原话 01a17de1)
前端 `web/src/voice/custom.ts` / `VoiceSection.tsx` / `ManagePanel.tsx` / `components.css`(作者原件):
- 上传=原始 WAV 字节+查询串、浏览器 `OfflineAudioContext@16k` 真解码重采→16k mono PCM16、≥2s/静音/15MB/解不开拒绝、`allow_asr` 仅「文字空且用户显式开开关」为 true、试听响应必须合法 WAV、canplay 后才可应用、参考原声与现场合成分开标注、每类请求独立 AbortController+代际守卫、objectURL 全回收、录音仅点击开启/只取音轨/停止取消离开即释放/20s 自动停/迟到授权即关、登记中重复点击不再发且不中止已发上传、演示入口不挂载组件零请求(`real && open` 条件,静态核实)——**与合同一致**。
- CSS 新增 44 行仅用既有 token(`--accent/--dur-ui/--field…`),无硬编码色(两处中性白透明度除外,双主题安全)。
- 验收措辞勘误(非缺陷):「改扩展名的非 WAV 在浏览器被拒」仅对**解不开**的内容成立;可解开的音频(含改名 WebM/MP3)按合同设计**转成真 WAV 后上传**,mock 端核验的正是产物 WAV 头。
后端 `src/cybergril/voice/custom.py` / `router.py` / `session.py` / `app.py`(ce7279e)+ livekit_wire(hel-879-assembly 修复):
- 会话开启先 `_capture_voice`(选择有效→选择;否则 worker 文件现值;再无效→default;从未选择不代写)写 worker 文件**后**才 `create_dispatch`,worker 会话入口读一次;通话中改选最迟下一通话生效、绝不半句换人(离线链 12/12 含 pin/next-call 用例)。
- 共享注册表只经 8090:`cg-` 服务端生成 8 hex、撞名换新、既有 11 支与通用助手状态零写入零删除(测试断言)。
- 试听未知音色前置拦截(TTS 零调用,防静默回落 default);试听零写聊天/记忆/回执。
## 4. 后台重点补核(故障注入,总工新增,全部假服务)
| 场景 | 实际行为 | 判定 |
| --- | --- | --- |
| 8090 登记成功、本地元数据落盘失败 | 面板已登记;未捕获 OSError→**HTTP 500**(未映射合同错误文案);但进程内记录仍在可试听可应用,重启后元数据丢、共享注册表仍在→声音仍列出可用(label=ID),**可恢复、不逼重复注册** | 🟡 文案/映射建议修 |
| 上传在面板侧已生效但响应断连→重试 | 503 诚实拒绝;重试另立新 cg- ID,共享注册表留下**无元数据孤儿条目**(列表可见 label=ID、可正常使用;既有 11 支与通用状态零改写) | 🟡 孤儿回收走 8090 自有删除,登记失败提示可加「已登记的半条会以编号显示」说明 |
| 通话开启 worker 音色写不进 | 503「写不进」、不开房、选择不受牵连、恢复即通 | ✅ |
| worker 文件可读→其他键保留;损坏(非 JSON)→整体重写仅剩 voice | 与 docstring「保留其他键」在损坏场景不符 | 🟡 注释/极端行为说明 |
| 试听流水写不进 | 试听仍成功(流水尽力而为),应用凭据缺失→**409 fail-closed**,守门不放松 | ✅(方向正确) |
| 选择状态落盘失败 | 整体回滚、worker 文件不动、503 如实 | ✅ |
| ASR/试听流水与限额 | 流水仅**核算**(有界 5000→2000 行);硬闸只有 LLM/嵌入(`think/budget.py` acquire),试听/登记/开房无代码级上限 | ✅ 如实;补测包按 ops 上限申报 |
| `_capture_voice` 写文件与 worker 实际读文件之间的窗口 | 用户恰在开房瞬间「应用」会落进本通话(worker 仍整通话单一音色,无半句换人;仅「最迟下一通话」表述在该秒级窗口内不成立) | 🟡 残余竞态,单用户设置面板实际难触发;可在 `custom._lock` 内串行化 capture→dispatch 根除 |
## 5. 集中问题清单(交有限返修/承接,不另起多轮)
**🔴 必须修(视觉作者一轮内,或经授权的必要小修)**
1. 「外接服务」分区仍写语音识别/语音合成「尚未接通」,与同面板「她的声音」分区矛盾(HEL-878 起语音链路已在真实窗口验证)。要求按实际能力如实说明:建议随 `/api/voice/status` 动态显示(如「语音合成:已接入她的声音 · 这会儿服务器状态 X」),不得一律改「已接通」,也不得维持「尚未接通」。位置 `web/src/manage/ManagePanel.tsx:190`。
**🟡 建议优化(同轮顺手或后续小卡)**
2. 登记成功但本地元数据落盘失败→500:把 OSError 映射为 503/422 中文文案,并说明「已登记的半条会以编号出现在列表里」。`custom.py register()`。
3. 登记响应断连重试产生的共享孤儿 cg- 条目:在列表/文档标注「无元数据条目=未完成登记,可用或经 8090 面板删除」;补测包含回收步骤。
4. `write_worker_voice` docstring「保留其他键」补一句「文件读不出时整体重写」的实况。
5. 会话页显示「本次通话声音」(`POST /api/voice/session` 响应已含 `voice` 字段,前端 `SessionGrant` 未取):建议开房后在通话条上显示本次音色并与「下一通生效」文案区分。结论:**需要**,小改。
6. `_capture_voice`→worker 读文件的秒级窗口竞态:用 `custom._lock` 把 capture+dispatch 串行化即可根除(后端小改)。
**承接保留(不悄删)**:自定义音色删除与完整备份/恢复(CG-009/R6 后续卡);R5 整体真机验收;R1–R7 完整功能;真实克隆相似度与真机(iPhone/Android/Safari)录音路径未测;演示入口零外发的浏览器复验。
## 6. 需求与勘误
- 需求 v1.12/60 项核对:CG-010 边界已按用户原话 `01a17de1` 更新(自定义音色为准),原话证据 `sources/comment-evidence-custom-voice-20261027.json` 逐字一致;CG-009 备份恢复等其余条文未被触动。
- 勘误(本卡完成,属授权的非视觉小修):`docs/requirements/README.md` 首行基准 v1.11→**v1.12**(current/baseline/changes 早已 v1.12),勘误说明写入该行;基准未回退,4 组需求检查复跑通过。
## 7. 结论
**通过(离线审核)+ 一轮有限返修**:后台 376/376 与离线全链 12/12 在统一整合 head 上实际复跑通过;前端作者证据完整且经逐行静态复核与合同比对一致,1 项必须修(外接服务文案)与 5 项建议优化集中交视觉作者/后续小卡;真实试听/转写/克隆相似度/真机麦克风与前端复跑全部列入补测包(`r5-followup-package.md`),未获批准前真实 cap=0。不部署、不合并、不替换正式界面。
+195
View File
@@ -0,0 +1,195 @@
# HEL-902 补测包:自定义声音真机窗口申请材料(交总管集中请批)
状态:**材料基本备齐,其中连续通话 ASR 精确计数手段仍是待补阻塞(§4,窗口前
须在 .12 验证,不通过则含连续通话的步骤不得启动);未获批前真实 cap=0;
本包只写材料,绝不执行**。所有上限为
**新的一次性窗口授权**,不复用、不重开 HEL-879 已封账(其 14/40 请求、
42717/500000 tokens、12/200 TTS 段保持封存,永不重开;剩余数≠新许可)。
工程参数由工程定(只能收紧、不能放宽),用户只批准真实用量与设备操作。
## 0. 统一 head 与复审状态(2026-10-28 HEL-904 后更新,旧报告保持历史可追溯)
- `d167ba9` 是 **hel-902-integration 的整合合并提交**(历史事实,见
`docs/rework/hel902/REVIEW.md`,其 376/376 等证据属于当时 head)。
- HEL-902 统一审核通过时的分支 head 为 **`b09a907`**(README 勘误与补测包
所在提交);此后该分支未再作为交付 head 使用。
- **后台返修(HEL-904,本卡)**:独立分支 `agent/2/f337aae1f551`
(自 `b09a907` 起),登记失败恢复映射、worker 状态损坏实况、capture 竞态
收口三项修复与确定性时序测试;其 head 提交号见本卡交付评论,**该 head
在统一复审通过前不得当作最终已验证 head**。
- **前端返修(另卡,原作者)**:外接服务文案等;head 以该卡交付为准。
- 后端本轮与前端另卡**还需原总工一次统一复审**;通过后由复审在下方登记
「最终已验证 head」,再由主卡向白栖知申请批准本窗口。此前任何 head 都
不是执行依据。
| 里程碑 | head | 状态 |
| --- | --- | --- |
| 整合合并(历史) | `d167ba9` | 已被后续提交覆盖,仅作历史 |
| HEL-902 统一审核 | `b09a907` | 审核通过+一轮有限返修(已启动) |
| 后台返修 HEL-904 | `07dda2b`(分支 `agent/2/f337aae1f551`) | 已并入统一复审分支 |
| 前端返修 HEL-903 | `088ac62`(分支 `hel-903-preserved`,源码 `51669aa` 11 文件逐字节一致) | 已并入统一复审分支 |
| **最终已验证 head** | **`03cec26`**(分支 `origin/hel-905-unified-review`;复审验证基线=03cec26,其后仅本表的登记提交,执行以该分支最新 head 为准。HEL-905 总工统一复审通过:11 文件 SHA256 一致、后台 380/380+12/12+ruff 独立复跑、需求检查 4/4;前端构建/单测/浏览器未独立复跑,如实见 HEL-905 结论) | **复审通过,待主卡请批新窗口** |
## 1. 部署形态与回滚
- 沿用 HEL-877/879 runbook:隔离双入口 8360/8361 + 8363 WSS 桥 + 专属
worker 8082 + 共享 TTS 9880(窗口内临时恢复,窗口后停回原状态);
`config/voice.yaml` 按合同 §4 增补。
- **不触碰** 8300–8350 演示/真实档案、8081、7880、旧资料与冻结账、4080;
ComfyUI 保持暂停;不合 PR、不替换正式界面。
- 8090 语音面板:窗口内**只读调用其查询 API**;获批登记步骤**经其上传
API 写入本窗口 `cg-` 条目**(面板仍是共享注册表唯一写者)。两句话分开
说清,不用「8090 只读」一笔带过获准后的写入。
- 回滚:不部署即无痕;已部署时停止窗口进程+移除 voice.yaml 增补即回
HEL-877 行为;资料空间三个新文件留存无害。**共享注册表/测试数据的回收
只能按 §3 清单经 8090 自有删除入口逐条完成,不得拿 git 回退或重启代替**。
## 2. 测试目标与逐步动作(全部可观察、可中止)
1. **真实登记**:浏览器(桌面 Chrome/Edge,LAN 机)上传一段 5–20 秒 WAV
参考音频(来源见 §3)→ `cg-` 登记 → 列表出现。
2. **可选登记转写 ASR**(≤3 次):留空参考文字、显式开转写开关 → FunASR
转写回显;另做一次「不开开关且无文字→422 拒绝」负例。
3. **真实试听**(≤10 次合成):现场合成 WAV 浏览器播放;负例:TTS 停机
→503。另做一次「未试听直接应用→409」负例(守门保持)。
4. **应用与下一通验证**(≤3 次实际通话):试听后应用 → 开通话核对
worker 实际音色=会话响应 `voice` 字段;通话中改选→本通话不换声、
下一通生效(通话窗口持有语义,合同 §2.5);从未选择→不代写 worker
文件。
5. **HEL-879 遗留量测**(并入同一窗口,不另开窗):
- **打断停声(约 1 秒)**:量测点必须从**用户实际开口/有效打断输入的
时点**起,到**浏览器实际播放的旧音停止**为止。
- 主测法(最小、单一时间基准):LAN 机上一次连续的「屏幕+标签页
输出音频」采集(Chrome 标签页音频采集/OBS,全程单录音文件)。
该采集只录浏览器播放出的声音(**不混入本地麦克风**),打断操作
的时点在画面上可见(说话动作/发送文字的动作),T0=输入起始帧、
T1=旧音最后一帧可闻帧,停声=T1−T0;同一段录音里再标出新回复
起播点与提示音位置(提示音按固定音色/波形辨认并排除,不并入
停声),帧级分析即毫秒级时基,不需要跨机对钟。
- worker 日志 `turn_cancel`→最后 TTS 段发送时间差**只是内部子段
证据**(附日志行),不能单独证明用户听到了约 1 秒停声;人工秒表
仅作辅助复核。
- **文字打断语音**与**真实设备麦克风打断**两个方向都做:文字方向=
她语音输出中发送文字→声即停;麦克风方向必须用 LAN 机真实麦克风
(非 fake device,不把假设备当真机)。**麦克风方向的 T0 必须是
同一录制时间轴上本地麦克风音轨的实际起声帧**(如 OBS 单文件多
音轨:一轨浏览器输出、一轨本地麦克风,同一时钟可分离分析);
仅画面中的说话动作不能直接代替实际音频起点。
- 真实浏览器/设备麦克风路径:LAN 机真实麦克风完成第 4 项;如用户
愿意,iPhone Safari 经 8363 WSS + 证书信任步骤试一连(可选,失败
不影响其余结论)。
6. **窗口内快检**(可选,**单独待批项**:一次性 npm 同锁文件安装授权,
本轮与后端返修都不执行):本机 `npm ci` → 复跑 vitest/tsc/eslint/
build + `hel900_mock.mjs`+`hel900_shots.mjs`。不获批则留待后续。
## 3. 参考音频来源与共享注册表新增/回收
- **参考音频(已明确到段)**:首选 D1 试听包中 `female_soft` 的 zero-shot
参考录音——来源服务器路径 `/opt/voice-agent/cosyvoice/voice_samples/
female_soft.wav`,本地清单 `docs/rework/hel879/d1-audition/manifest.json`
记录 sha256 `4a65d17099239711601cb17e28b7aa7b6149acd153b92fd0366c23902bd4f687`、
341192 字节、10.7 秒、16k/mono(中文女声、时长在 5–20s 建议区间)。
备选 `xiaoyun.wav`(`6ad3ca7a…dc87e`、297004 字节、4.6 秒、32k/mono)。
窗口执行前只读重新取回并**先核对 sha256 与清单一致**再用;不新增采集、
不新录用户声音、不申请摄像头。**授权测试音色由总管决定**;无需为此
再问用户算法或端口。
- 共享 `voices.json` 新增:仅 `cg-` 命名空间新条目(服务端生成,结构上
不可能碰既有 11 支;面板仍是唯一写者);不覆盖原 11 支与全局默认。
- **回收(窗口结束逐项执行)**:按本窗口登记清单(含孤儿条目)逐条经
8090 面板自有删除入口删除,删除前后各留一次列表快照比对;**回收失败
的条目按「编号/名称/登记时间/失败原因」留精确清单交接**,不删不属于
本窗口的孤儿,也**不未经用户确认保留任何一条测试垃圾**——默认全部
回收,确需保留的逐条写明理由交总管向用户确认。元数据/流水在实例资料
空间,随实例清理。
- 全局默认音色、通用助手 `voice_state.json`/`persona_state.json` 不触碰
(测试断言已含)。
## 4. 时窗与逐类上限(一次性,超出即停;计数口径含失败)
所有上限为**候选申报值**,工程执行时只能收紧不能放宽。**一切失败、取消、
重试、已出站但无成功响应的调用都计入相应口径**,不只统计成功流水。
| 类别 | 候选上限 | 计数口径(含失败/取消/重试/出站无响应) | 闸门性质 |
| --- | --- | --- | --- |
| 时窗 | ≤2 小时(建议 1 小时) | 首个真实请求起算 | 工程守则(负责人盯表) |
| 实际语音通话 | ≤3 次 | 通话真实发生数 | 工程守则 |
| 开房(含空会话) | ≤6 次 | `POST /api/voice/session` 每次调用(成功/失败/取消都算) | 程序可数(API 日志/会话账本) |
| LLM 请求 | ≤20 | 统一账本 acquire | **程序硬闸**(`think/budget.py`) |
| LLM tokens | ≤60000 | 同上 | **程序硬闸** |
| 嵌入 | 0 | acquire 拒绝 | **程序硬闸** |
| 登记转写 ASR | ≤3 | `voice-usage.jsonl` `register_asr` 行(含本地元数据落盘失败者)+**面板侧转写失败/断连的如实口径见下** | 工程监看(程序计数、非硬闸) |
| 试听合成 | ≤10 | `preview_tts` 行(含 TTS 出站无成功响应者) | 工程监看(同上) |
| 通话 TTS 段 | ≤40 | worker 侧流水/日志计数 | 工程监看(见下,需窗口前验证命令) |
| 音色登记(8090 upload) | ≤3 | 上传出站次数(含断连后已生效者) | 工程监看(面板上传账) |
| **连续通话 ASR 音频时长** | ≤45 分钟(3 通 × ≤15 分钟实操上限) | 见下:**待补阻塞** | 工程监看(无程序闸) |
| **连续通话 ASR 连接重试** | ≤6 次/通 | 见下:**待补阻塞** | 工程监看(无程序闸) |
| 新增付费支出 | 0 | 全程零付费 API | 工程守则 |
**硬闸与监看的界线(如实)**:只有 LLM 请求/tokens 与嵌入是代码级硬闸
(统一账本 acquire,超限直接拒绝);其余上限是**工程监看或程序计数**,
靠实时看数+负责人停机,**不拿事后流水冒充硬闸**。
**登记转写 ASR 口径的如实边界(HEL-905 复审核实)**:`register_asr` 流水
只在**登记成功(含本地元数据落盘失败)且参考文字来自转写**时记录;面板侧
转写**失败或上传断连**时 API 只见到面板拒绝/异常,不产生 `register_asr`
行——这部分用量不靠该流水,而由「音色登记(8090 upload)≤3(含断连后已
生效者)」上界覆盖:**每次转写尝试必伴随一次 upload 出站**,两项上限须
一起监看,不得只看 `register_asr` 行宣称 ASR 用量。
**连续通话 ASR(worker 内 FunASR 连续识别,区别于登记转写 ASR)的计数
手段与停机**:
- 墙钟代理(可程序数,命令可执行;**如实声明其边界**):实例旁会话账本
`<db>-voice-sessions.json` 只保留**当前** `.sessions`(在途)与 `.debts`
(删除未落定的欠账)——**删除成功的通话会从账本里移除**,因此
`jq '[.debts[] | (.first_debt_at - .session.created_at)] | add // 0'
<db>-voice-sessions.json` 只合计**残存欠账**,是全窗用量的下界核对,
**不能冒充整个窗口的通话墙钟合计**。逐通墙钟以开房/挂断当时记录为准:
每通 `POST /api/voice/session` 与 `DELETE` 的时刻由窗口负责人当场记录
逐通归档(≤3 通,人工可数),账本 jq 只作残存欠账与在途通话的核对
(在途看 `.sessions[]` 的 `created_at`)。通话墙钟总时长
≥麦克风推流时长的上界,再加 §2 每通 ≤15 分钟操作掐表即 ≤45 分钟总上限
的执行手段;窗口负责人:总管。到线即停:停开新会话并挂断在途会话
(`DELETE /api/voice/session/{id}`)。
- **待补阻塞(如实声明,不假称齐备)**:逐通精确音频时长与 ASR 连接
重试次数没有本仓库可执行的程序计数——worker 属共享 .12 栈、不改其代
码。候选手段是窗口前由总管在 .12 上验证可执行性:LiveKit 服务端房间
指标/日志(如 `lk room list`/服务日志中该房间的 track 与重连事件)或
worker 日志采样。**窗口开始前若验证不通过,该项保持阻塞,窗口不得
启动含连续通话的步骤**;不得以墙钟代理冒充分钟级音频上限。
**停机方法**:任一上限达到即停(LLM/嵌入自动拒;其余实时盯
`voice-usage.jsonl`、统一账本与会话账本);窗口结束停 8360/8361/8082/
8363,9880 停回原状态(见 §5 快照),`ss -ltn` 复核端口、房间经 API
挂断或 TTL 清理,账本与流水快照存档到 `docs/rework/hel902/`,孤儿 `cg-`
条目按 §3 回收。
## 5. 旧服务与资料保护清单(窗口内不变式,逐项核对)
- 8300/8310 演示与真实档案进程不动;7880/8081 不碰;既有数据库、身份、
聊天、记忆不重置;测试写入只落隔离实例数据空间;每日自动备份任务
(如有)不中断;不动队员模型/运行时/认证;不申请摄像头。
- **共享 TTS 9880 启停保护**:窗口开始前快照其当前状态(是否在运行、
进程/容器 id、启动参数:`systemctl status`/`docker ps` + `ps` 输出
存档);窗口内临时恢复;窗口结束**按快照恢复原状**(原本停机则停回
停机),恢复后再次 `ss -ltn | grep 9880` + 进程比对留证。
- **端口/房间/临时声音/日志逐项核对表**(窗口结束逐项打勾并存证):
端口 8360/8361/8363/8082 全停;9880 恢复原状;LiveKit 房间清空
(`DELETE /api/voice/session/*` 或 TTL);`voice_samples/` 无本窗口
残留 wav(对照 `cg-` 登记清单);窗口专属日志与账本快照归档
`docs/rework/hel902/`;共享 `voices.json` 与 11 支内置前后 diff 仅
本窗口 `cg-` 且已按 §3 回收。
- **不能用 git 回退代替共享注册表/测试数据回收**(git 只管仓库文件,
不管 8090 面板与服务器状态)。
## 6. 通过判据
- 登记→(可选转写)→试听→应用→下一通话用新声音,全链真实完成且日志
对得上;通话中改选不中途换声(会话响应 `voice`=worker 实际音色)。
- 打断停声时延有**同一时间基准的帧级录音证据**(输入时点→旧音停止,
排除提示音/新回复/麦克风混音);文字打断语音方向有观察记录;真实
麦克风路径用的是真机不是假设备。
- 共享注册表前后 diff 仅 `cg-` 新增且回收完成(或留精确失败交接清单);
11 支内置与全局默认零变化(前后快照比对)。
- 全部上限内完成(含失败与重试口径);结束后无残留窗口进程/端口/房间;
付费支出为 0。
@@ -0,0 +1 @@
author-logs/* -text
+38
View File
@@ -0,0 +1,38 @@
# HEL-903 原作者返修保全与入库
本记录只证明原样保全及需求结构检查,不代替总工统一复审、浏览器独立复跑或用户体验验收。
- 基准:需求 v1.12 / 60 项;读取与保全基点 `b09a907747f9836c4aea04016757321d28b7cb6b`(`origin/hel-902-integration`)。
- 原作者:视觉总监;HEL-903 交付评论 `01a17e55-ad02-72b5-a087-0ccb15e28dd4`。
- 原 ZIP 附件 `01a17e55-418d-7cf8-bf09-7fd21893a2e0`,SHA256 `b5435308cee0318a04eb892ca0b2a6c49f7cb2dd9c9f8644403acdb49960ea75`。
- 原补丁附件 `01a17e55-4943-7889-a6aa-2ad6154cb888`,SHA256 `e9b803d4b3aa66879637d766ec9d8b7eafa8de71e7451bb362c09a4a749ceefa`。
- 源码入库提交 `51669aa`,只含原作者 11 个文件,无总管实现改动;HEL-879 与 HEL-900 既有成果完整继承。
## 实际核验
1. `author-manifest.json` 中全部 11 个改前哈希与 Git 基点一致(新增文件对应不存在)。ZIP 中 11 个完整改后文件、工作树、暂存 Git blob 的 SHA256 与改后清单逐一一致。
2. 原补丁 `git apply --check` **失败**:`components.css:1035` 的原文件没有末尾换行,补丁没有对应的无末尾换行标记。不能采信作者所称的严格 Git 回放成功。保留 `author-original.patch`,按 ZIP 完整源码原样写入,未调整源码末尾换行或任何实现。
3. 从确切的 11 个暂存文件用 `git diff --cached --binary --full-index` 导出 `canonical.patch`。以独立 `GIT_INDEX_FILE` 读取 b09a907 后,`git apply --cached --check` 和严格应用均成功;重放后 11/11 Git blob 哈希与作者改后清单一致。
4. `git diff --cached --check` 通过。
5. 四组需求检查实际执行均通过(每组 60 项):
- `python3 scripts/check_requirements.py --base-ref origin/main`
- 同上加 `--handoff docs/requirements/handoff-HEL-903.json`
- `python3 scripts/check_requirements.py --base-ref b09a907747f9836c4aea04016757321d28b7cb6b`
- 同上加 `--handoff docs/requirements/handoff-HEL-903.json`
需求基准及历史来源未改动,关联要求与承接详见原作者 handoff。未变更需求。
## 自测证据的归属与限制
`author-logs/` 为 ZIP 原始日志字节,记录作者 tsc 通过、eslint 0 错/1 条既有警告、vitest 148/148、Vite build 通过、离线假媒体浏览器 90/90。本环境 `web/node_modules` 不存在,依本轮禁止下载安装约定,**总管没有独立复跑前端构建、单测或浏览器**,不能把作者日志称作独立复测。截图留原 ZIP,未批量重新上传主卡。
本轮真实调用 0,未部署、未合并 PR、未写共享音色、未访问 4080、未改变队员配置。
## 交原总工统一复审
HEL-904 已交后台分支 `agent/2/f337aae1f551` / `07dda2b`,应与本卡原作者源码在独立候选整合,一次复审前后台合同及真实补测材料,不改写母线。
需特别核对:后台新登记错误与前端恢复提示一致;延迟 worker 读音色、通话取消/失败/TTL、重叠会话残余;连续 ASR 用量可实际计数并到线停机;用户实际输入至浏览器旧音停止的量测。作者另发现 Chromium 在连接发出任何字节前被重置时可能重发登记 POST,交总工结合后台核查请求重复风险,未确认前不称已消除。
原 HEL-879 窗口已封账且永不重开。新窗口真实 cap=0;仅主卡明确新授权后执行。CG-009/R6 删除与完整备份恢复、R5 真机与 R1–R7 均继续承接;HEL-901 停放。
@@ -0,0 +1 @@
PASS mock is this hel903 mock
@@ -0,0 +1,92 @@
PASS mock is this hel903 mock
PASS moon-desktop: services — TTS row says configured + health probe reachable
PASS moon-desktop: services — ASR row claims no probe it does not have
PASS moon-desktop: services — no voice row says "尚未接通" or "已接通"
PASS moon-desktop: services — reading status opens no microphone
PASS moon-desktop: a voice is applied through the existing flow
PASS moon-desktop: settings still says a change takes effect at the next call
PASS moon-desktop: call line shows 本次通话 voice from the session answer
PASS moon-desktop: subtitles still show beside it
PASS moon-desktop: a choice made during the call does not change this call
PASS moon-desktop: ending the call releases the microphone and drops the pin
PASS moon-desktop: the next call shows the new voice
PASS moon-desktop: 500 after filing — says it may be registered and to refresh first
PASS moon-desktop: offers 刷新列表 and words a second registration as a checked choice
PASS moon-desktop: the list is not re-read or re-registered behind your back
PASS moon-desktop: refreshing names the new numbered entry
PASS moon-desktop: the numbered entry is marked as missing local metadata
PASS moon-desktop: after it is found the cautious button goes away
PASS fog-desktop: services — TTS row says configured + health probe reachable
PASS fog-desktop: services — ASR row claims no probe it does not have
PASS fog-desktop: services — no voice row says "尚未接通" or "已接通"
PASS fog-desktop: services — reading status opens no microphone
PASS fog-desktop: a voice is applied through the existing flow
PASS fog-desktop: settings still says a change takes effect at the next call
PASS fog-desktop: call line shows 本次通话 voice from the session answer
PASS fog-desktop: subtitles still show beside it
PASS fog-desktop: a choice made during the call does not change this call
PASS fog-desktop: ending the call releases the microphone and drops the pin
PASS fog-desktop: the next call shows the new voice
PASS fog-desktop: 500 after filing — says it may be registered and to refresh first
PASS fog-desktop: offers 刷新列表 and words a second registration as a checked choice
PASS fog-desktop: the list is not re-read or re-registered behind your back
PASS fog-desktop: refreshing names the new numbered entry
PASS fog-desktop: the numbered entry is marked as missing local metadata
PASS fog-desktop: after it is found the cautious button goes away
PASS moon-phone: services — TTS row says configured + health probe reachable
PASS moon-phone: services — ASR row claims no probe it does not have
PASS moon-phone: services — no voice row says "尚未接通" or "已接通"
PASS moon-phone: services — reading status opens no microphone
PASS moon-phone: a voice is applied through the existing flow
PASS moon-phone: settings still says a change takes effect at the next call
PASS moon-phone: call line shows 本次通话 voice from the session answer
PASS moon-phone: subtitles still show beside it
PASS moon-phone: a choice made during the call does not change this call
PASS moon-phone: ending the call releases the microphone and drops the pin
PASS moon-phone: the next call shows the new voice
PASS moon-phone: 500 after filing — says it may be registered and to refresh first
PASS moon-phone: offers 刷新列表 and words a second registration as a checked choice
PASS moon-phone: the list is not re-read or re-registered behind your back
PASS moon-phone: refreshing names the new numbered entry
PASS moon-phone: the numbered entry is marked as missing local metadata
PASS moon-phone: after it is found the cautious button goes away
PASS fog-phone: services — TTS row says configured + health probe reachable
PASS fog-phone: services — ASR row claims no probe it does not have
PASS fog-phone: services — no voice row says "尚未接通" or "已接通"
PASS fog-phone: services — reading status opens no microphone
PASS fog-phone: a voice is applied through the existing flow
PASS fog-phone: settings still says a change takes effect at the next call
PASS fog-phone: call line shows 本次通话 voice from the session answer
PASS fog-phone: subtitles still show beside it
PASS fog-phone: a choice made during the call does not change this call
PASS fog-phone: ending the call releases the microphone and drops the pin
PASS fog-phone: the next call shows the new voice
PASS fog-phone: 500 after filing — says it may be registered and to refresh first
PASS fog-phone: offers 刷新列表 and words a second registration as a checked choice
PASS fog-phone: the list is not re-read or re-registered behind your back
PASS fog-phone: refreshing names the new numbered entry
PASS fog-phone: the numbered entry is marked as missing local metadata
PASS fog-phone: after it is found the cautious button goes away
PASS status down: TTS "已配置 · 合成服务这会儿暂不可用"
PASS status down: ASR row honest
PASS status off: TTS "未配置 · 这台服务没有语音配置"
PASS status off: ASR row honest
PASS status no-route: TTS "这台服务没有语音状态接口"
PASS status no-route: ASR row honest
PASS status unknown-500: TTS "状态未知 · 这次没读到语音状态"
PASS status unknown-500: ASR row honest
PASS status unknown-net: TTS "状态未知 · 这次没读到语音状态"
PASS status unknown-net: ASR row honest
PASS session without voice: "声音未说明", not the settings choice
PASS session voice "default": shown as 默认声音
PASS dropped answer: may be registered, refresh first
PASS dropped answer: refresh finds the numbered entry
PASS 503: keeps the server words and adds the refresh advice
PASS 503: refresh finds nothing new and says to wait before registering again
PASS 503: the checked retry registers once and the take is gone
PASS recording: microphone live while recording
PASS recording: stop keeps the take and releases the microphone
PASS recording: cancel releases the microphone
PASS demo: zero /api requests from settings and the conversation
90/90 passed
@@ -0,0 +1,9 @@
vite v6.3.6 building for production...
transforming...
✓ 59 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.49 kB │ gzip: 0.35 kB
dist/assets/index-B8TdHoQm.css 88.58 kB │ gzip: 18.37 kB
dist/assets/index-ba4PURHH.js 342.44 kB │ gzip: 114.48 kB
✓ built in 654ms
@@ -0,0 +1 @@
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
@@ -0,0 +1 @@
需求结构检查通过:60项;来源和覆盖完整。仍须人工核对授权语义与实际体验。
@@ -0,0 +1,6 @@
C:\Users\Lucian\multica_workspaces_desktop-api.solsum.cn\hello-af23464d222e\hel-903-112f9b6e687e\workdir\src\cybergril\web\src\chat\ChatPanel.tsx
252:100 warning The ref value 'fullButton.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'fullButton.current' to a variable inside the effect, and use that variable in the cleanup function react-hooks/exhaustive-deps
✖ 1 problem (0 errors, 1 warning)
@@ -0,0 +1 @@
hel903 mock on 8913 pid 12880
@@ -0,0 +1 @@
hel903 mock on 8903 pid 16260
@@ -0,0 +1,22 @@
RUN v3.1.2 C:/Users/Lucian/multica_workspaces_desktop-api.solsum.cn/hello-af23464d222e/hel-903-112f9b6e687e/workdir/src/cybergril/web
✓ tests/hel879_frames.test.ts (10 tests) 16ms
✓ tests/inbox.test.ts (13 tests) 20ms
✓ tests/voice.test.ts (45 tests) 478ms
✓ tests/hel898_agent_left.test.ts (7 tests) 119ms
✓ tests/hel900_custom_voice.test.ts (19 tests) 571ms
✓ reference audio becomes the WAV the server accepts > refuses what the server would refuse, before sending anything 512ms
✓ tests/hel903_voice_rework.test.ts (11 tests) 11ms
✓ tests/reading.test.ts (11 tests) 36ms
✓ tests/r2wiring.test.ts (11 tests) 30ms
✓ tests/r3memory.test.ts (5 tests) 8ms
✓ tests/domain.test.ts (12 tests) 9ms
✓ tests/real.test.ts (3 tests) 10ms
✓ tests/review878_late_cleanup.test.ts (1 test) 11ms
Test Files 12 passed (12)
Tests 148 passed (148)
Start at 13:00:27
Duration 1.48s (transform 315ms, setup 0ms, collect 856ms, tests 1.32s, environment 1ms, prepare 670ms)
@@ -0,0 +1,856 @@
{
"base_commit": "b09a907747f9836c4aea04016757321d28b7cb6b",
"changed": {
"docs/requirements/handoff-HEL-903.json": {
"before": null,
"after": "bd4b385eb0b6633ad1a801a5c67eee8b14699116d9865d4d603e7e0d0b2e2b1b"
},
"web/src/chat/ChatPanel.tsx": {
"before": "38c4912f43c01b7399f3d687e022b14135f5ad648cfd005ecc0b93637b055900",
"after": "9d458bd826e505300e8054565fddcc196fce93214f4f240e9b44fb2728520b63"
},
"web/src/manage/ManagePanel.tsx": {
"before": "a0e6a4a9028277b9adbed280d8958890fe6cd56a2375fd23072bd7719d3af878",
"after": "d32bb459231fdc5ec262c162483dc29d669ec2c3a4c4127c86beb23cc271a12e"
},
"web/src/manage/VoiceSection.tsx": {
"before": "de0cf6fa86e85a8b34ed79fccd2cdab4e9a2e66bea298ba2f03de2e7c4f797ec",
"after": "68e8e15442dc8d3e758eb938b4fb202af0a40ccae2e3c0af8b4e32abd4216535"
},
"web/src/styles/components.css": {
"before": "653cbdafe876966a1fb320a438460837b10aa5f1cf204fbabb26e33810e3b848",
"after": "c0a535c24db36d767ba571394176a3b5f722c534844016afb4903d4811a953b0"
},
"web/src/voice/custom.ts": {
"before": "46bc0b6291e2a60cbd90704fade5c0f45aef009b817e388c7651495b372f2112",
"after": "bf6690e33b49e79a9d65af112f429a65f0da3e35ceeb35efd552abb36f3f2f27"
},
"web/src/voice/session.ts": {
"before": "2a9a8cd5f1b32fabc68151fee19561e88983b4f5b739da40806dbfeb7698d6e5",
"after": "76eca20191124b1b402a91e2da9c4ec04330986085a6146244ac544f7e57d888"
},
"web/tests/hel898_agent_left.test.ts": {
"before": "727752e2cfa56618353ca9a95cb4f4a308fa3020a7fe6a210eb3f021a6654c9b",
"after": "8026851f378686fc56b4e1f143b0550c536e9d76eaadd59522d096b182f31bec"
},
"web/tests/hel903_mock.mjs": {
"before": null,
"after": "60935d78508e0d03325c22833af160d2be767e87690c965c5ecf676dd9800cc6"
},
"web/tests/hel903_shots.mjs": {
"before": null,
"after": "8bd4042a1958a51eeabaeb03a2ab7bb78bf6379e440b114b85d64b07a5dbf112"
},
"web/tests/hel903_voice_rework.test.ts": {
"before": null,
"after": "804a62b3c55f5cd3e95b734761c60628795568cee326e372cff550a57874771c"
}
},
"files": {
".env.example": "c6551011203737a875feb87a24d267994cc17e2b2e576bb39bc9d9295fd8738a",
".gitattributes": "aeef7e1984486c112d00695aa73a4338cf4fbd81d618522c0f05e43441188066",
".gitignore": "b0c08e3eea2b01c059e6ff75846df875875627dd44a579851a0e371b49282bea",
"AGENTS.md": "40eae2dec1bddfe05318eeb76815806a3efa3e4ce23e3d3668132a7ee4cf61b7",
"README.md": "1ee70d262a155a7832b12e4339a95a916dd9ac1d20589ddf002ee53c7e325c13",
"SOURCE-BUNDLE-PROVENANCE.json": "829ed6ba821286b07033401c00a943b82b6e97f85b21e2b23a609ff6d7aaac51",
"config/character.yaml": "7b0279bfb0c9ac90e09fb58a482ebd15562b768d99d676976a128cbece7371c6",
"config/models.yaml": "baed2a8042c330823c08aba0ebcd95899e8d3a1b8446655854b24e2b62289eb7",
"config/semantic.example.yaml": "32173b89234abab76df0d4334fc25d4f2ad285fde001294a666ef4f6299b0199",
"config/voice.example.yaml": "c52848b27b5de230038149bd22e2b2b122b6bb598f114333c7667474960b1a42",
"deploy/__init__.py": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"deploy/cybergril-api.service.example": "afcac96b347e1ad6c9bb6f201c20ba2843d75e5612af9874d14c8a50f4f636d9",
"deploy/cybergril-hel874-8360.service.example": "bbd9abf6ac66bf50f4f9b96903ef7bb14331cfb1357a1968b298bbe1ddd0dc71",
"deploy/hel835_guard.py": "ee6c533d6b31643354f1e165d6315d7d8515560cf1509e05ec161ad0968c35ad",
"deploy/hel874-release.md": "8273e82ad6bae999e1c95201a4ae97765acbc1883d9881b2cc68844e6e6833a1",
"deploy/hel895-release-candidate.md": "40cf119e330e11b40e288edef6d03d36b0af92a5d1775736134ea7599441b189",
"deploy/mem0-stack.lock": "784bb70fdae8b31b1671a401f498bc726498873d1296151dc593ad730f64c484",
"deploy/voice/restart_cybergril.sh.example": "881327010f9c78bb258fa513a33900cb64274778af7e6818de7268fa8e497b3e",
"deploy/voice/serve_dual_8360_8361.py": "6cc4830df0d3a29347f943735a54855de6b2ae4e0501e7fc099680b56e7bb430",
"deploy/voice/worker.env.example": "7eae6da5539ae096d71e8e7c87cde2b9faeb236d6deae7e56c8295d7c1eb7657",
"deploy/voice/wss_bridge.example.py": "fa08f17bd828e016a24f7e16fab01b844dc0757b6436dea1443d551754baf4cf",
"docs/01-membrane.png": "ac3ea97a3a0237edd64752f8910eb91fa8fcf495d7e7e7985191d11ad628b993",
"docs/02-heartbeat.png": "57a2deaf723c878a7d6fc919fc8c3c73c35c08437f7ea996bc4b122c2e07240d",
"docs/03-memory.png": "624ed9b917471c5a6096246339995ae5682a472439504fe5c64e40098dc6c8b3",
"docs/04-title.png": "be2f63ec008f445ac056adcab3543d5e3a844d36553ba2adb2fe69534b847c97",
"docs/HEL-878-preservation-manifest.json": "0c5e9db95268452ffc95c4fd1a15705bca0a8c4711d77b7106316b0bdd689865",
"docs/HEL-878-preservation-review.md": "d5f28f1d57b792dc436f7ddb3c4b2640119d907ab11b53eb79aaa40776880b53",
"docs/SPEC.md": "a2506bd15ae3265ad5049bd750f5f17d88c0ae63a681073a4cf7f81b9d0fe24e",
"docs/budget-package-hel895.md": "ad8f01148a11cd7e97562bf2fca01623e481bbbb37b5b762a6a46dac5a76f071",
"docs/budget-plan-hel874.md": "799cb9569b60c64968b2bc676bb31749c06b7ea423ca32399585e1be36c27dde",
"docs/frontend/.gitignore": "405c7e9a52883194cd5b95a435948eda03961e5c399cb81bc67939d1681d3ff5",
"docs/frontend/IMPLEMENTATION.md": "60e4cb3e194e51e2e408a2546ca6ab30f6940c0c069e4d51d3a99523a2988ba9",
"docs/frontend/PR.md": "80edd7042d7032493e7adeb69dd106af8ab2db5beeb9377b2a7c80b44b68feae",
"docs/frontend/TEST-RECORD.md": "923610921e8a157949ccc78c992fa66b87b1bfd892d920898a2ceb1cb5a8836c",
"docs/frontend/browser-results-1440.json": "e52842e5979161a238f4912713a677fb7e0c4c6e73a4d8e13c5de945ce8a33b1",
"docs/frontend/browser-results-390.json": "95b81baab1bfee9aff8ac8cac553d80d9addb6c5cdd754104f4e81e2a61d91ff",
"docs/frontend/browser-results.json": "773c3dafb2a53a7450a40e8f577b094dd0529074e9783261492551c41e7804da",
"docs/frontend/import-results.json": "fc28b1b9d1b36e1fbeeb326e35689d6482ad793a55e0b7eae25b038af9f46714",
"docs/frontend/recordings/hel825-phone-nav-esc.webm": "9a3e1086a50e60f51d270a44f93f2dca02a605efa91057a7d738f8f7c34ebc77",
"docs/frontend/recordings/hel825-wide-nav-dialog-esc.webm": "6945fba64c0a674b646ab32f771d8430f4e1bb6994a1a42704dd29e4f82fce6d",
"docs/intro.html": "1b2784fa3b38f63307c9cc1b75a091784121eac3d1ffd92dba7b4a48eb888b3c",
"docs/preview.webp": "e36368b5a7753057f2382925831b55addf5c78d415079b9eb9de4fed6f1d92f4",
"docs/r1/DEPLOY-ROLLBACK.md": "f381ac058fb79211aaf4b8655701de8f2473db88735d943e5f4b659738837e5c",
"docs/r1/IMPLEMENTATION.md": "acdaa3f1c1dc1c6f3b1c1a049c0bb4fe2279e91af1acb9b831581c49e8710521",
"docs/r1/PR.md": "f08cfff4531eaf9c89ff944993468d19f9cb1b1f2b02c18b6947addbb417abe9",
"docs/r1/TEST-RECORD.md": "83b1ba9136f1ee1f48516575cb2f9f2a0e32d0595e4c51918726e7ad3fd0e542",
"docs/r1/browser-results.json": "710c04ec4d15559ad64baa5c2e0a29f6c03884b5f83b85cd09e536cdfcc7b9d7",
"docs/r1/controlled/browser-results.json": "5b3e1e53589bcdb969b93d20bae9f6ffacf7819334d585048d620a24c17e7e6a",
"docs/r1/desktop-history-moon.png": "1cb74ca298499abde7a0832e371849e29565e4532b5c0b2b2ebb8ddc981a17b9",
"docs/r1/desktop-model-error.png": "839b9b05f757e39421e8d3b15c192a1b1f213a879ff35ac81c6ce7178631048a",
"docs/r1/desktop-preview-moon.png": "ca8115ec27e70471585df2a5ce4d3f1e4b89e162f4cc4c351ae6f608c073e089",
"docs/r1/desktop-real-chat-moon.png": "cd2422457dcac56af1149f8d6bc3a0590bd5e37adbf3ad8015cc212e6dd6e05e",
"docs/r1/mobile-chat-fog.png": "7ac20f654c78fdf11fe5bb11e4731dbaf551e01227e284b855bd13cbf8832696",
"docs/r1/mobile-history-moon.png": "9168aa143c67469bfc00cdccaafd5ff59dd183f9526544cd4d0cb4f366517cda",
"docs/r1/mobile-relation-fog.png": "c1002c20e65a0967eca6ef1c2559755d0de9d1f04f66406f34ca86a302410557",
"docs/r2/CONTENT-REVIEW-HEL-830.md": "32ad98545aa8791998979120f3710cd5b0691fb8b85fd755c993586a7829a2e3",
"docs/r2/HEL-827-HANDOFF.md": "fcde52d27bbb77495f9b47f572b03042725a4885127f2b7ba36ed39529ab907d",
"docs/r2/HEL-830-HANDOFF.md": "dd392fdfca8d764be9db115689e16c3d87cd3710ecadd442c0b4d6ab5792a8a4",
"docs/r2/VALIDATION-HEL-830.md": "b80ee2d62dbde635fd1cd64b6660649055e36f7893c29308b9eae43367e0d1a2",
"docs/r2/VALIDATION.md": "ad7c6243b2208476d7ee8b740fa43e28606b8f7ac2419718a350d17d3555192a",
"docs/r2/model-smoke-HEL-827.json": "09f2242f1a8e4f637967c7ac3e232407e40b0fe5707f8cc57a76ac1e8a93ff84",
"docs/r2/model-smoke-HEL-830.json": "4c83baff50ee2038925f50d41ff4ea8ddaf68e3883c49931a79c3c44621d3a21",
"docs/r2/pytest-HEL-827.txt": "8e8a03f39e1a8b3062766de3d746dd8e63144a617231dfe1a7c33c4ebc21bdea",
"docs/r2/pytest-HEL-830.txt": "c93e4c1a44bf30fd51b325e5510cbb1247bc4811408c99793cfbf8d499f3f43d",
"docs/r3/CONSTRUCTION-CARDS-HEL-832.md": "fd0f1f930a95f6500700107381dfa29e6f68cc2df0e305a200c9d1568c6a2b3d",
"docs/r3/DEPLOY-HEL-835.md": "2baf25cd13729a7583d9ef07b45ef1ed0a64db08358c359494509a78b94c001c",
"docs/r3/MANIFEST-HEL-834-source.md": "b98f1c7aa7c6924a3c6d3d86be6a6fdce447db3c60b944813493cd5fe122a45e",
"docs/r3/PLAN-real-followup-HEL-835.md": "39676418d5841f88c2bcc562300e1d108bcb1a3cda8801b82f80fb5a4cf1d993",
"docs/r3/PLAN-synthetic-retrieval-HEL-835.md": "c026477f1848253f6899e701b4bb6ab067c186791eccd66193a473b67ddd7c01",
"docs/r3/PROBE-runbook-HEL-835.md": "acdea88bb6bee26321519eebe596cc036231c0b793ddf8fb7bcb8de82b534a2d",
"docs/r3/REPORT-HEL-832.md": "4503f9e54ef09914616a5a84202169277f558aa4435e1a6f5fc967d50d9623ea",
"docs/r3/REPORT-HEL-834.md": "77740766ccfc3d0858e3f4af7660b54b36eef8c2e0841b38251baf8b0eb3f189",
"docs/r3/STATUS-HEL-833.md": "4c4c028d0c79906510c3a28f14b4bd81c21f990fa7f745294d983a453db5f45c",
"docs/r3/browser-HEL-834.txt": "fa2640e476c7ad754ee3d5132b4e830945cb909e3ae2c9140ec87517fbf9508d",
"docs/r3/browser-HEL-835.txt": "70741b740ef4e73f42c695c82f824488a777d41cdd43b47164a50dc6a6b46e3c",
"docs/r3/correction-proof-HEL-835.py": "0ed3da3f6ba132127decb2cfb97a7e7d05b9fbc81b7c9bc6e19cf11385eceabb",
"docs/r3/correction-proof-HEL-835.txt": "7dea0a5735a89a461fac48f72561188282f9a023dd6017cbf99d6ffe747c260e",
"docs/r3/e2e-HEL-835.py": "381fa13555786adf9ca542f11224b6939fe20f91760eaaa70788fefa11543923",
"docs/r3/e2e-HEL-835.txt": "5574437c36fb01e8baf167379c52e87444554b77b606ec19e9200b2ca5012474",
"docs/r3/fts-probe-HEL-832.txt": "8240648088d661575e5e4b8fb3eac2e0b359a1ad461614f9d3a792729b8cb9e7",
"docs/r3/guard-test-HEL-835.py": "e012f31edf597b69fab631c791faf4c21aa8e038705f76e1709c198305ec33fe",
"docs/r3/guard-test-HEL-835.txt": "298ff142518231ced38d63b49313554da72ccad81248ee4151233081e4001e5a",
"docs/r3/handoff-HEL-832.json": "e391ed66273bae849067f0d000469bc7e62c8559d349e2d99fab018162c8d6c8",
"docs/r3/handoff-HEL-833.json": "01a1df4de10d4b453ab1a45e334efba1554a8f59bffd9c58b77c419820184be1",
"docs/r3/handoff-HEL-834.json": "cde71b060455bdc5ebf0971dfc92f875182ae7e1aadf429439f5888e849a79a7",
"docs/r3/handoff-HEL-835.json": "881a35a7efaeeff1cf37bedab2002a43b0568c914ae99cbfe361460a653b5b6b",
"docs/r3/lint-HEL-833.txt": "82b3e6a6c090a57601d22943bd23fca9218d1031dbe5a7b754092f9a156b4f18",
"docs/r3/probe-seed-HEL-835.py": "52af2bce1a1745bc0c52e23c18ca43af2ce492fd4339bbbee0bdbddfe2264260",
"docs/r3/pytest-baseline-integration-HEL-833.txt": "9d0001187df1efa22add8cad53334914153ca78641f327aedd20a9ac11e10068",
"docs/r3/pytest-factory-fix-HEL-833.txt": "4023bff69febb0dafd1b4b50684b7fa6bd110bea1008934c1f86be0a45608454",
"docs/r3/pytest-factory-fixed-HEL-833.txt": "a9633c21c64d119b38d38318bafc6f01a0e2981700cbacf477ac3094bf0bfdd5",
"docs/r3/pytest-final-HEL-833.txt": "6d854a95805385d3ea0bf71799b6c0755d99eecece242b5d060336c50905f55a",
"docs/r3/pytest-r3-HEL-833.txt": "43c3af9e809b8242f8b3f7218db9a1857ac5b0c852e68eb7eb1238e95fa83dd3",
"docs/r3/pytest-r3-complete-HEL-833.txt": "e572da11882f4e474a135ecfe382ddbdf81b3ce0498ab0352e903a7d5bf18902",
"docs/r3/pytest-r3-final-HEL-833.txt": "5c5c3ab408f4a7facc22b878b2a5df2f5524f00fd39bbf26cc3e248351d130d1",
"docs/r3/pytest-r3-first-HEL-833.txt": "92b40ed9a75ccd1b150ac53e1f74952e15ae9be89c4ef58c2aa80262dbd7e70e",
"docs/r3/pytest-r3-second-HEL-833.txt": "5ed4e8608f1537f81a46a18453b092851c44c5ca8125f69a0c32155f15fbfa37",
"docs/r3/pytest-rerun-HEL-835.txt": "b6664da3662203e0a83a60ed86d011a1e8f04c5b919bbd3dda0281d6a4663d14",
"docs/r3/pytest-restored-HEL-833.txt": "f01bfba7ca8899a889f67218dec8718aaf13dd50c029071239a5d6c45dd0b842",
"docs/r3/pytest-restored-fixed-HEL-833.txt": "1c30ecd3b900aa1b62f3dcb76ca7154e0395e357119559a294f1f8a5c77122cf",
"docs/r3/requirements-HEL-833.txt": "6f91ccbac356f222d7b5f776f50f830da3de37196651d0c04162ecf8b590ba05",
"docs/r3/requirements-HEL-835.txt": "18c86cac189e8e5b3a8718c57d075aa823124afb9337b71fb577c089283a38b3",
"docs/r3/web-build-HEL-833.txt": "7dff1478eb043cad384184db8b1a638e1e6d1b7faefd6b109427cf8c2e22110e",
"docs/r3/web-build-HEL-834.txt": "a85d8e2d9253f115ca9045f43c4c55bde3fa8e3b3797fef84b264fbbe695103e",
"docs/r3/web-build-HEL-835.txt": "c754dde832fc925848e644bac96beafa77d4a9f75aa4ae0e2e360a75feae8765",
"docs/r3/web-lint-HEL-833.txt": "1c2b79600d1b8317501cbd26dcb3c4a931f1e9faa25d1a79017907bf88688d12",
"docs/r3/web-lint-HEL-834.txt": "2813d6a1d44e721a2365b7208e3a02dfc2bac27c7c6d187493c7312926cefffe",
"docs/r3/web-lint-HEL-835.txt": "ee0f22dc391a5a537aed68e83127c9dbd432ac6e4015b9e6e4ad31da00f98ab7",
"docs/r3/web-test-HEL-833.txt": "407ba2dd1bf999497aa2195d25f879404f90c48b8248ee53c08f2df1fbffdeef",
"docs/r3/web-test-HEL-834.txt": "8f5ec1193c72425c02dedd90d23ad23bed133b43020e38b92d0d5f76d1e2ef21",
"docs/r3/web-test-HEL-835.txt": "f51c9ab4f7558471822326dc6d4874a04123abd892c43eefc39c44aaf3609c82",
"docs/requirements/README.md": "64ed5afa15edfb9364cf2b9a2af757035899b99e1bc62c2ead0c584a05784036",
"docs/requirements/baseline.json": "6cf523a70929c30f13605492df06dfe56b8cd92af23345412a98affd18aaa0c6",
"docs/requirements/changes.json": "a38222d0c4018abab44f1a7aa47028b6e5446376a6483b4de7f031b3e2f926b5",
"docs/requirements/changes.md": "eb967e418e608a42491b2df4f9ff52cd7c98316b7eb1b7e42874c9b5eec673de",
"docs/requirements/coverage.json": "05e17f85781fb9c01e6cf60786f26236e436a7c3730de864e0d77ceb9b79965b",
"docs/requirements/coverage.md": "228fcd71faa5c509b19143f8c871efa764f8c0e9e5ac627e33f3428c407f6658",
"docs/requirements/current.md": "7bbc122a24d2964f12cb557aff8d33997199267fb8142cbf9047254491c11a10",
"docs/requirements/discussion-takeaways-20261025.md": "e2ee666e5193b237a739a8d022a82bbb868cd144f8f4e1fe34f0e3981f94cf7d",
"docs/requirements/handoff-HEL-697.json": "2ddfca357e170e864d482ca469da0129c1cf26ef6b437ee24b8a3d34947b4510",
"docs/requirements/handoff-HEL-811.json": "07a596b9328224dd621c9d1d4b428f2d76ea5dc81c173d9d7a55c0b70ee8d8be",
"docs/requirements/handoff-HEL-816.json": "4577524f25db9b301a3bc68fb970d31e4320cae11e4354d23881bb134223f642",
"docs/requirements/handoff-HEL-821.json": "7f3be159f017a9600708c7d0ff2809c67bd2811498f0c232d040a9601b78862f",
"docs/requirements/handoff-HEL-822.json": "8753bd145ae4ed0d045d0d4f6160101e200b4f1071601228ef2c0b43db1a5303",
"docs/requirements/handoff-HEL-823.json": "5d6337ad15e82538b8b284b4aa7889579a1b3fc89029af221ffdf35d8f0a0fef",
"docs/requirements/handoff-HEL-824.json": "302fd163a35f296d64cecbd521d84b2de525d5bddf27bc1a9dbb9f7e969f6f1d",
"docs/requirements/handoff-HEL-825.json": "60e2c4f87ee1c2a814e934337a2a48801fcef7750cfcc9134578f6c77238d898",
"docs/requirements/handoff-HEL-827.json": "b1e625dac3163d09967c7c8d43de2f0927becefac535f58844ee6045fa2b1f6a",
"docs/requirements/handoff-HEL-828.json": "89f05d82366835a425d0fc5db9bce9a7b600dd0a782608f2b0a494f1d9ffe054",
"docs/requirements/handoff-HEL-830.json": "16fa3048878b08dafc2a8ca02e8d3db417530a1678c5cb31a20987ad5b684de6",
"docs/requirements/handoff-HEL-857.json": "382392235666d735655e241cb4bd3c8208e15b6fdc2427b86de30b9d472064a9",
"docs/requirements/handoff-HEL-858.json": "68c1975f89a926822321c4159d7fe60d6a8569cf382321290aa23fc05b47b538",
"docs/requirements/handoff-HEL-859.json": "388c620e99367a24e499efb01361a04c8dfdcafd36cf575f0a69d28eb79cd09f",
"docs/requirements/handoff-HEL-860.json": "1222921c61630b072f58ee1c643a4da02721d592199b34bd0806d8c5be4e58d7",
"docs/requirements/handoff-HEL-861.json": "d604581abc60ff553fe62ccfe4d98c8b8532da948f8f33ea2bd53333fa7961ff",
"docs/requirements/handoff-HEL-862.json": "d08fda4d2c4f2ef0ca9fd8c4e265cef85b5d520e40da6a322a2d099648b94acc",
"docs/requirements/handoff-HEL-863.json": "30d5322a2673d6d4d71bb07153d3d974a636af1f04f96f31e85a7c082b568c86",
"docs/requirements/handoff-HEL-864.json": "909213044fa7a75f8043c95bb071636ff376200a124e4d1300f8ff65bbec802e",
"docs/requirements/handoff-HEL-865.json": "1e361d2b7b2a66f56f88da021fda5a8432308c855975b0613cf35c9da525852d",
"docs/requirements/handoff-HEL-866.json": "539759490fb6f1795b5a325632e3720a7332c06640038fbbc958eca11df59324",
"docs/requirements/handoff-HEL-867.json": "3eb2fd69e5231e257967160195bcc3ee93975f27c339d3eefe4f295e0efd818e",
"docs/requirements/handoff-HEL-868.json": "dd609e7828b8ff20c13a6b938fce40d77f38aa30b978bd451b468b2169d5db0c",
"docs/requirements/handoff-HEL-869.json": "8954ea038f375934732adc2cfbe60e75eaa23969b4fb163bca29512d6520b7b4",
"docs/requirements/handoff-HEL-870.json": "71baea0fb2cec30b7ff40d3382fa749b0c46213ad34ac64d02456ac1c0527bdc",
"docs/requirements/handoff-HEL-874.json": "73cd0a585b86c2f3b03d7b4bdca65f933065efc0c55e82fdef550d1fed253a9f",
"docs/requirements/handoff-HEL-875.json": "984f362ba3cb42434c53194d479c1dfe4b03113261a74b46e7e6ff599e8ce5ae",
"docs/requirements/handoff-HEL-876.json": "df18ad67b29ab72b9ac9a2928d88b2ab25d4ae0975b3fc2c41802f97ddd5e8cd",
"docs/requirements/handoff-HEL-877.json": "238dc7f98fe7617051a7d121175f450cf9fbe8aaeb2153e86fc9ea5daa93fc1e",
"docs/requirements/handoff-HEL-878.json": "258cf2935f5aafc20cd7a80a9550ae062024849ff39c078068f371692bbaa516",
"docs/requirements/handoff-HEL-879.json": "ce8aad74a3abebefb1f4b4b0d72b9bcd93a9286f8ebb446fc9359957df5896f6",
"docs/requirements/handoff-HEL-880.json": "9506c3c5c1278b090d1e3eaf0f6191290af6114031e1f0e1ec514efeb74fb275",
"docs/requirements/handoff-HEL-882.json": "8901262dc1714b4f6264c2f375e7b12e977653ed5ad80a5efacf48754c1c1089",
"docs/requirements/handoff-HEL-885.json": "caf97d45ef8c23777942ed504d5ca6d3411ab5b86fda58c5ba308e31ffb80d32",
"docs/requirements/handoff-HEL-886.json": "5e965ee60fba01a4e7ebde534c76177aa509421aea31e33beb83d01387b7413b",
"docs/requirements/handoff-HEL-889.json": "bc28ee707a433d28f4efef9c33088781f98e1fe323f0c228f9444789d37363d3",
"docs/requirements/handoff-HEL-894.json": "932251b76b952705a97e5b4259c0bb5596d8b51347c4c91ee082146a4a3fa3fa",
"docs/requirements/handoff-HEL-895.json": "afb68a2db07dbf791a59e7878a646bd6cc30e26c8d94943551eb769024a79bbf",
"docs/requirements/handoff-HEL-896.json": "9b3f7533e4ead1ffbca2b502c3be35223648314e74aac907f29c38cb5b8b4955",
"docs/requirements/handoff-HEL-897.json": "56cf0e97b5035ccfaaff3f7dd219ca30352df600fa4c9684eb690e4e44731bc9",
"docs/requirements/handoff-HEL-898.json": "af31e5dca084900b4b8e61adaeeb6868a377f74ba1a5afb57219c4ff783adb31",
"docs/requirements/handoff-HEL-899.json": "580ad2fa309d1bfeb78a4e295205f9d2e270654933a09d8a939be291e2a0408c",
"docs/requirements/handoff-HEL-900.json": "297b5c257b5bf55b2e09c49ef8a9646f01060dd74b92433d8bd3e32c9ffce218",
"docs/requirements/handoff-HEL-902.json": "3f734a1a40b401dc30e0411f9cc1ba43b1ef8d4633d30ea20b4f6cc5a0860a33",
"docs/requirements/handoff-HEL-903.json": "bd4b385eb0b6633ad1a801a5c67eee8b14699116d9865d4d603e7e0d0b2e2b1b",
"docs/requirements/implementation-verification.md": "874a6a712fb1c5781c4b9e0dd31f831d42cd44603dec61c67c6d502d3c833819",
"docs/requirements/mature-reuse-handoff.json": "7b3a78495ff71b3dbc7498552b086d19c5f1b774388f02be78e7a0b225e40989",
"docs/requirements/source-manifest.json": "6b3e5cc6836c881a915b812164e7abcc1092b8f19f0f791686ab9324f5578aa6",
"docs/requirements/sources/.gitattributes": "1e1b7f6dd91450faec143a53cdc10ad708093a09d0ee5660ed1436abeed33e6b",
"docs/requirements/sources/approved-frontend-spec-R2.md": "d95edfe36dd08d4282176f5e7615750ddb5baa75d3a542b83ed3f9d8b24e02c8",
"docs/requirements/sources/comment-evidence-custom-voice-20261027.json": "ed72bd78218e1c0b4ca644bf933d6bdb2e3aa8cd2c0333f3410f6b60845bd3cb",
"docs/requirements/sources/comment-evidence-dynamic-sample-20261023.json": "fe7528a2b68aca99968734b200d9d27a17d85f5110e60c6f52d7c569755c260d",
"docs/requirements/sources/comment-evidence-full-experience-20261026.json": "75dd55b8755bdad217b44c3f516c9372b046aae06dff511527014c7b209eccff",
"docs/requirements/sources/comment-evidence-mature-reuse-20261025.json": "38ca076e26477e89cd161eaadb0fb00b0ee7bb5a26de2bd6ab41de52faaae79e",
"docs/requirements/sources/comment-evidence-shared-space-20261023.json": "e56299db726e64fbeea9e07d352066ef2b578889cc9a33cf1789e3234862130d",
"docs/requirements/sources/comment-evidence-visual-role-20261022.json": "ecf30eb323129dca2a0d9c2ff2e0163884ee94d3fc7828b0540cd45f03103797",
"docs/requirements/sources/comment-evidence.json": "d08ca2359e16d048fdc46b4b5ceee536d3937bbdb7b16e42598b831e508f2370",
"docs/requirements/sources/design-acceptance-v1.md": "21f76eb69da11e8b2f3a5addb2f7303c1d9f766953595924232582b428013b12",
"docs/requirements/sources/dynamic-sample-effect-acceptance-20261023.md": "f5e9fde65a58ebc365de47569053ad7c30ce77ae82bb78773cc070e3cbffd90e",
"docs/requirements/sources/frontend-architecture-v1.md": "eb05f1707a932014d613072e8c5d95bc8d6a0281390dc93c000d63851ecbbc86",
"docs/requirements/sources/governance-proposal-approved.md": "ff356076d682edad3df54e8257799000127bdbb478d1fbc43d37886a28d65c09",
"docs/requirements/sources/integration-plan-v1.md": "454af1d4a3b0360d33aea9c0e97f738b320968ba7be68e012a5fcec6f6ed0169",
"docs/requirements/sources/mature-reuse-principle-20261025.md": "a2a75e071d3af5db722727bfaf8083bb60d07dcdd74e90d1e1374c1aafd2064d",
"docs/requirements/sources/other-agent-discussion-20261025.txt": "42cd97029d73eace110e49e0566343dfeb25fe4f0633a10b78b126289938aaeb",
"docs/requirements/sources/product-decisions-v1.6.json": "ccc5c50c5604385208eb054caac01e27fc63367dac7731e1be9775a1e5cf46ab",
"docs/requirements/sources/shared-space-static-acceptance-20261023.md": "9aec04ab40e40ce275c75104f004cd3d1473ff8fbef428767afbbe5fafd09cb9",
"docs/requirements/sources/visual-confirmation-v2.md": "6e0229aaf693f215cc9dcc647be694aeafb3770683b21bb7e3e30291af60255a",
"docs/requirements/task-handoff-template.md": "8ee4d6d4c237e8a2c134ba751426975b0e24f03bfd6ac048b80642c69b59e445",
"docs/requirements/task-migration.json": "a5131adbdf14a79df01992d2cbbc4b7aea5b8b377c7115a80cedb04073f9bc7d",
"docs/requirements/verification.json": "7da6c2732cf28d46f8deaafc7ec571b8fc37bbd7efec0126ba339e51bbe12ace",
"docs/reuse-hel847/mature-reuse-plan-v3.md": "584d92cfe67e064df958d8cf8cd66ab49bf54b7426a3abbc208f279b2690256c",
"docs/reuse-hel847/mature-reuse-plan-v4.md": "351e210133783bc5431414ed887b78a897cc09dd6a3cbf4869046417d0f1c795",
"docs/reuse-hel847/openclaw-reuse-plan.md": "5df74412afdff07ee1ab65a96ccb39c4c06f1df7b59651ab8d5ab6470d77546a",
"docs/reuse-hel847/probe-result.json": "de08b408dd632ce672aff2c090d8afc8b125e462ff26e5564df674f784f7db59",
"docs/reuse-hel847/probe_recall.py": "12a7ce7898e3e2541910ce68eb01ef5de6bb2a654b8c762fc76f7c6c64efb98d",
"docs/rework/REVIEW.md": "2cb3425b9df96c62fd81daf0ab22a5d7016de6b74cffdc6bef49a0658d40f464",
"docs/rework/appearance-fog.png": "0e4ca6e32ec31bc63910924b9cc34850caf8dc831b63255bc58d2cd2f493db19",
"docs/rework/backup-fog.png": "590b6206e4ab837f02ae524e726748d3e11dc8c62506ffcf642fd971dd1ba28e",
"docs/rework/birth-calendar-help-moon.png": "765648e39fb33966117f255772c492bb4a510f00279c1a4e2e81173eeae78305",
"docs/rework/birth-preview-moon.png": "de9264431b010e3c856e8436bf228e016d289c4151b62972d49316045bf7c899",
"docs/rework/browser-results.json": "6018d335c81995f7f3a2226183f52a558e9984fe67d25ad63c331d676ca2ea27",
"docs/rework/city-fog.png": "fbab999af93a61a35da2b994d5f78e4a3129289fe749390d3d3e71083f72093d",
"docs/rework/compact-first-press-390-420-moon.png": "a3e84420b022984ed2af6e34c220b649bcd0408f6dd704093144b5e20cf44b86",
"docs/rework/compact-first-press-390-430-moon.png": "8e6b217c1734cf9aaaee616ff9d6c6e7c503ccaa11ad001f5469643321c139bc",
"docs/rework/demo-voice-390.png": "115a5c6af987609469f860ec111c59d64892cf4e618693cfc8367d19f716f2cd",
"docs/rework/existing-birth-fog.png": "e05f816d01a29642457e2f9c38dddbe7f4d4e2c2cf241b94ac96dfd5ba705694",
"docs/rework/existing-growth-fog.png": "65a30346ee685b8249e1dbed5217b15b5422ac9e0453a64417fd68c991cb0e24",
"docs/rework/full-history-fog.png": "d95eb4e69c98450d0b20e982eee49eababa4f998ef3b51bd76549e14d3f6d02b",
"docs/rework/full-history-moon.png": "db0fe19658abff84ced08744fa4a3e443dcaf8ddbb495862077d552e2484ac3b",
"docs/rework/hel874/README.md": "cf65d13af9c8d6967fa815fafcd1a289ebd3a866018d925b549b64c697973d93",
"docs/rework/hel874/erratum-hel882.json": "7914d30ddcfe8f32e58ec181b78a9c0b60674f6b3b0d1975d0cc21a235cb52c1",
"docs/rework/hel874/real-sdk-after.json": "5f342b3eea44339eeaf6d90b001ef39575899ca5e3ef854054cad2a6c68eb9e1",
"docs/rework/hel874/real-sdk-before.json": "277f57bc4dd9ba556e65f25b1a042a6f5cbc940ad1e7170316885e3e752ae2d2",
"docs/rework/hel879/browser/real-boot.png": "5610d244e72ef0e5e675a6fe48fc36bda5e797657a49ea70db79355b563a612f",
"docs/rework/hel879/browser/real-browser.json": "f046bd865790120897602baf7179d51045ea3cceeec45acfeabc2acc7ff1a1b1",
"docs/rework/hel879/browser/real-card.png": "366c80949935b1de5dc16df718e8e4126c873f172dd2f0f0bfdd6893fbd608bc",
"docs/rework/hel879/browser/real-gap.png": "c1a7709ad49b82db1fdb86e814f61abf2c573c6ff79f8fe16c98d779613ab8f0",
"docs/rework/hel879/browser/real-record.png": "7dfd5a631c2f9916c1ae35d2a7d60c280c4814132becea69e80af6c98059bd1c",
"docs/rework/hel879/browser/real-stage.png": "e2e5bac215e47a46e088cc5ed518e1f12ca7a70acc61df4397ae342680066b3f",
"docs/rework/hel879/d1-audition/README.md": "0da72f608956457ec67624c6d79bfc5c0d1cdb1ae7c918a0781abc5e0de371ca",
"docs/rework/hel879/d1-audition/manifest.json": "66605e35df3ea8f73c8fde6f6efa3163cbb1359d65f48810b1c88be556b2f9fd",
"docs/rework/hel879/r5-window-20261027.md": "c095529a2841156cc42a2b95c371dea01e00da263a4be1fd52937c2baef91ce8",
"docs/rework/hel879/r5-window/call1.json": "ec8d3ef7c7405573117e8d55091a8aca591b73f4253f492f72f9415505f9685b",
"docs/rework/hel879/r5-window/call1.subtitles.jsonl": "69f41191150f40db49eeb948d0412c0da6bd9734b7d3384598616cc7d506717e",
"docs/rework/hel879/r5-window/call1b.json": "1a110cafd9c2755682b82f4f1b3025eb9105475d0cc85be32f73019b869f5fc1",
"docs/rework/hel879/r5-window/call1b.subtitles.jsonl": "15f0cac2f5af3a97c1e36115f4cb14761f0ee352a62d1ed7386ceaefab2214b3",
"docs/rework/hel879/r5-window/call3.json": "a7a4a2897da9713871cfaee2efb21daeb4df370795fa87d7eb3006d1178aea35",
"docs/rework/hel879/r5-window/call3.subtitles.jsonl": "6ac5e7a330b387a44af7bfe8a202eca513517983d15641f19cdef536143edc5c",
"docs/rework/hel879/r5-window/evidence-window.txt": "7447b6aa49ec8937ae7648d95b4cc423a4d6a28f31f071e1e218b8bc418e8dbf",
"docs/rework/hel879/r5-window/worker.log": "c5d7ea664f8b0d14dbf0a786dcf10ec6be7dde581dc9754681752f9e8d5406a8",
"docs/rework/hel879/regression-close-ownership.json": "4fe1dcdb6aeaa514251b9fd8c72efe6d4654eeadcdfb0001786c1d6dee3a3a1b",
"docs/rework/hel879/regression-inflight.json": "bda736af33860f827df6024dfd45da1bc089c8afbf2756a0920d9592728d3ecf",
"docs/rework/hel879/regression-mem0-real.json": "da2d6bb865ec2abc8456c7749bcdd3ef3dbdce944f2aa6da2d4641128f28a4d0",
"docs/rework/hel879/regression-semantic-recovery.json": "e78976c1e2856988accf0df0727e52a7880f425e31d9f8984b1454f1d68a44cf",
"docs/rework/hel879/regression-shutdown-queued.json": "b069772ad232b1d8b4f29875c7e7bec9d133a061835e86db3986f7dc00ea7344",
"docs/rework/hel879/release-artifacts.json": "3212d6200d57bb5c1b43c584c8eda5ee25f37444b2b13bf4e02174f739f7bcde",
"docs/rework/hel879/release-check/hel882-release-check.json": "5ba134d5cced47431bb631803c5617a84236f099e06c90153a3d27d3e503d1ad",
"docs/rework/hel879/release-snapshot/selftest.json": "afa3fcfc8a76f4eb5ef8cad2e6578a49c89f39e1b83cacd4feb2284f831f0ec7",
"docs/rework/hel879/sdk-audit/sdk-audit.json": "5fc52280ba998f4d57ef8d4a6d5db8bfcc12c4b3e3ecef2aba79172082afaf67",
"docs/rework/hel879/sdk-audit/sdk-check-hel879-rework.log": "99f0c00dcfd7366c352780542bb169c5ce9df3e2883dc2a776cb5aec10a9042b",
"docs/rework/hel879/sdk-audit/summary.md": "28786b10f79109ce921791a1635d56338d755a49d3c371e84dc059b8173908c5",
"docs/rework/hel879/sdk-check-hel879-remaining.log": "95e0b29d63f32adb4551e7195dad44926f741ae3b8dae7c0f15600fd6242c9e0",
"docs/rework/hel879/sdk-check-hel879-timing.log": "32158bd3ead2d05e76c7b6d43ed9342a9b4df4bfe5be115f7065d6de312b5f5e",
"docs/rework/hel879/timing-fix/delayed-dispatch-after.json": "368771cc63b83f7a5cd202afc57eaa639856e69a2e32414432031b5aaa846040",
"docs/rework/hel879/timing-fix/delayed-dispatch-before.json": "16942e6d43535fb175c2c69283a680df2c139f86b820b1e3093fdcbca7bedce2",
"docs/rework/hel879/timing-fix/sdk-probe-after.json": "8dce4cafc9a463149c644e83eb7fc2e239d8fb95c4a9ba1885bdcfb0af7dc4cd",
"docs/rework/hel879/timing-fix/sdk-probe-before.json": "42ed372966e41ebf1bfdcddfea22c4193f4bcf24a35c09accdd1db1cea1119d4",
"docs/rework/hel879/timing-fix/summary.md": "3b888256ce8bbd8b1994a3f9eeae59d0d35d3d6d5121664141ac2559e4b667e0",
"docs/rework/hel879/unified-review/final-review-20261027.md": "7c73a5d00a8d12e875f6e6b280cdc0264971d7f0b11e3b2a588da324f2f49690",
"docs/rework/hel879/unified-review/frames/sidecar.json": "6793b779e4469943f9ddaca78cb21eab1e8f5466521896b4f397792e863f78b4",
"docs/rework/hel879/unified-review/frames/worker-frames-raw.json": "c2bb153888d5f94957a0f6dd1cc4efcc6e12c658c75f5a1029ce141207e606b5",
"docs/rework/hel879/unified-review/logs/requirements-fc905fe-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel879/unified-review/logs/requirements-fc905fe.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel879/unified-review/logs/requirements-main-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel879/unified-review/logs/requirements-main.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel879/unified-review/r5-facts-20261027.md": "5bf2b54c45d2c3730801afd0db3d0246362278dc49a3274266b8760451371c69",
"docs/rework/hel879/unified-review/r5-test-package.md": "82b1f7fadab74ab1aea1f336af84c2f96f03c22c9abf5faabcd68d4216dfa7c8",
"docs/rework/hel879/unified-review/seq-job-analysis.md": "cf621542cf609083f8ce4597d98727c1eecc5b6d3442a8dfafa44bdf0d0ed7d3",
"docs/rework/hel879/unified-review/summary.md": "0b6f576351099185d74bc294c35343643d41a38f75fb6c2cedd8cf23cfbcd2a7",
"docs/rework/hel879/unified-review/wss-bridge-smoke.log": "c2c6f0fefa84be22b06bd140a34916563995cb803bfd199767241f1beaa76f38",
"docs/rework/hel879/voice-seam-interfaces.md": "12a107bf05cb17262bb5c9a24614092c7967aa43c0c4c7ff10d43444536a3293",
"docs/rework/hel882/HEL-882-sdk-addon-stub-switch.json": "1fa6407d9cb9fd39ae9b576cf530c77891bf0e1c799033cb5a94b33d0ef86ec5",
"docs/rework/hel882/README.md": "a87fd204e50c56531700f176472d005f7e89868c420b5f7620fdefd84205eaad",
"docs/rework/hel882/addon_stub_switch_hel882.py": "eb8e3016c517abfc2ebd4e4b5fba5f7b4fbeade178dbae3f4c0bc09c3a48dcbb",
"docs/rework/hel882/manager-review-api-rerun.json": "d3c9cc125a6d4494dda7ab98bd0a7b6a049338ed4f8491f51cb7c59b168c9699",
"docs/rework/hel882/manager-review-sdk-rerun.json": "f30a0f95e21c306f948bf748e799e67b9f449bfa0ce575eff7868492f92febec",
"docs/rework/hel882/real-sdk-hel882.json": "8cce7b2e9574089e235961594cd7335863dafcaa2383f47143e5a2a4ee82f9c8",
"docs/rework/hel882/release-offline-hel882.json": "854f11fa19d78ebfe8bbf7e03f378ae4097dd244de0d91b38152ae0f3d543316",
"docs/rework/hel882/review_hel882_api.py": "bc58a1faad731dfbc637e78bfd9b5e8dcf8b1b6c088121b1254bc94631f23970",
"docs/rework/hel882/review_hel882_sdk.py": "0d27a64ca971006747ded0749bd156bbc5900bb327c3bf447b797cc005f8534c",
"docs/rework/hel885/README.md": "a6b90140b73a59ee0b019d7602e7da2e77313fe2b2af9af47527384ae1d5dfc6",
"docs/rework/hel885/manager-review-extra-rerun.json": "31a20ce30ba851734ffc8f1b318b1e2324d0c22f8a4e0198630b3e492363b891",
"docs/rework/hel885/manager-review-partial-rerun.json": "b7fb2ff659bc701ee3cad88aff49cde96f043eddbe53724624e97b3a4bbdcc22",
"docs/rework/hel885/real-sdk-contract.json": "e6850945a4b44cec7c3b31486995521ab4a82b4c0156f41d103efba350c98b56",
"docs/rework/hel885/recovery-real-sdk.json": "6e870e0356162293687ee68bfb50afa6d11043e03e7028d018b2087c82eba1f2",
"docs/rework/hel885/release-offline-hel885.json": "c31cb1119541a70df93f1342d9d8ad12e196a8a5ffed768904ba03bc4770c5fd",
"docs/rework/hel885/review_hel882_extra.py": "8153c198a2a926916c593fa5369abda9eb8f6f7ccac7dc8e26b273cdaa6497a2",
"docs/rework/hel885/review_hel882_partial.py": "1f8f72e55e13218b92857f8a89bac95c7a83c139b844f1ebb8cefbc22b27de03",
"docs/rework/hel886/HEL-885-inflight-baseline-78b6267.json": "1f8c0edfbc88482fcd2c6186b70fb482d537ed3951ffb795f5e885d5c69a51fc",
"docs/rework/hel886/HEL-886-inflight-rerun.json": "f0fdd0056c7c569efb8103f2c82170ba2ecd6c2d608c1e6a5ad0065b5f8d59d6",
"docs/rework/hel886/contract-hel874-rerun.json": "e4c66881f0c19d39edbc16ede18f53151bec333bf3e6aa6b773d3f9a3d706f8f",
"docs/rework/hel886/inflight-real-sdk.json": "5803ad65587489b6d2382902b70922537c47a8d233a041ac043c7060f1fa3741",
"docs/rework/hel886/manager-review-extra-rerun.log": "8f46c743ce7be4668c48fc91f2dd2031bb1ae4bf4c2c8586e61dd16ea1bedde8",
"docs/rework/hel886/manager-review-partial-rerun.log": "3531914e4218a4d7528f7b50a845b778d0bdf41c1266b210f44428e80de494be",
"docs/rework/hel886/original-verbatim-after-fix.log": "753944017e39033874b2a026b31ef2e9c4e0137502847b29da1c0f08d8817a21",
"docs/rework/hel886/recovery-hel885-rerun.json": "db162f62e3cce19404045028a65fe1ef1b4f6823e2590a3898d36dba8d00af0d",
"docs/rework/hel886/release-offline-hel886.json": "671e73775e313264a31bb369ca4ce023130f800e74dcc56561c59a00abc4e3d9",
"docs/rework/hel886/rerun-inflight-fixed.log": "8727ba7711137e6b57e7e1baff6cb9cd5e7b531a1860fdd96afabc4fca6f15d5",
"docs/rework/hel886/review_hel886_inflight_rerun.py": "d806c9a884314a0646d7d263f533f33daba6707cc040ba59fd7f73c91534c785",
"docs/rework/hel889/HEL-889-edges-rerun-fixed.json": "1e2da91b0f5af7e0e9f20000be9e0510d776dcbe1189775ded59c9a69e8a84b2",
"docs/rework/hel889/README.md": "0c71977bb34d7687ea53269edce597827bb550a136d6ba6b6961989c4b354ab0",
"docs/rework/hel889/contract-hel874-rerun.json": "7d535f3128fb06915f38f6b00244b2faeaaa368ab04e6e8d2b2b967fe92052c3",
"docs/rework/hel889/contract-hel874-rerun.log": "63a94f494dfb25f15f834a8927b7897c9dd5cd4647b12485dd78bfb318cb609b",
"docs/rework/hel889/edges-real-sdk-hel889.json": "24ed3a2be6aca29ab6e9a9cccc5b9f468745237ed9d45105e4259df7094dd25a",
"docs/rework/hel889/edges-real-sdk-hel889.log": "4f6ac192eb7bd952d5560ce1ff6ef879940bb6d25bb42aa0e9a6d8ab1d0dbf41",
"docs/rework/hel889/edges-rerun-fixed.log": "35da33b2f898a177b0bb781532e4b8fc6da7c5a5d57d87649f5a85d8d697319a",
"docs/rework/hel889/edges-verbatim-before-fix-f1b50ad.json": "622db8e329e710c56949a6e938b0eaab926f22a5377ac1496c718e268734d63b",
"docs/rework/hel889/edges-verbatim-before-fix-f1b50ad.log": "d561d2a1e617e9837354bb9a0617c710e279ef83d386b401bc4751c8fcdaa238",
"docs/rework/hel889/inflight-hel886-rerun.json": "429a419b4329fda60f249a83e30eb8820ba8681b769c8e11ef910d0fe34a0d9c",
"docs/rework/hel889/inflight-hel886-rerun.log": "51132b0edfa1707d32a926fc1d76cb467c6aebcab82f0de23211aa4fae757bfa",
"docs/rework/hel889/inflight885-verbatim-after-fix.log": "1bc0a7063fe271c670a945121cdf307ec5ea5e96d8dc9bf1ca211895ff7c50c6",
"docs/rework/hel889/manager-review-extra-rerun.json": "31a20ce30ba851734ffc8f1b318b1e2324d0c22f8a4e0198630b3e492363b891",
"docs/rework/hel889/manager-review-extra-rerun.log": "8f46c743ce7be4668c48fc91f2dd2031bb1ae4bf4c2c8586e61dd16ea1bedde8",
"docs/rework/hel889/manager-review-partial-rerun.json": "193040328c630fed808a42507714a214e2db6e5a88042ed68b8fcbb655a50560",
"docs/rework/hel889/manager-review-partial-rerun.log": "6948e3ff7e268d3b940fed813030ad0b3e1f488b0374957e2d6bce00d0c3ca3f",
"docs/rework/hel889/original-verbatim-after-fix.log": "2b2bf6d1a53b31803f9274b6c83bf18a80be13d04c071336822f36e8ee935b7d",
"docs/rework/hel889/recovery-hel885-rerun.json": "e2d603b777bb949aa9cbc1ffab027b004ed9ebcdc292c705c0b74c9c6767597b",
"docs/rework/hel889/recovery-hel885-rerun.log": "193512e7acc2df77a4cc1649515ca5fa0b963c165b733babbcafe50fbc4ff349",
"docs/rework/hel889/release-offline-hel889.json": "a91e67b5492b9aa196fbc99a1a18605396441a2534cddf7ea14b22dd22bf14b5",
"docs/rework/hel889/release-offline-hel889.log": "6adda1a9d16e458c02d6a9b6e9cd65b9aebfdc81d826aeff55f8c1df837580d7",
"docs/rework/hel889/review_hel886_edges_rerun_hel889.py": "2d9805f4f2bb210877ea03106012068d657e3367f4ca5769439beef53a3dfc69",
"docs/rework/hel894/README.md": "f7f6ffc21313507a1ca3d8c45f330f4229f15d50c274ae9252e5b4e28226c39a",
"docs/rework/hel894/before-ceb0f35.json": "6bdd1f12de0b74bb281ab47c969945bc384671fa2ef92d6e7c548967079ec824",
"docs/rework/hel894/before-ceb0f35.log": "cc5aa75523def5975476fcd45825d518914e886b46e75748b871bdddfc9adfb7",
"docs/rework/hel894/close-ownership-real-sdk.json": "35edac5f922f8c1bc556ce109e0cb0dd44aabcb7864728b14c4f6c14ba1bc03c",
"docs/rework/hel894/close-ownership-real-sdk.log": "d152d5d69231a05eb73620316db9493528eaac474451126a0e7696a4f2fdf432",
"docs/rework/hel894/contract-hel874-rerun.json": "c1ebe639712ccb76c54f53e531d7fb5689a2bfb94eb2a0220e0a9b2bc3fc8b76",
"docs/rework/hel894/edges-rerun-hel889.json": "2b5fd2dd6aa60c9b1e3a0b1b4ebb62776a006dae1b89b2099f8b99b65e30034b",
"docs/rework/hel894/inflight-hel886-rerun.json": "16fd3f06e38c55d16cd49eee36d505950c4d6df42c5d88f7fddfdf08e468966d",
"docs/rework/hel894/manager-review-extra-rerun.json": "31a20ce30ba851734ffc8f1b318b1e2324d0c22f8a4e0198630b3e492363b891",
"docs/rework/hel894/manager-review-partial-rerun.json": "b0ffe7b2a765ce230641780a11be32ac933e3079ca981db8c32fccb3d273a761",
"docs/rework/hel894/recovery-hel885-rerun.json": "072838353bf06aa0eac3c8cfa9a653aa1aba2c6e40cf4fa78250f35eed848b35",
"docs/rework/hel894/release-offline-hel894.json": "a01c5dcb054981cd2e966d809ab04861b678109eb901e7c627dd3417649c5885",
"docs/rework/hel894/shutdown-queued-hel889-rerun.json": "2dc2e61073fba8f128ecedba8bf5c6040580a936ccfab00677d78352def33007",
"docs/rework/hel894/verbatim-after-fix.log": "562eb581b196c64b3d379f733f2bca500042d5b39d84027620afe9e0e61f95f2",
"docs/rework/hel895/http-pagination.json": "6d7a24e89fca3ae0dcb4b3ed4ccdbad05315c428467c286273c5a443f9ee1811",
"docs/rework/hel895/real-boot.png": "71f15746f8e2c9086980b1b0937a88b14ea6f409d67ea2e87ca3f89bb34f7788",
"docs/rework/hel895/real-browser.json": "bafb54812a41406c346082287eff8fbad69edfd12521a58dba544cd8a3ec22cf",
"docs/rework/hel895/real-card.png": "d29907914917ba499138c8aed562c5ce14e7b8819b37d168f65ac05e959d09b6",
"docs/rework/hel895/real-gap.png": "4ac193acaaf2cc4f6b84e5b6c3ceb83b95ad69dade965fc1b1237373fa092409",
"docs/rework/hel895/real-stage.png": "0007a60053b154c2c4b7754b0389e69566174df22e50b66cdbdcd4e73d953f1f",
"docs/rework/hel896/README.md": "2e80276baee4bf1bc4836293b74aa3c2803a39bbb4d3a1fd0651bb6a3c5c7f7c",
"docs/rework/hel896/browser-before/hel895_real.mjs.original": "d7f682aa268f78576974b2ab515ca87aa630a27aa88ccf954c5c85da67abe777",
"docs/rework/hel896/browser-before/real-boot.png": "9e8f192db66acb7e12fdbb68e197878b4fe355076adf5d68c7c4f66182ce8f60",
"docs/rework/hel896/browser-before/real-browser.json": "bafb54812a41406c346082287eff8fbad69edfd12521a58dba544cd8a3ec22cf",
"docs/rework/hel896/browser-before/real-card.png": "51475111fce910ce7df91a45274c8522249013bb8a5f0691070b3ea31e89bb74",
"docs/rework/hel896/browser-before/real-gap.png": "045c0f025b17780828d12f7f742521151cdf2a26eb88a1f26a2221f9ea59d1bb",
"docs/rework/hel896/browser-before/real-stage.png": "c7c4996d36e4542f0d6075a1faa999f06dac96e2c8baabd9bff5fa1bcb248ef2",
"docs/rework/hel896/browser/real-boot.png": "574bdef19cb49b9767d760e17806e9b4be4c74531c9165b1f10108ef6a23bc2e",
"docs/rework/hel896/browser/real-browser.json": "f046bd865790120897602baf7179d51045ea3cceeec45acfeabc2acc7ff1a1b1",
"docs/rework/hel896/browser/real-card.png": "eeb3b7713ca9950e42fd7dad33ea7322fe31fa4ff2457c8278055403fa571a70",
"docs/rework/hel896/browser/real-gap.png": "623ddc39640623356fdd3db5b1fbec633bacb55d6264866fc12df9d3859f3ebe",
"docs/rework/hel896/browser/real-record.png": "8925684798fcb879058d15b25cd681920ffb3bf2f51f680f4f057e86beaec8f6",
"docs/rework/hel896/browser/real-stage.png": "e73e8bc79d03c2dfa8d446031c661e78597c6626ff3845cd0ca7a9ce3fbd4846",
"docs/rework/hel896/browser/run-commands.txt": "fe9554e7bc876041b9aac92a434cd48a98746a4f54aed29cbc4e60a73d12708a",
"docs/rework/hel896/budget-split/budget-split.json": "2306bf31bf46c0b7ef69589dcd33bef67988789ec7866c938244140f5597986e",
"docs/rework/hel896/http-pagination-rerun.json": "936c07b50d7487e7121c88c8c64e0b440ffd406966e705acc46504c801f253af",
"docs/rework/hel896/regression/verify_close_ownership_hel894.json": "1cf311a752222c9f9e8247e207abcd54a02ec74f1f3aaa267de1e7071519dc6a",
"docs/rework/hel896/regression/verify_inflight_lifecycle_hel886.json": "0306d9aa599bd21c71e942522323285dd7fca7f975cc876b8d58ae183730ae36",
"docs/rework/hel896/regression/verify_mem0_real_hel874.json": "4b0a3f75848d017c9ecc9e8b15d080fad40320280a7af41bfc4f5909ec05c519",
"docs/rework/hel896/regression/verify_semantic_recovery_hel885.json": "2ca8109bb8e18d2e42341c9f0002c99f2a18bebaae55e106c87440e5946d056a",
"docs/rework/hel896/regression/verify_shutdown_queued_hel889.json": "51ce6516daf0f1625bc41be587e28c2f7c7ece7918083ad3ab32e61be19aa156",
"docs/rework/hel896/release-snapshot/selftest.json": "8cd9e006c26c24307b58707bd83bba785a4f6438e9f3d922fbb7318d3b0f4121",
"docs/rework/hel896/sdk-audit/sdk-audit.json": "16447ddd5a0fdd5d42c60c55544666ea9d729c66d3b2d72d088d845c92aaf947",
"docs/rework/hel896/sdk-audit/summary.md": "28786b10f79109ce921791a1635d56338d755a49d3c371e84dc059b8173908c5",
"docs/rework/hel897/author-MANIFEST.md": "b77c014ba496ce1d0d25d7af7166fdf5d2b6ee9f88dd55bef0b8dc5d399faaeb",
"docs/rework/hel897/preservation-review.md": "da576648238dfac4b656976f8ddab0a73dca9b835f8f8df3c2a331c2265b8365",
"docs/rework/hel897/preservation.json": "f4b42d482dcc075112e49775b75dd42d98dba587db9caf499cc4bbc1ce4b853a",
"docs/rework/hel897/verification/baseline-voice.log": "bdac37ef3f11862f97e4203e5f89398cc3733f94614e038628d2764fff118ec9",
"docs/rework/hel897/verification/build.log": "ae7afd2c0039c63fae3a98d1e6f9e2297f6571daa5db6e9dc5ede1b39b29bb5a",
"docs/rework/hel897/verification/lint.log": "cd386c675832086bd4e971d9013795802169abdc5f17ed5685bec83cd3e62a17",
"docs/rework/hel897/verification/requirements-0-878.log": "390db0ca70a614ce52762e0c58f761ff9c352d3aa7f33d33e44220473a91b43f",
"docs/rework/hel897/verification/requirements-0-897.log": "a28d30af3b4008b7dcf00a8368c56be4fe9ca90a5dca597665dedb31ede63130",
"docs/rework/hel897/verification/requirements-0-plain.log": "bdc7c0adceb6a4167ed86405cca65372102be43fe14aadfffef649d00d3bccb5",
"docs/rework/hel897/verification/requirements-1-878.log": "d047c9640b88fc2bae9935b1edc673fa78b1f395e2cd7273a970f64559260328",
"docs/rework/hel897/verification/requirements-1-897.log": "e7850ddeba1c4599986f0baa681c03a05483b50242366c24929d77b9903671e6",
"docs/rework/hel897/verification/requirements-1-plain.log": "23ce0f8507fea249ca04266ff2446e1ddc6cd548507cbc5a593b5cbee195ccc9",
"docs/rework/hel897/verification/requirements-2-878.log": "1841b81cea75ee5c9c1f7805f15a9097064803a415f34baf487cc636dd3e1247",
"docs/rework/hel897/verification/requirements-2-897.log": "fd6c1d457a39b7dd139e904013b1294ca1fba3963873957c99e59065e0c181e0",
"docs/rework/hel897/verification/requirements-2-plain.log": "dfcfb121a77a5e61f8c86bf85ceb3f552599a2d2208dae7e03d142747289d4ec",
"docs/rework/hel897/verification/requirements-3-878.log": "4b74e9de0e9c3bf5c677b6190886f225ee5e030e08e46b75c317bdcfc3c8daaa",
"docs/rework/hel897/verification/requirements-3-897.log": "83329b71f5a62662dccf6f55a8818ab4c756a6608fc57f345cf66f2eddad325f",
"docs/rework/hel897/verification/requirements-3-plain.log": "5ef5db73083d4072020ea7f77a06977266263cefb46aa3a49488f5e8792390b3",
"docs/rework/hel897/verification/typecheck.log": "d33b2ad7a4c36716723e149dda586ca9ce7e7330f24a096f4763bcc5d52c4bfa",
"docs/rework/hel897/verification/vitest-all.log": "718c853381bf572be1dd716eb747adf095ffefc9a97d847c06946ee4a09a17a4",
"docs/rework/hel898/HEL-898.patch": "9e653a45bcee4e1927c797bb94869e259d98298188485c03b5bfb42a221fea37",
"docs/rework/hel898/MANIFEST-HEL-898.json": "f9744e3513129df9c23a86c3dfa934ade47e3d4b3e53ad7adbc0ef706121811b",
"docs/rework/hel898/author-logs/before-fix-voice.log": "1d19bb00239c89466a1e7fd893ee002a492e0c666ef211042f17968107cecd32",
"docs/rework/hel898/author-logs/browser-hel878.log": "cb4d8e2f1b7f75b4e36bf94e053ae3ac7d297b66cf7c479018a67e211e48cde3",
"docs/rework/hel898/author-logs/browser-hel898.log": "438e5a87615ec3005040fa2a0946244fa6c4d083a9629160d8243a3e0c09dbe8",
"docs/rework/hel898/author-logs/build.log": "f2e1d0c89c8a81d808dab307f891221d99277960a55b9ef96c0577b43bf692ad",
"docs/rework/hel898/author-logs/lint.log": "1f1b4b564f7342fa50ed2a44b0c1fb110476efc42b04e1ec9880053b3c5fd940",
"docs/rework/hel898/author-logs/typecheck.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"docs/rework/hel898/author-logs/vitest.log": "9979ce6e9e813c8953ae6ce5440e9934f497848ffc51ee865f729bb643498a46",
"docs/rework/hel898/f92a86f-rework/HEL-898-f92a86f-rework.patch": "ba1aa445ff7e3be6c4f1ae60866c38a793f06d564f2edc39c21d1fa1142b4c14",
"docs/rework/hel898/f92a86f-rework/MANIFEST-HEL-898-f92a86f.json": "082fc1b0c655c4a24f9a8cbb2307dbc2a28decccc47169fdfecd9357b13c7e08",
"docs/rework/hel898/f92a86f-rework/README.md": "ababbfd5f2809feba7b075083bc89ecdfa487d737db4050e0c951b8ef7eb75de",
"docs/rework/hel898/f92a86f-rework/author-logs/before-fix-vitest.log": "1b38231602e232a10661b1da4abbb08071a0c67aa0d33a224212ae2e2c9429dc",
"docs/rework/hel898/f92a86f-rework/author-logs/browser-hel878.log": "cb4d8e2f1b7f75b4e36bf94e053ae3ac7d297b66cf7c479018a67e211e48cde3",
"docs/rework/hel898/f92a86f-rework/author-logs/browser-hel898.log": "9c24c70b57e87478a45cc28f98f1bcd360259d5c4f8108559cc54d2a6e184bdd",
"docs/rework/hel898/f92a86f-rework/author-logs/browser-unified-after.log": "f6d27faa14e3cb5fb7d4e6726476d1e1b6cede8da7953b8d4a8915677a676943",
"docs/rework/hel898/f92a86f-rework/author-logs/browser-unified-before.log": "be9e6e9b1095c42c593b7227479e8b865fd2f3f69ea5044ed41e1396749a9a53",
"docs/rework/hel898/f92a86f-rework/author-logs/build.log": "ea00bf64f258dee412fa16c3f91f2f8eda0f7d508d9ff4cccc4c699373341ffd",
"docs/rework/hel898/f92a86f-rework/author-logs/lint.log": "b91a312f6a7093ab71938a5000bd7e74d495dced85b4cca806d1c07cf5d0322b",
"docs/rework/hel898/f92a86f-rework/author-logs/mock-hel878.err": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"docs/rework/hel898/f92a86f-rework/author-logs/mock-hel878.out": "aa3283544f6aaf179ec8889b8e5b4f40140d9d931734f68f4fcda4236fe431b7",
"docs/rework/hel898/f92a86f-rework/author-logs/mock-hel898.err": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"docs/rework/hel898/f92a86f-rework/author-logs/mock-hel898.out": "8f5c505be5b4a53a1004146453981eaca882443b9ee5493f6ef75b316ec166ca",
"docs/rework/hel898/f92a86f-rework/author-logs/typecheck.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"docs/rework/hel898/f92a86f-rework/author-logs/vitest.log": "8e8956c2eaa5efeb3bb98f54df75fc7d0e620551bbc3989ff58bf558516e8320",
"docs/rework/hel898/f92a86f-rework/manager-checks/b0dcb17e1acd970f706763efe0fcb7062596039a-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/b0dcb17e1acd970f706763efe0fcb7062596039a.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/browser-launch-failed.log": "ce337e1d74ce2a15a29e4fd13a5ba55be2dbc306f7e4ac98f65183c1d6c8730c",
"docs/rework/hel898/f92a86f-rework/manager-checks/browser.log": "212a347da8189c405357caac49551848dd742c68f969f96cdae89f39302f98c6",
"docs/rework/hel898/f92a86f-rework/manager-checks/build.log": "f99c37ce20446d893da9034b6c94657fe1a780ae53c94278aeb02c20da98f4c0",
"docs/rework/hel898/f92a86f-rework/manager-checks/f92a86f5d8f839338311cdee08179049de34eb20-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/f92a86f5d8f839338311cdee08179049de34eb20.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/lint.log": "a6d2f20be6af0732befc1f4253f56785ddce8d4efa8fef542d12d229e715567f",
"docs/rework/hel898/f92a86f-rework/manager-checks/origin-main-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/origin-main.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/f92a86f-rework/manager-checks/seq-renumbering.json": "7fd2bbe038be6cff322782819af1123b1f12aadf99fcfd2d676f2bacf1c5152b",
"docs/rework/hel898/f92a86f-rework/manager-checks/test.log": "2a4061596e1c77ce27669bbf90a70edd5995b2fae18f056c55dbd9f261a731f0",
"docs/rework/hel898/f92a86f-rework/manager-checks/typecheck.log": "d33b2ad7a4c36716723e149dda586ca9ce7e7330f24a096f4763bcc5d52c4bfa",
"docs/rework/hel898/f92a86f-rework/preservation-review.md": "36219963433b4f286e46dcf831da9c1df88f4d940ecc4b4f5a7d15b44e15ce46",
"docs/rework/hel898/f92a86f-rework/preservation.json": "c1b07b77d250d034bfe72e806b8a85f20eb4d6c4a0e1b08f3fd98afb8e0e92cb",
"docs/rework/hel898/manager-checks/build.log": "606ca426f708ce742d8febb2610a455a179e1b22038fd4f322fbbeb882785450",
"docs/rework/hel898/manager-checks/fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/manager-checks/fc905fe6bd1cad3b3c5469dfb06c68a6f1c77a4c.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/manager-checks/lint.log": "bde4108ccd7f12e3e499d9aa3489710f250169aa5cb709d446dffaf15e35d54e",
"docs/rework/hel898/manager-checks/origin-main-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/manager-checks/origin-main.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel898/manager-checks/test.log": "955c26e7260e552317f01c95ed40984ff460ba06cd710f9a12f231144d3ea760",
"docs/rework/hel898/manager-checks/typecheck.log": "d33b2ad7a4c36716723e149dda586ca9ce7e7330f24a096f4763bcc5d52c4bfa",
"docs/rework/hel898/preservation-review.md": "7b30452cc64fd7468c6be941e6a42ea4411d73678787d81059f93e7eb2d88654",
"docs/rework/hel898/preservation.json": "9b34b4520de79e5473a171c7789c506219a432e6f4fcb6e305816fb830f59f0e",
"docs/rework/hel900/preservation/HEL-900-author.patch": "994d510231b4820baa4009a90ec4c8b5a560c41f44b88a6cef1a35b590094590",
"docs/rework/hel900/preservation/HEL-900-git.patch": "33f7afe059fa9a9684ba545ba7818b718ac0e7a58751b9bcfe142292bf50a1e3",
"docs/rework/hel900/preservation/MANIFEST-HEL-900.json": "d83b1530d9815fcd531cee8a496491eea668e75d007b402ee10c734ac23d3df9",
"docs/rework/hel900/preservation/README.md": "6ab08385da75377f7decdbd07335e2f970f08d501aad9faf420257da90f063c4",
"docs/rework/hel900/preservation/author-logs/browser-hel900.log": "c75130714cbb1f865f944732e3049d646a093bf76a48210c938a5ba0257e9239",
"docs/rework/hel900/preservation/author-logs/build.log": "d80a8cc7620f16b21864f5e08ecf4b049fe13c64b479c147ed59f5472d8eea17",
"docs/rework/hel900/preservation/author-logs/check-requirements-handoff.log": "ad23bae99ef3cc51e5b98b6018b22ee4ecb570fdcf78ada981a4614f55a88aef",
"docs/rework/hel900/preservation/author-logs/check-requirements.log": "ad23bae99ef3cc51e5b98b6018b22ee4ecb570fdcf78ada981a4614f55a88aef",
"docs/rework/hel900/preservation/author-logs/lint.log": "975339138b6edca80852f5285338d2e6ba56189959e61ba54185152006b9fa86",
"docs/rework/hel900/preservation/author-logs/mock.out": "68d43ad9fa5c8faac342b628b3282473cd2c133e0405e3f86ffec0ed430437de",
"docs/rework/hel900/preservation/author-logs/typecheck.log": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"docs/rework/hel900/preservation/author-logs/vitest.log": "40416149588d3ff63c1e440bcc3c903895edcdc2c96a6629bb396d8d9216fc73",
"docs/rework/hel900/preservation/ce7279e68f64319c080f-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel900/preservation/ce7279e68f64319c080f.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel900/preservation/origin-main-handoff.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel900/preservation/origin-main.log": "303b8cd465a6a733ae7274aef6d28402289a31ff2d9bf27e543be20ff8579fa9",
"docs/rework/hel900/preservation/preservation.json": "1f710c2ed8b73cfc414199c06e2b640966bd50ffd5f01ada895b52986a8e4e90",
"docs/rework/hel902/REVIEW.md": "76507fc70913a1476724f5143e76d2f1d363cf53c4eb3e33acc9aa3d3215543b",
"docs/rework/hel902/r5-followup-package.md": "5b7667dcccbb726aa4628211e3d4fd3bf10d14123a18db6359df2787f01105a4",
"docs/rework/life-empty-fog.png": "1bbf2570e4a8800f7608dc6640f7b9e44821f399823ce9443294b26a0b28427d",
"docs/rework/live-gateway/browser-results.json": "30be349c389d080901de6a8670eb46782db3fd49db523a44649c6d40fcddfe36",
"docs/rework/live-gateway/desktop-history-moon.png": "e3659bca0e1022477921eda7f68be797b6478b3aabee66b49ea10535cacd7728",
"docs/rework/live-gateway/desktop-model-error.png": "1b379403df9aa951d63bafc93d6e5672aba24d210a1141fcbd7b0328558f3929",
"docs/rework/live-gateway/desktop-preview-moon.png": "d5019e895c42e30598b41419920e2ab6d21963135abe14ee0d168445f6771fff",
"docs/rework/live-gateway/desktop-real-chat-moon.png": "19b7ff408d00db160acf85f8219795e506041e5c6d6bd617395296241eabb316",
"docs/rework/live-gateway/mobile-chat-fog.png": "9bf5e16b0a9627ff6f083a6037a1eb28c33ea096e3704f8a4cb099140d212587",
"docs/rework/live-gateway/mobile-history-moon.png": "5378c9acba14c1f0f355679f46be44e767a9b6ae72fc3715b4f3dc33e28028a4",
"docs/rework/live-gateway/mobile-relation-fog.png": "19dc0bd378732568a0d3e4c48ed11f26bff7e10184f4e27949636d2b11ccc8bd",
"docs/rework/long-end-360-640-fog.png": "4c837b6743263ede73fe47ee73ab74d9545fbb2a7628cbea135ca7a2f1ebec2a",
"docs/rework/long-end-360-640-moon.png": "486266d4ae75bb1750f265c014b6564a9db29403595a2384d80426f2c8574cc5",
"docs/rework/long-end-390-420-fog.png": "8e86a38399aeb4099886ca3a5d44fe1d36bb64292259cc32da4f64aa5f5fa50c",
"docs/rework/long-end-390-420-moon.png": "eb71bbf0ecc3c0bfea52946e220142f91cc79b542534bc1d0fbdbf17953dfda0",
"docs/rework/long-end-390-844-fog.png": "89b9d157d0a3cb357c50d58f38ecfc692b9c356eeeaef30b081aec34f9f0b43f",
"docs/rework/long-end-390-844-moon.png": "3f9415a261915cee3f16e88f66e49142a1cc39fecb05a282677d61661b559b7f",
"docs/rework/long-line-360-640-fog.png": "75cf566114a26e4df2faa12970f9677f075b04c0865629cc99116aa1841718e9",
"docs/rework/long-line-360-640-moon.png": "3b49df960565dff19b46240144381d3891afd8d8e6d358c827600b7a4c357e92",
"docs/rework/long-line-390-420-fog.png": "9771dc93ca1387918a82ebf214130925ec4dcbf80459a63a7694c37e991c67d6",
"docs/rework/long-line-390-420-moon.png": "63442ed8846d4d7ba4e4c0e7e9b65397916982fd6c028010e90482969f34a072",
"docs/rework/long-line-390-844-fog.png": "af30bbde4ad69a07cd74c16c1f042fea3aeb21e496dda6fd90436f2a2c3eba26",
"docs/rework/long-line-390-844-moon.png": "c4573c80e40f9eabcec85232d5d3b8a4f48c64341fbf7d6609f6bef723f4d09c",
"docs/rework/long-start-360-640-fog.png": "fda738f17b21e708a22742c1378cd598d1705cdb71abad3c1128e56791a85ea9",
"docs/rework/long-start-360-640-moon.png": "9d89286b79fe1b8a8ab811701b3997b15d613c9ae1af2a0c1c19ae1072db6cb1",
"docs/rework/long-start-390-420-fog.png": "4ac8dc957c6479d5d41632ef954ba18d1e211ececa4572a04af1a25379d1fa9d",
"docs/rework/long-start-390-420-moon.png": "19d72e5aa42fe4144bc616181e5cb63535f0853580cbf9a9df1d617335cd1256",
"docs/rework/long-start-390-844-fog.png": "566a267f4680bf9e0c6a5bf8a31567266870b5dfdc289149cb17746982ee870b",
"docs/rework/long-start-390-844-moon.png": "3e7feca3c35a6a4e93730a98495c350545ecde343b0fb8f2d5c6d4392678d5f3",
"docs/rework/memory-groups-empty-fog.png": "0f090db810e1dce89380331d5a4373849b1f8432bd065f7e96aabcf42a8098b4",
"docs/rework/motion-enter-0.png": "84cd16febbfef374e2b1337343c0877234e964a6748e8a446d0448496d50f3b5",
"docs/rework/motion-enter-200.png": "0f8785c46e25cf716166f5631aa317401c29a239894e8ac8c06ba07815027ca2",
"docs/rework/motion-enter-80.png": "05dd14acb0a9b9fcea7910a94540895c2dbdcf3798969a6c416d22f46537765b",
"docs/rework/r1-regression/browser-results.json": "c5d47431753028968dfaf767ae324ffa38f13df898fc4ddb3e0931fa5614e8fa",
"docs/rework/services-fog.png": "3d97b02f0098f91e5d7d32c973bbc2fbc2c15287874bc18579bc2c2a0e912e67",
"docs/runbook-hel870.md": "149f57b77b9bba00d7b38c1359540d4adca3cedf313602a2da96159aafe3b006",
"docs/runbook-hel877-voice.md": "1ca96ccf4df50b90b338443d2cdca0181d5ac316216e95611625e5172763eaed",
"docs/voice-custom-contract-HEL899.md": "bf81216b2742e5948ccc4d2184b1a2f2f61303908a128dd7bbc4f6a1c35b03b3",
"docs/voice-r5-readiness-HEL876.md": "0ba8e2bbf51659221762235fc7766e247291186b8ebda3c28f94bfc8780cade0",
"docs/voice-subtitle-contract-v2.md": "fbe77f71b6a6efc69c24d0e4bdbf74d49df13d076870ee24ba1085faf19a2ca4",
"prototypes/hel-799/PROVENANCE.md": "0d03344a0b4fe7324ee5b931b4c1062aeda4c35caa8380c753c20730ccb1ff86",
"prototypes/hel-799/README.md": "8269d688ffbda2dd34608778204f5074079c254dbf69072e7a2fe78e793f3093",
"prototypes/hel-799/build.ps1": "8a5d8cba2c60eb0b4999a482635c317cf8f1df54bb295eb5fca4b5f9464b43a1",
"prototypes/hel-799/handoff-hel-799.json": "812d4eab8f1b4f57b8378ff0a1f810e8b2dce5342cbfc686cf430c03bd2e1452",
"prototypes/hel-799/index.html": "826bfc045d941c2114b16a198626ca952ff3d95dff02cfc6e32ee09858bcf80e",
"prototypes/hel-799/records/comment-evidence-shared-space-20261023.json": "e56299db726e64fbeea9e07d352066ef2b578889cc9a33cf1789e3234862130d",
"prototypes/hel-799/records/shared-space-static-acceptance-20261023.md": "9aec04ab40e40ce275c75104f004cd3d1473ff8fbef428767afbbe5fafd09cb9",
"prototypes/hel-799/reference/hel-798-static/assets/her-room.jpg": "51ca450a454c108d304feee2745070fbbbd3232863096126badf279ee53a925a",
"prototypes/hel-799/reference/hel-798-static/assets/her-talk.jpg": "0b9e91b58d75afb836efdad3d8e8f0aab0c44df64b2c4ddd59d011fd499aeb33",
"prototypes/hel-799/reference/hel-798-static/hel-798-compare-pr8.png": "6ec7b3d454af4404eafb0b0fa86c17baa10e6aa622456c2ef8689d9011bec764",
"prototypes/hel-799/reference/hel-798-static/hel-798-face-to-face-annotated.png": "8d5092b722d653d0217ed758635d0be61b70a5ebdc79da449a280a8bed43d52d",
"prototypes/hel-799/reference/hel-798-static/hel-798-face-to-face-moon.png": "a8619a1ff459ec1d28ec192104c851f6dee079c7b6056c1a927842591861a56a",
"prototypes/hel-799/reference/hel-798-static/index.html": "8ee3fba1e62409f06d0ee8b42cee735a1d5eb64965ea3a027b997352bcce3d7c",
"prototypes/hel-799/src/ambient.js": "6a9b5edf204364a31e855fa3554643715b91d1b2ef88175a0bc2be2cb0792e27",
"prototypes/hel-799/src/room.css": "a3daf8ac189969e8dab1de8f8139bf6fc2426b5c15d14d332f40532b3fe50515",
"prototypes/hel-799/src/scene.js": "d51302195996d6ec75c2044dfa01ec81ad2f17b11676f143ede965f0d282e2f6",
"prototypes/hel-799/src/shell.html": "53b468896e6b8d722fc8ded1d7a3f83b8f8e6ab0b027d7d0b0a2ba4bec70b292",
"prototypes/hel-799/src/talk.js": "55af813358f83c5e0f99948056489b4387138c1464c51bafaa1359ec6072f73a",
"prototypes/hel-799/tools/cdp.ps1": "e7b872ee6915bbfd4c6bb528c7b5593f25e60bde0bc66c79f3c63f0efddd22fb",
"prototypes/hel-799/tools/contact.ps1": "f91761ec80dc44d1746238667da47df291f43f7bb14d6137002d73777612fd0c",
"prototypes/hel-799/tools/record.ps1": "b6ed8344b125e739664c2a31b735d18238577f5d591ec9ad3e011bb20be99d37",
"prototypes/hel-799/tools/snap.ps1": "ecb8dc017bfcb3883942ac7b01e160037c8261749ddc5759b4048d85ad6020e6",
"prototypes/hel-799/tools/strip.ps1": "fe37c21270feeb64f810aef8f6aead21bdc3b79fc57ed99cff8435e35896817d",
"prototypes/hel-799/tools/verify.ps1": "796739f6d079cc500d6221a6155cc1fb4545f246c3cb753007b8d4b0b969d557",
"pyproject.toml": "7b803e2f08b61f4cf40e5bfd83138d5de883ef2101a20094ae74052213dab45b",
"requirements.lock": "fb0218790cc3208ea7695afb7ff98b186f169e13d01f3c97d482ed7edcee8cb6",
"scripts/check_requirements.py": "6fd1143aafa7ec146b8f33396af6ec83566ba678c66c50987887b4c929a62b1e",
"scripts/gen_hel877_sdk_check.py": "707d2b508662fe90681957118f99930c6b50d56d0d30d9cc15d95dcd7a5d98fe",
"scripts/gen_hel879_timing_probe.py": "a5d22ed39cff0cf7c0ae3670f081871a7eb739be08f3d39a9aa5d6e543251927",
"scripts/gen_hel879_unified_frames.py": "4ad231ec3ca8a5e87b6500102dd634df71101737b2a0399ff20b4ad8946f8112",
"scripts/gen_subtitle_samples_hel879.py": "a6c391a7ef18e61335d1dce029ff8e98c852b8634ade0cdaaed27437ff46c391",
"scripts/r2_backfill_persona.py": "48d75f4b1ab7d43628df0449074f1063874756996c4a4bf6aefd19a237dc7411",
"scripts/r2_model_smoke.py": "e5eb2bc7b428c021d87466c71eee381f634537188b9b94fb25a84f93e1eb2e5c",
"scripts/verify_hel876_voice_seam.py": "f6db5aa96bdf000331efcec07beb4f9b74c592fbf4a2eb29190f49f527a2a197",
"scripts/verify_hel877_voice_chain.py": "c7c060006f0e9ab0916cf5489a5ed845930a6cedd75cfd4806916a0799a4f29b",
"scripts/verify_hel899_custom_voice.py": "45f53dfb31fb41461b342c79a31fcbed3ae3af34e9f4ad28876f6756ec0dcc03",
"spike/hel857/README.md": "39083ad6365f0f3d59f67ed67c1917b9fdef82ea8a8dba2d9c645b4c572ac7d8",
"spike/hel857/__init__.py": "ad18d8bb81710d642d592b60cffeaf73edba5a825fff55015f4bf9a4ecf85cc8",
"spike/hel857/__main__.py": "0c067efd5fcc616f2b7d81f06a65f4c92eddd07c8eefb98fce250fc5efdc7243",
"spike/hel857/ab_child.py": "38bab11fb4cb5bbc515cb79a3e457dbe42ae579ebdea84ff49791e957263f280",
"spike/hel857/ab_preflight.py": "4b153e9bb7faa1cd1a92bba978ad8b29960656e7999e5a26e858ad5534e923ca",
"spike/hel857/ab_scenarios.py": "0f9d7865a3a9bf78334b567d30979f4e4e0e638d4adb309087834843b1555aa8",
"spike/hel857/audit859.py": "f6bb5123b19ba8d4ef751be2c86c8f0aa5715371223609bae58708344c73b6c6",
"spike/hel857/audit860.py": "ce17214437abac7eb4d7e76a44d40482c7ba17285cdae507cb97f4754c4efb89",
"spike/hel857/audit861.py": "e6db2c4110ddf01105b048753a416b98aea175ff6602e4121a018a6010c56164",
"spike/hel857/audit862.py": "5cd66d49cafa8a40e8b78a5cf9a64d538bc5352688149f1a8712cf791fd6324c",
"spike/hel857/audit863.py": "4696fa67a1ba5abd7be7873442187cfeed47ea20ad821c82b66eb2bab3265c94",
"spike/hel857/audit864.py": "c25449c609385124b51fd3d3b8406ee32c898c18505b5aef58049a140ee89159",
"spike/hel857/audit865.py": "63a0c0fab527536c7ee51a2f54b1c892152710a1d4e9ea66ca1583dcbbda25ff",
"spike/hel857/audit866.py": "d106ee648589ff67e6a3735041e4a7c4acbfc7704b95d88f3233d2b8df7bab53",
"spike/hel857/audit867.py": "f8aeb2f3c3eb5589aefb0111fea2a8feda272ee62e530a53e509eb78259c0471",
"spike/hel857/common.py": "6e7450b506bd13e8bba2744e0d93557c6679a207d4639eeeca3d9eda1a479da5",
"spike/hel857/fixtures.py": "b8d680c1fc818da469015e2354f7910f1472a4413e111c6dcc3ddce081ab180e",
"spike/hel857/ledger.py": "70dfd3fad1ee912463659f6b3ce0baf4ce78c618baf6efb09c4fa644ad63e08a",
"spike/hel857/ledger_child.py": "9eeb977aa3fd2e747edf5592f712541bdbbec015d5a0ea814c258a65c613eeef",
"spike/hel857/ledger_fake_test.py": "b6166499506f5bd75ab1d5c9bcc937e0fdd571ea1d7b0b8a21d6bebfd4c47210",
"spike/hel857/plan-next-HEL-869.md": "10ae07336a30929d86a5bbee2974f354a8d6147a4e0b00b000989874d4dfe3f1",
"spike/hel857/recheck869.py": "6142cc4aff0a8001da85c6864558eac744a2bed9984a4e623fad25a10add586b",
"spike/hel857/resource_check.py": "98644760d80e97c4154c37f8da841e6154d540783182c191bdefb3c6926beb67",
"spike/hel857/results/ab_scenarios.json": "6745088341baafd14f73ce88ba7d8de3550b48122a1c90f439d192c055a5eef8",
"spike/hel857/results/ab_scenarios_run1.json": "c473cbee15e9d243cb0475cae36ae3077ccfdc660be615dd84e5da62a2c4f4b3",
"spike/hel857/results/audit859_postfix.json": "5a6a57191fd904c7226892fb1e69f4ea4dd0394169dbe8e1ed13922297f559cf",
"spike/hel857/results/audit859_prefix.json": "d651383754d036cc4a5f52a943d58e4acce9e7bc3558fd0bdd38de098997442c",
"spike/hel857/results/audit859_reuse860.json": "d6f0ddee989c67fa8922f6c2b81272039f08fac4e3369625dd36fdfb00a3a12f",
"spike/hel857/results/audit860_postfix.json": "31b3127d1f5e57738ecabc32fb7f8cd004af2d6cbd9d8ab8c262ff0f406349ee",
"spike/hel857/results/audit860_prefix.json": "5f4a7fa5a23548231cc022a01836f7f07bcf68cb385c98db38ed2f457d5efbe4",
"spike/hel857/results/audit861_postfix.json": "e6a346a54ab23c68e18aff18014577c53910c321dcf8ac6dd81fe0556933d826",
"spike/hel857/results/audit861_prefix.json": "57fff753d876a7f35589ce1e112a8aeea658d8eb5831050c2ba49155a63fe9b6",
"spike/hel857/results/audit861_reuse862.json": "5252430ca76d8ead326e86106cd7f7d336dde3644e16ba3157e78516929424e2",
"spike/hel857/results/audit861_reuse863.json": "a70a394efc27a59c62ba9774988838e001871037fd009705098ffb4eeda15834",
"spike/hel857/results/audit861_reuse864.json": "8d92cc3b187271ea7c2071b452f08da844bc6772e3952e121e4c40c6ab806305",
"spike/hel857/results/audit861_reuse865.json": "661f59ddcaedcba89aad251ee44eac960726bd40e1ee00c54daddc56b1fbbc07",
"spike/hel857/results/audit861_reuse866.json": "77f1dda2e2423e36196d60bf39a1fb925ef3635d3a2510ed69a49cd4b4f7b405",
"spike/hel857/results/audit861_reuse867.json": "72957a8a0420538930fe551579fdd4702195f134c28080aca05ecb5927a7d4bc",
"spike/hel857/results/audit861_reuse868.json": "789add169e08d6f1ea9826f1f8176cab451edb88adbe3b58d1054e176bddf836",
"spike/hel857/results/audit862_postfix.json": "7b735a07a705416a9f1ae85f7c801307a8b66a63cbad57c6e1e86bd4bfdac9e6",
"spike/hel857/results/audit862_prefix.json": "0f35e645bed678822d0d21e334e1fac1000a2b0d243a31b652ee4ba4b6c5365f",
"spike/hel857/results/audit863_postfix.json": "a89446b732321cec324f8da57bc3175ad87042b16de519c5b33198497e4e5fc4",
"spike/hel857/results/audit863_prefix.json": "c75f93e93933c218d539f4e1e0ce567f72f423dbd96537d68100ab28b3b3437d",
"spike/hel857/results/audit864_postfix.json": "d7d8374e5e7df46a52c32329b8fd10844011e37cb460515973bb7ad00a8c5e59",
"spike/hel857/results/audit864_prefix.json": "64d2f75dc13935e1b67a3d884c04d4c7efde69e004f4e331f689f972bd37d15f",
"spike/hel857/results/audit865_postfix.json": "3197245db1fd41a7fb6b5cf41441c0f22abc90ad489fe722805fa905a7c4d3a6",
"spike/hel857/results/audit865_prefix.json": "d72e3ac932ce2181ed9d37fd4987bd70dc2aede3853c79033a6485e5f8e9f4fb",
"spike/hel857/results/audit866_postfix.json": "05257d7d3fd1c486100ad1af650e92c84cfabca3614a488ab9db342136c56920",
"spike/hel857/results/audit866_prefix.json": "3cebddab4dab5c0f2ba4016da224908e6e840c1fbd2f586de253d208b230f7df",
"spike/hel857/results/audit867_postfix.json": "1ff0480bc1dfd3ccb014e111c7e98d0e0b62107b856a50f9c14e0b2ffd17a3fd",
"spike/hel857/results/audit867_prefix.json": "91ae2b39f8bd9456834adbfd6e10120123205bccec077f4014584d567af69711",
"spike/hel857/results/audit867_reuse868.json": "e65a98408f9b54a33bfcf22f749a17f2bb880729163f085049251b6c92ac65a2",
"spike/hel857/results/erratum_HEL-869.json": "8b1fe85be0e5b6291d4fc4c72f5f99afef63b270e719416995e6a3e3d457ea23",
"spike/hel857/results/gate0_ledger.json": "e2a7224a4a29ffcf0e54006d93db71ec14400d7a548a67167f023339cac02faf",
"spike/hel857/results/gate1_backend.json": "ceb0def6253609b0fd610386a5ccd67cb15233bd9aa021c6a42d72bbc7a1f94a",
"spike/hel857/results/gate2_privacy.json": "579ad8c1944598f92ca90ec622c578b9b8cd242f50e1337608f45114b4a91d77",
"spike/hel857/results/lock-notes.md": "44e89b7af34ef5db1d25d3916d2d254c10097e49e3d8940bfcf8e7ee69cc967c",
"spike/hel857/results/preflight.json": "4bae4229f2a6a3d6c46964dc76594831c90405cc7e8c12bd6d5edd2dc482c1a5",
"spike/hel857/results/recheck_HEL-869_rawretention.json": "0872c3abf7f40e6a0776d8b8603f7078f54444389963738048215ed74f512d41",
"spike/hel857/results/resource_manifest_pre.json": "08675786695fd7a32fc21a38e72f0b8708a0edb55d4efb02ba2438b6851a362d",
"spike/hel857/results/retest_HEL-868_S1.json": "b613d1e6a8cf3386c0a963692e9d40143b28820fad1b96ece92efc6656e504d8",
"spike/hel857/results/retest_HEL-868_S2.json": "0e5f9be91b88582cc2044b1fb56860a34be1be6ef6f9fff13268a512302ffb2a",
"spike/hel857/results/retest_HEL-868_S3.json": "2fe60d0c4a290c1f7e1027953f0898b0d2bba009196cf91a98857cf9d4947386",
"spike/hel857/results/retest_HEL-868_S4.json": "fc9138388defeefa28706573ce89b05ee3788efe6835caebdaba09680c36e14e",
"spike/hel857/results/retest_HEL-868_S5.json": "fd162411ad19de84b3a4254202be130cf3f9af18c419baa8334fbe550541414c",
"spike/hel857/results/retest_HEL-868_S6.json": "b418f4f1535682110f9ad6ec733ee5cdfbb16e383dce603f43965b7953f0d431",
"spike/hel857/results/retest_HEL-868_S7.json": "9d5f0fa41fef1088183f73c03003dcd1e1c75bfd36aa1eb64d0650763928cf63",
"spike/hel857/results/retest_HEL-868_S8.json": "374fce83614d304911e5b7b833d55bc128b0df3617b3d841f7d1ca8ea3be2405",
"spike/hel857/results/retest_HEL-868_S9.json": "7255bef509190578e529523cb7f680a09460d170e885f04bc7f009269b9021d4",
"spike/hel857/results/retest_HEL-868_calls.json": "afd039315333e85fe2b831018a894643f3cf5ea112e15a37f87cf9fb4c5dccea",
"spike/hel857/results/retest_HEL-868_calls_stream.jsonl": "e80062f83cab4268e680bb45cbaa36f49e8f5bbcb99da9bed1c58633966a3bf4",
"spike/hel857/results/rework858_offline.json": "01b9d8fb9757605ed528bb9cc559b22098b4b7e599f4890240459d1bad877ef4",
"spike/hel857/results/rework859_offline.json": "0785228e2c0964bace4b95617dea6b1261c2cf5bd3923a0c61cfb8e7d5e754c5",
"spike/hel857/results/rework860_offline.json": "788f4090233d228f54ceeab99a3517870022e926dda160d7f5a9ae8fdc35c203",
"spike/hel857/retest868.py": "bbcb3bd6dfd0f560f25301d667e12a08793236df27ea985838e027cabb2f7022",
"spike/hel857/rework_suite.py": "4a249249344fe76088b5e11df30721d4354e21dea3ca6e544cdd59205600e44c",
"spike/hel857/step0_backend_check.py": "0d8bbc9e356e026f87b1800e877901d1dfa7e0db58b52f559d8f83b0b667d98b",
"spike/hel857/step0_child.py": "d43b2fc56e49578fcf75d038ed3140e679a7190561b180686b4c9d53fdb72d35",
"spike/hel857/stub_gate.py": "1c63195f95af9682750fe7a708e789e71402d919f2a33d31ce27e5a7d4ebcd45",
"src/cybergril/__init__.py": "28b475ebffa3d3a8e23c310ac95109d4806190d72f621100a3a8d026157553f7",
"src/cybergril/api/__init__.py": "cf114cbe3885a318da0c95d8d52720c7acad2f378c8c34d82d1ff3f985dec6dd",
"src/cybergril/api/__main__.py": "e0fb1851ae8f3e5c65397b9bbd81f011230bdab70b14720feaaa854a545513cb",
"src/cybergril/api/app.py": "54a7ed03fe0976996f4e6b68c4ee6b5db79a712301b5a275a13477fb718defcf",
"src/cybergril/config.py": "fef3da0512f91dbfe5d0ba0b72df512d33bae4482b63410631ed0fb934095893",
"src/cybergril/contracts/__init__.py": "de6de43999429d64c016b242e8b1c3a64dc67359a0694b86e87ea1783360b486",
"src/cybergril/contracts/audio.py": "fc2a4d783b921d4f0ce6b73d71a3c2742ab0b66112312c1016bedbb93e7a30ba",
"src/cybergril/contracts/capabilities.py": "cc0584d223dc8c035b412cfb6381e95d2c7d6accb07b06c14d918bfaa31d405f",
"src/cybergril/contracts/context.py": "95122b4f9e1be718d5f3ffb012c273e5acd33bf6a8f53aa9a5dc0b7a69bef000",
"src/cybergril/contracts/percept.py": "5076a620ac8e83697c34ea3142f02040e645642233cd713106a20dd516e6e2bd",
"src/cybergril/contracts/render.py": "6c3072da720e1dd57ac3245b8aa84da89d17bb6e024cd0ad2e85a2a179e942ab",
"src/cybergril/contracts/state.py": "bf67347a42f70a6b65576a137364765a2b9b59131f569e8ee0635d3f7d3c579b",
"src/cybergril/contracts/text.py": "566f61221a2059f7809c7c362f6d326f4553448a53d64b851813e39959db485f",
"src/cybergril/contracts/utterance.py": "271ac41a21e792b66a90db5380ca9452d46f92cb8c586d105570e9819fe005c8",
"src/cybergril/core/__init__.py": "b20a1f351aa4861eacdabfc85c40f40627ee114f50f80101c6f106ccf72ab2ea",
"src/cybergril/core/bazi.py": "ab6a827b19a70ea24d655cfd6ac8c5e541795c241390a8d5ad3d3e04bc1678d7",
"src/cybergril/core/birth_view.py": "8b9e67ceb42ad26df9b79ee120818bb51659bebaff9710fab496058cc5df5950",
"src/cybergril/core/mood.py": "20e6b3f8cf79b333d660197a83bb5af826cd12b6d5371c7eb4702ccac6d7a1fb",
"src/cybergril/core/mood_runtime.py": "bcb8a6818d738ddea60198b22b2295c1519e81cd269b53ffc10f0767151ad2cc",
"src/cybergril/core/projection.py": "574df3cb060699867396043e86d222a12ec0eab962f7c04b597ed1996cfb0243",
"src/cybergril/dyad/__init__.py": "fb4ee7bcd35ec72d4e9c32d4be1e013e708b988c2fa1abbdb59d695bd974ef25",
"src/cybergril/dyad/ledger.py": "63a0adef8ffef0c5e1b0c2c8aca23235cb618f1cde0ff4e4cc1fb0d5f7badcbe",
"src/cybergril/dyad/runtime.py": "7e26adea61db256c9d8c2f377e1b539e3452f668e20963442405c94af22149cc",
"src/cybergril/life/__init__.py": "f9e61e40ddd4cdbc520dc582e9197ba37ea5ac734ec6ad6eea3a7eea4eee0693",
"src/cybergril/life/activity.py": "3a0d0fce9e49f537f55d8db50af1d860c7a7c922bbac0be0f67d1723c3f671c5",
"src/cybergril/life/model.py": "2ce2360bcdabbe9de52851324ab04fa672ff644264982d8760812e7176f208ba",
"src/cybergril/life/persona.py": "b58999b1a9a50f0a0d222d549de5f6d97ae62dc243374444143b64feacaedecd",
"src/cybergril/life/public.py": "c3e85cf86c8acbbce2c5cea70f14f7f9cf0342378b3751da3495b6f29052e81d",
"src/cybergril/life/reach_out.py": "c430d0d0b855b5c2d3252fa75ed93988eac5f5cc1cd95044e27f89dc45bc4890",
"src/cybergril/life/runtime.py": "aa28cc8d7ea7bbec6107dd09f53ff62e1310e2b2e337cc86c0635bf653c94f2a",
"src/cybergril/life/sharing.py": "7fd16d4492aa270040a12f28905bf45848895a931104a487b152a24c672239b8",
"src/cybergril/memory/__init__.py": "4fd64b3bd074c9877d4b3feea7f970e6d5c5fc76ca3c2147b852fafeb4265fc8",
"src/cybergril/memory/episodic.py": "e680b3dc95d6e320a0690936f63f7814ecf334381608f7e2afb3d47c0b80b9e4",
"src/cybergril/memory/r2.sql": "e199d34a0f7c0c8737b59bebe9d7a566e4764b9b783096e5e8c5f595217ff4b5",
"src/cybergril/memory/r3.sql": "c9c7e59131b601e0532c8105eff505cc1365de156e50fbcace68ff620ed2ff95",
"src/cybergril/memory/r4.sql": "1bfb63fed14c01c33d499f49185992ca1c295b7bc8d4a4c8488753d93a9611b5",
"src/cybergril/memory/schema.sql": "3b420fc7f581f43f0409f93a4ba1f3c72296da74abbde8d82248bbc3c531c5ce",
"src/cybergril/memory/semantic.py": "b112bf309c52ab0b1182c10ae0cf4c342b8b52a1f3a0a30503f17119aa61f5de",
"src/cybergril/memory/store.py": "ba34f446709c298e544946a2a130a92e229ed0ecefcb3f38c6007d2075bff291",
"src/cybergril/providers/__init__.py": "27f8fc8fa1fa73b5516b97b52ecab1ea927f2cfa20dc221ca872198b2d7dda5b",
"src/cybergril/providers/asr/__init__.py": "56c89ede6111df2185f3d365890f4818f4e77f58d9b925d7f8c3ad19cc64ba2f",
"src/cybergril/providers/asr/base.py": "747c935d30fbc50552c8af2698cda4b96ab403e6983f1eb662c90b844fe33c91",
"src/cybergril/providers/asr/scripted.py": "4674aea0882c0dd54ddafe7ec501e09364668ac69074383abe25611a28c8176c",
"src/cybergril/providers/avatar/__init__.py": "e9c5fa66f32d5edec861779ca7376d98f80e1b22e23080f72f6b68c4577a839b",
"src/cybergril/providers/avatar/base.py": "bb6ed5de7e608cccfccbef8027a57ce419d3d244ee222ab40bf329e91423dffa",
"src/cybergril/providers/base.py": "2c70cc5ab6c59b51d8de6c49c298d09815ef00b18a967d3fd36fc193dde3a72c",
"src/cybergril/providers/embedding/__init__.py": "69cb8d4462f727b11180ba66e66ff9993335e55cb1e9a0c238e252c8fb26f6dc",
"src/cybergril/providers/embedding/base.py": "49bfd7614d5f703408aef4064464e2d6400fec116f9f6b5e82b486c58a2af083",
"src/cybergril/providers/embedding/openai_compat.py": "78ca1d03facdb79aaa37a54dbfd163fb192c9c88ffa25a244a2d82bf880602cd",
"src/cybergril/providers/llm/__init__.py": "641e66a647bf9dc01252d769b67596a0252b857a65954dd71662f8f1de7faf88",
"src/cybergril/providers/llm/base.py": "40eccf124f7cb4b360e9b6d9df89b0ada069cdc056a43572a3836e40d5c83f6b",
"src/cybergril/providers/llm/echo.py": "1d68e2e5a51f0639920f56626f42e2f19a1e0e9f5c6d0fad7c74ba53de2e6779",
"src/cybergril/providers/llm/openai_compat.py": "f809ca9cd160815c518bd7bd4c616c28b8ae7ff90c0818d0accaf99f24c62daa",
"src/cybergril/providers/tts/__init__.py": "5432b91bacff0beee5d11d4001c6e79aa20daefb9793450f87474931d7bfadb8",
"src/cybergril/providers/tts/base.py": "6fc19f9536801efeebac96cd689e156e0862b80b1a4ff1cf6559f61030c2da55",
"src/cybergril/providers/tts/silence.py": "04fd210f541bcafef2763053ed3e29dc2c28740dc092499aebfeb08114b845ba",
"src/cybergril/providers/turn/__init__.py": "326995f0240211df3bee0d74e83bd30593b2dd39142af1763ee8d04d18daca82",
"src/cybergril/providers/turn/base.py": "18077523dce912b65b2d8096f5e9d0d75241c0b5e8cc699701c614a162c77086",
"src/cybergril/providers/turn/heuristic.py": "15745e1c059361e5e860acf9dbe3e0a45cb242ba35abaecac7f1a9e447e8ead5",
"src/cybergril/providers/vad/__init__.py": "d2a0fb7fb4e6f751dc2c44894344b719d6b4622cf3417167e80109c6e64fae96",
"src/cybergril/providers/vad/base.py": "0265a10832a29baae349a105310775db6ea2b8421e99ee9d5d5d99ea9d50335f",
"src/cybergril/providers/vad/energy.py": "1962ad3ed0ff65387e93d68d3ab9ef98bc859f1cd755df5770ea503fae4fc468",
"src/cybergril/think/__init__.py": "47b1a45a41c16441621cda9df7499ee8c64128a35e4bcd897de7d6fa04fe84f3",
"src/cybergril/think/budget.py": "84c62821631bdf9802d483abf3bc8e041d52b9653ae216ba4a2ae1beb82c6bce",
"src/cybergril/think/router.py": "4a05601e5e587764708da17f41c617d0f1433f42794860ce5b09810fe4e41204",
"src/cybergril/think/scenes.py": "5a22383a13a52d34c767b8345ba170900e68b9d14fd5f68f0efb1b28f33e4aa0",
"src/cybergril/think/wiring.py": "3154c973ac2ba4c342bf547a93b37756f6519d9f24ab29a1c92b98fe97998c70",
"src/cybergril/voice/__init__.py": "7003c103ce0995806ec392939dd6db52f12b3e36f16560af2881b6bf70e1a786",
"src/cybergril/voice/chat_api_client.py": "702cb60e5640d236421da842df48142a118f8353852d975238702a7839c2f8cd",
"src/cybergril/voice/custom.py": "cb01ef929911e4e206b56f7141952365a7fbcbeadb03dc183b36fea1ffc93daa",
"src/cybergril/voice/livekit_wire.py": "ffc477cd96d48c510905ce9fb887ba5b00edd6941ee59cf1b960f51f18500941",
"src/cybergril/voice/llm_chat_api.py": "b6cae28d22b68a6de61273e160daddb21da12f5924dff2d3c00f4636a345a007",
"src/cybergril/voice/receipt.py": "cf4a2296fc38539dd0d6e6ac17f0a894b0a82da89e6e8ace110265b84d75f2b9",
"src/cybergril/voice/router.py": "b1db42d4a6979e92b239be5e318e75e14661d07847bccbf0230bba2cb6a2f2df",
"src/cybergril/voice/seam.py": "ea8ebde885453728b36bfcf46eec1bac3d14944e8549dceb3c27c329aab715fa",
"src/cybergril/voice/session.py": "63727cfec316bb5f8f086c458ae0bac82220ff70b515ccd70a1a9d7114c45483",
"src/cybergril/voice/stt_funasr.py": "e9c25aafe344c3432a13f17770d905c0239d72ce164b58443aacccc7013584b2",
"src/cybergril/voice/tts_cosyvoice.py": "7fa61424fb3ae5382dc9f27e881138953b69726c181084a6f693c141e7f6b5ba",
"src/cybergril/voice/worker.py": "dee6994fce5fef70f6ea0fb4659cda2e7d46d6b889485d5d2697426dddd949ae",
"tests/conftest.py": "5e94b5e9cb5804ab7f4e683e30f68cb7d38e2405b781e9cc790c9be023ddebd2",
"tests/r3_baseline_probe.py": "944c3a559023ed08adb68994714bea43865994c762d83e420767412a5874a720",
"tests/test_api.py": "cdddc3a657cd0595a1cdfeb9b40ddab3169c12db51e2dd8282cdbcb9ee8e3d23",
"tests/test_bazi.py": "0c6dac1649f8cdff102f0595a33915ea674cd98d5268bd80bdf1aa8133cce240",
"tests/test_birth_view.py": "90331ce8ba1cfbf319c5b9de38867c597233bbe30132a07f169791b92337c954",
"tests/test_contracts.py": "d65d467c4ea097068e60336703b7a04df5e2690d63bc6440837025ddd53ac35a",
"tests/test_hard_gates_hel882.py": "b031c8b586d3add4c329604c33774da33e411b641c9cfe12df485f3e16b6e3af",
"tests/test_mem0_backend.py": "220efe649a1a65e7a33767c99494c8bbc50a77cf0a0ac74871db57f73b2f8ceb",
"tests/test_mood.py": "ad057857584e1062369a30842c8eb2efe7bec699f32699db40090bd4d10404a6",
"tests/test_mood_runtime.py": "bcc6952037a54a0b3b323fa3df94c3d893811475043d5c9e83f2faed7c9a8d5c",
"tests/test_projection.py": "3acddc73bec5dc031db3821006b4fed0d1144cdb457d8ccb9dfca177d3e18a30",
"tests/test_providers.py": "de875f8990029bb800139ccd8d5e16adac74a7de40ecd42af5b98a2f2f6bf76d",
"tests/test_r2.py": "60c7bcb696332991546e61d0104010dd7f7441b7ac3dd8282706f50b5b8fc353",
"tests/test_r2_model_smoke.py": "91084082080b32dbce60cbbc8a752c8ada46c0eec71e5b85207a9d490c4a52e5",
"tests/test_r3.py": "4019c091936f4f5e013b90639da853e47f24fd76911f01eaf0018e51c5b62471",
"tests/test_r4_api.py": "94c149ddb31f07fe14b7422cb09b866358c8ecb3fb3e20284f5fb5d62abd3bcb",
"tests/test_reach_out.py": "31c86e68c2f29c2495ec9fdf5428cab953609430d452b047ccf6a6c76aee1bee",
"tests/test_readback_hel874.py": "100df58a193506d36106b33abed79e0c0f910b91591be3c147582266c9f952ce",
"tests/test_rework_hel885.py": "1b82624d4eb97a44edf7e5e5dd248622e821794d8adc010c7b684d2590584de0",
"tests/test_rework_hel886.py": "445230c3d578f3d0f5b01a8acc3f4463664c88f1e71320fb3be1a1c1bdb3bba8",
"tests/test_rework_hel889.py": "c2fda1a800100ee74b29aad66f0c9d10bfad54553981caa2fdae3503736c5861",
"tests/test_rework_hel894.py": "e7788a19d17b686bdab6f5e9aa960fafc52e3550c4d2bcfb8c3b870f822a8f81",
"tests/test_router.py": "56f2b3ecd29866db534db1731831861be00759fbe563f6e6e3ab76772449514c",
"tests/test_semantic.py": "4afcd8a2bc46b273423a113ec2166cbe0a56df7ba9afc967370a00a97de4be2f",
"tests/test_semantic_recovery_hel882.py": "fb08dc41fcacc3159109d7b68f5acc47ac80a14829aae76821dd6ade71a795c4",
"tests/test_store.py": "c2726ad5b00ec14d15aa379cf32d5379784b1daac649456427bc50ab79413a6d",
"tests/test_voice_chat_adapter.py": "ed0aaec68c96a191ea2c3f5e9f72dd79325191534df419e834ea9884babee9be",
"tests/test_voice_custom.py": "de78ac24fde7edeb3bf8120ffe1926f3298b8def21884ab60c9f6d5a2cb49236",
"tests/test_voice_review_hel902.py": "d710126a2e225a789f8876654e048f95f9d47030664fde5e60aab5283ea00115",
"tests/test_voice_rework_hel879.py": "a13c2db2c879ffc6554d15d3587ff593a0d3e132d618106449bb57cf5a595cc8",
"tests/test_voice_session.py": "8bedf05edb52662200e37a5ca24f3ae2303e492f84ff88aaed23771cc4778481",
"tools/audit_sdk_offline_hel896.py": "f88e9eaf3985f2dd4d72ca1b453fb2c1b822ba57197cf507e829c57a2beba0f1",
"tools/check_release_offline_hel882.py": "4818caedf2cde26bf5999d5836adfb227009b94b4b5fd59da53374123060cd32",
"tools/release_snapshot_hel896.py": "f1129015492fad4075c0fc798a999910537223aac5884f66e27b3495d0d0020c",
"tools/render_preview.py": "506a04919db24226477ebbc44e0ab344a6ce6ca75b2f3b81b566d43370c053ed",
"tools/seal_budget_hel879.py": "85b904899ccf725b38165bcbc6416da0ee13ac3f7e91d8203e53853648a5aa59",
"tools/verify_budget_split_hel896.py": "8c5bdee07eeba1a1fe2206621356e6153d84e16bd5e961ba91a532b6e64beba7",
"tools/verify_close_ownership_hel894.py": "84c5fa2b8f9dd7314d16c9dbae7cebb3d9b8b37c7f69bb3e2cebac08c83f41cd",
"tools/verify_http_pagination_hel895.py": "a73c81fb8381cde3e13f7d69ff17b81a96cc510a84c592cd4d1fd1f39cb028f4",
"tools/verify_inflight_lifecycle_hel886.py": "55d24905efefb293286f2a9a77f8d1c687af37927135de14483e5ff7c20e6408",
"tools/verify_mem0_real_hel874.py": "2c46b6ef306f96e1cc1791bb851cd316fbd3da472bd45e62bfaa84272be2bb84",
"tools/verify_semantic_recovery_hel885.py": "b63895afe1f211f566aab5441f8c5cf54a92e3758a3c7b1e7f0334dc2c9a0f28",
"tools/verify_shutdown_queued_hel889.py": "488fac844ee3b66a9cdadae6cb8ccff7e67271749777ae0b5a3404dab7c93f90",
"web/.gitignore": "f99ead27b34cbc6756d707e7e7ee7340eb9a71e02dfec2b40d5fb848dd3f4d4f",
"web/README.md": "f596492a443e7ae9972496a40db2f40c79f8667d4552628afa4718efbd29c726",
"web/VENDOR.md": "e11c139b5c7a6f86264439b03997bd89c79e40c994ac1db57f31717e83b660b6",
"web/eslint.config.js": "c59a6a7e9f0dbeb5c8b90a66d1405606a5b0f909fafae47ca8e6737a9a386982",
"web/index.html": "ecf71d805fa2d20a6e244ac1f6fc729a55a44dc24c4a7ac75f966179a51cf61d",
"web/package-lock.json": "f950de58b5e86380705d979029894e62b39aba451cdb349922b97b4177677a27",
"web/package.json": "f58c320d8b4d35c586016dc002d5e83a26ceeb05d8729552c831c39d0791336e",
"web/public/assets/her-room.jpg": "51ca450a454c108d304feee2745070fbbbd3232863096126badf279ee53a925a",
"web/public/assets/her-talk.jpg": "0b9e91b58d75afb836efdad3d8e8f0aab0c44df64b2c4ddd59d011fd499aeb33",
"web/public/assets/icons.svg": "8630691c7349dd33c1accf2331cb4c844ffd9b70465021b1c18bf8f5aea0588e",
"web/public/demo-material.json": "78fd67e8e4a680e8b3277db4a922360a83fa0be1ceee0cb257ba1c74bc6ede37",
"web/public/vendor/livekit-client-2.22.4.umd.min.js": "040db4c0db72e7f7599cd0e06b0f58f95e0082a395cdde410ca26224a79a0626",
"web/src/adapters/demo.ts": "be4593f70337e87dd4b3c0cb499cd73cb4dda1ee921d579d8bafe0b6cea53083",
"web/src/adapters/inbox.ts": "8e145b914af1247f066ebb3e99574d43303a915173e6206950657e462461c85c",
"web/src/adapters/real.ts": "5aa12ec2367c8505f663e52885138adf33ceeef4cfe2ae02d7d6402ff42864ee",
"web/src/adapters/receipt.ts": "cdd7f41d79c843886167947f05bdbed159919e288c6196c6d94d5080b821876d",
"web/src/adapters/sha256.ts": "c65b58368858ed13bff0fcb0710dc72c9cc5b112fd443dc6820fc66e62e08f22",
"web/src/adapters/storage.ts": "e06afb3f8f801c2a4ff5a5ea109f27f59ad2d3187c915d48d50fba3f68cc08be",
"web/src/app/App.tsx": "6d266a532c832fd8d93f447bf516158c17f517363155a47df7642cbc62ae1b90",
"web/src/app/RealApp.tsx": "8afb63b8a6b196e29ced1a8bcef541f1f80a10811f1dc0f965631a9f0c201f5e",
"web/src/chat/ChatPanel.tsx": "9d458bd826e505300e8054565fddcc196fce93214f4f240e9b44fb2728520b63",
"web/src/chat/lineStage.ts": "687835a7e3f6f3a9bfbff863e2cd7e1ea96c463d1bc35b9e9be2833cd3252da2",
"web/src/chat/reading.ts": "b35aa4b7c433f660929b2bb46f85d06ebbbde942621df5c2df8da6b3312f4dfb",
"web/src/demo/fixtures.ts": "17e3525a155a4d07f37f4dc4e557a259788e943e39ee7ceca1e6fbf6a272e81c",
"web/src/domain/id.ts": "b03e2c6a7120d1af5065a61103a0aa0679bdfedf3437ce31374d63df1fba8137",
"web/src/domain/model.ts": "88d76909fc771ca4572fd6097ead70beb347e6ba17b97eab913757a1bc193ec5",
"web/src/domain/privacy.ts": "3bb318fae0704f09d1bb657bf54c10dea6ce8b0847daaee36a8dbda22d9c00db",
"web/src/life/LifePanel.tsx": "a91fe08695a856c8e02b9a08b0c6c89427155243b52c300e7d6ce2ded3272192",
"web/src/main.tsx": "4af93f56bbcb00d8d3327ed9f64ab57288234838ed311a1a8c3e825d4ada46b6",
"web/src/manage/CreationWizard.tsx": "7c4e8945b3baf705c9fbc88ec8a50d3b4be289bd892789427061bbe97d396d5c",
"web/src/manage/ManagePanel.tsx": "d32bb459231fdc5ec262c162483dc29d669ec2c3a4c4127c86beb23cc271a12e",
"web/src/manage/VoiceSection.tsx": "68e8e15442dc8d3e758eb938b4fb202af0a40ccae2e3c0af8b4e32abd4216535",
"web/src/shared/Viewport.tsx": "2302c18e00019134184a70d038bb0bcaea4e9230ae8c83ee9da0b7c285590bc0",
"web/src/shared/motion.ts": "fb397e28411c59349efd75cb5aac1a2722f047ece2e9b7b305803a523576c9f6",
"web/src/shared/navigation.ts": "f4820f6fad24bcb5e89dc497562aacd52ef264608f0a77718cade3a9efc4cb25",
"web/src/shared/space.ts": "5d32075facc0a4e3a56577a126942cee85f724185afd4a43de5f1f85979a1fca",
"web/src/shared/ui.tsx": "6900ffc625d4db2e8ef4772f9371927f85bd47c7464257a36c78e2c49449183f",
"web/src/stage/Background.tsx": "0e36869e3b0600238fc380941ab355429996966fb1aa8bebfc7c5ef9cf807706",
"web/src/stage/Presenter.tsx": "d4cf57bd990bfcf94ca567a1a681339c31474696fe2a92dbc6af218781522a7c",
"web/src/stage/ambient.ts": "076a02ec6152d245d8be5a40cda364f298518cfd7e4a4c3315b4f245afe3a59f",
"web/src/stage/camera.ts": "f3fd15fc4546d0572e03c2f82ae39bb7d2bcc78f65e1d7a3646e297f28dc8128",
"web/src/stage/roomScene.ts": "f01befa8068d88f0f62b845bd4a3cc4421fd7a28d4f5e5ade9c11d0df95488e3",
"web/src/styles/components.css": "c0a535c24db36d767ba571394176a3b5f722c534844016afb4903d4811a953b0",
"web/src/styles/tokens.css": "f44fd4f1bfa1183e19a819256bd402403cd0bf3f5ac09a65bb10879ea3cf3475",
"web/src/voice/custom.ts": "bf6690e33b49e79a9d65af112f429a65f0da3e35ceeb35efd552abb36f3f2f27",
"web/src/voice/livekit.ts": "0148fabff55c4d6112aca2b412c53280104442601d6f5e39349279298761293e",
"web/src/voice/session.ts": "76eca20191124b1b402a91e2da9c4ec04330986085a6146244ac544f7e57d888",
"web/tests/browser_check.py": "b3f9dee4246717950304a49a3086dd3c64ae293c18f2a5e273bd3f576bba0656",
"web/tests/domain.test.ts": "649cedf11049ca2be64569a9b727b53baf4aa7824aede9234cb343511f96cc45",
"web/tests/fake-livekit.js": "fe6489d393aa686cbb527f04d397b3bea19b9e663a665b74369310719c12a917",
"web/tests/fixtures/hel879/adversarial-gates.jsonl": "febd098b07af47fed78b94cd69e7bcf5d6f173c84c2cb4b241dee25bbb8f8671",
"web/tests/fixtures/hel879/sidecar.json": "6793b779e4469943f9ddaca78cb21eab1e8f5466521896b4f397792e863f78b4",
"web/tests/fixtures/hel879/worker-interrupt.jsonl": "4263617779fb7b8467a8cda9eec17e021591edfa7606a7bcf739e4f004940e84",
"web/tests/fixtures/hel879/worker-no_receipt.jsonl": "2acd5b70da10062f6ddceb02f6648e16b4676f15fed3a5a3f0902291fdb7634c",
"web/tests/fixtures/hel879/worker-normal.jsonl": "bcba7ad2a07a63d3b444cc6cec2d0a5371d96c8567e3e5b9d5c290efe07757a1",
"web/tests/fixtures/hel879/worker-phi.jsonl": "6716f9b8a8556b5b8ff9fac024760838595314d5f00c786fb54862e59136b008",
"web/tests/fixtures/hel879/worker-proactive.jsonl": "9f4b5bfaf9ad76dce8546c470f80dd6fbe9d36248079e822e5323a1d30079a85",
"web/tests/hel825_shot.py": "75f6075ae3d3c493360797e7e2f592fa5f4701f99e99981b8b9674ce70e01c92",
"web/tests/hel825_tag_probe.py": "f8c2f51a0403adba71ac4c925700d248efad5b7ad8c3eda14ec81ff98ca3aca0",
"web/tests/hel834_mock.mjs": "303079e4811f7ea62d52ea95c95b1b225259db1e19bbba914e9c70de31ffbe5f",
"web/tests/hel834_shots.mjs": "cb1984c50bae5da46f370292efbba6bb04f7d861bda3af572f2b45733843bc2c",
"web/tests/hel875_mock.mjs": "00b17de26604c7ca0999da12139fd4006468ced3e0536b60d239ccae0f949cc6",
"web/tests/hel875_shots.mjs": "41b04fb9b9ee9569f39dd2dc57f694f5efcd2890124fbc6ced14e6d546878aa2",
"web/tests/hel878_mock.mjs": "4c8cf85102a0c5cecab47ad493c7a698073d4aa25f0d1c0ff598c032640a8d76",
"web/tests/hel878_shots.mjs": "33c338e42ed3417974c75eea54ea0319809af94d694e7928a84f287c74cb59b7",
"web/tests/hel879_frames.test.ts": "86b9cbb402e28661f37e7ad4e292ed4a6f8a3212921930bfc246fe26475d6fe8",
"web/tests/hel879_unified_shots.mjs": "1678eb9548b9427d1746aacb913c7f52246f9e0d2a87006f9d6fc411a862f82f",
"web/tests/hel895_real.mjs": "404f28bdb80abbbc2283c7fafa869728a36ab1677e32eb9c3fd6287ce92cde0b",
"web/tests/hel898_agent_left.test.ts": "8026851f378686fc56b4e1f143b0550c536e9d76eaadd59522d096b182f31bec",
"web/tests/hel898_shots.mjs": "e0fb3ade9f91d629c8be6c1aa611a185ba400510c3cc4a06c06e75869b814393",
"web/tests/hel900_custom_voice.test.ts": "54be8b0437262528b034844ad66275e79ce53ef68d5dc679c53f3e6d2094ecd9",
"web/tests/hel900_mock.mjs": "f120fe076d4b5ca6f1970f0f853bc3148e0fd668d8b53177b5c6d08a8771db30",
"web/tests/hel900_shots.mjs": "d49905fd5475e4e0022456e75490a15ad0ed7fad2717ed290e539a1bfcf85ab3",
"web/tests/hel903_mock.mjs": "60935d78508e0d03325c22833af160d2be767e87690c965c5ecf676dd9800cc6",
"web/tests/hel903_shots.mjs": "8bd4042a1958a51eeabaeb03a2ab7bb78bf6379e440b114b85d64b07a5dbf112",
"web/tests/hel903_voice_rework.test.ts": "804a62b3c55f5cd3e95b734761c60628795568cee326e372cff550a57874771c",
"web/tests/import_check.py": "a7eea222bfdf823a2bcc2fe139780d1d44279b2629e81f2ef8c5c5353abab990",
"web/tests/inbox.test.ts": "649bc6e09c93427369aca63ce214ee3d288197d0f886bfced1696f41228b0ffb",
"web/tests/r2wiring.test.ts": "d497adceb2c5187d980acb8801dcedbb1b5c991148048b9138bcb33820642b6f",
"web/tests/r3memory.test.ts": "166b963bafed1ac00d76506f74dc554c4b9e24c3719a055e02b94e72e95f609e",
"web/tests/reading.test.ts": "55573c3cfeb613fb5c2990f8b5274f0c8c75fb25d7f723e05fec95761e6389b4",
"web/tests/real.test.ts": "f63d377c148c901f97d3d5104bc648d72f0301fc2f28652d39693e3ce2836cfd",
"web/tests/real_browser_check.py": "0f716011d3b25d9c9578aba4e2d4e10e5f38ddc572e54d4528fdaa8645e84ed0",
"web/tests/review878_late_cleanup.test.ts": "65261cd249a36693d0c6b06ce279de8a60aa74dd7baafad6999d2fec790f9935",
"web/tests/rework_browser_check.py": "aee2600a4672b6052ab9372a20d9342a47be637a7271af7aed421b73256567d5",
"web/tests/voice.test.ts": "360ac94641a64fe6dd9d83a9243d037afc789b034bcfbc2fb558bc604262e4a5",
"web/tsconfig.json": "08c0f187ee66aed52b329aa4a50ea298839b02c1aed4deb9387c81473cc5d157"
}
}
@@ -0,0 +1,919 @@
diff --git a/docs/requirements/handoff-HEL-903.json b/docs/requirements/handoff-HEL-903.json
new file mode 100644
--- /dev/null
+++ b/docs/requirements/handoff-HEL-903.json
@@ -0,0 +1,57 @@
+{
+ "baseline_version": "1.12",
+ "read_commit": "b09a907747f9836c4aea04016757321d28b7cb6b",
+ "task": "HEL-903:HEL-902 统一审核后的原作者有限返修(只改 web/ 与本卡交接/证据)——外接服务语音两行按 /api/voice/status 如实区分状态;通话栏显示 session 响应 voice 固定的「本次通话声音」;登记后无明确结果(500/503/响应断连)时提示先刷新列表看编号条目,列表标出缺本地元数据的 cg- 条目。基于 origin/hel-902-integration @ b09a907,保留 HEL-879 真实窗口修复与 HEL-900 作者源码,不推写统一母线、不回退 ce7279e,零真实调用",
+ "requirement_ids": [
+ "CG-006",
+ "CG-009",
+ "CG-010",
+ "CG-013",
+ "CG-014",
+ "CG-015",
+ "CG-016",
+ "CG-018",
+ "CG-019",
+ "CG-020",
+ "CG-021",
+ "VIS-003",
+ "VIS-004",
+ "VIS-008",
+ "VIS-010",
+ "VIS-013",
+ "UX-001",
+ "UX-003",
+ "UX-005",
+ "UX-006",
+ "AM-001",
+ "GOV-001",
+ "GOV-002",
+ "GOV-003",
+ "GOV-004",
+ "GOV-005",
+ "GOV-006",
+ "DEL-001"
+ ],
+ "deliverable": "web/src/voice/session.ts:readVoiceStatus 把 GET /api/voice/status 分成 none(无接口)/off(未配置)/ok(已配置且合成健康检查通过)/down(已配置但健康检查未过)/unknown(读不到)五态,voiceStatus 改为由它派生、行为不变;VoiceView 新增 voice(本次通话固定声音,开房前 null,服务器未给或不合规为 {id:null}),只在拿到本代 grant 时写入,结束/失败/迟到旧请求一律随 view 复位;callVoiceOf 只接受列表可容纳的 id。web/src/chat/ChatPanel.tsx:在既有通话条加「本次通话 · 声音名」(default→默认声音、缺字段→声音未说明,名字只按该 id 查列表,查不到显示编号,不用设置里的当前选择),title 写明到本次结束不变、设置里的新选择下一次通话生效。web/src/manage/ManagePanel.tsx:外接服务的语音识别/语音合成两行改为进入分区及点「重新读取状态」时读 /api/voice/status(仅真实入口),按五态如实说明,识别一行写明没有单独健康检查;配置框、按钮、说明卡与文字检查提示随之更正,视觉观察仍写尚未接通。web/src/voice/custom.ts + VoiceSection.tsx:VoiceError 增加 uncertain(已发出后断连、5xx、成功响应体读不全),登记遇到时提示「可能已经登记上了,先刷新列表看 cg- 编号条目,不用再登记」,提供「刷新列表」并把再登记改为「确认没有,再登记」;刷新后对比失败时的自定义列表,指出新出现条目或说明暂无新条目需稍等;列表把无本地元数据的 cg- 条目标「只有编号,缺本地资料」并加说明。4xx 与离线未发出仍为原明确提示。不新增删除入口、不自动删声音。components.css 追加通话条声音标签、状态行配色、编号条目说明与手机按钮换行(只用既有 token,两主题)。测试:tests/hel903_voice_rework.test.ts(11 例)、tests/hel903_mock.mjs、tests/hel903_shots.mjs;tests/hel898_agent_left.test.ts 期望视图补 voice:null。",
+ "acceptance": [
+ "外接服务:语音合成在 ok/down/off/none/unknown(500)/unknown(断连) 下分别显示「已配置 · 合成服务健康检查可连接」「已配置 · 合成服务这会儿暂不可用」「未配置 · 这台服务没有语音配置」「这台服务没有语音状态接口」「状态未知 · 这次没读到语音状态」;语音识别不声称有自己的健康检查;任一状态下语音两行都不出现「尚未接通」或「已接通」;读取状态不申请麦克风;说明写明「可连接」不代表整套语音通话已验收",
+ "通话:开房后通话条显示 session 响应 voice 对应的「本次通话 · 名称」;通话中另一端改选声音,本次通话显示不变;挂断后标签消失、麦克风释放;下一次通话显示新选择;响应缺 voice 字段显示「声音未说明」而非设置当前选择;default 显示「默认声音」;更早一次通话迟到的 grant 不覆盖新通话(单测)",
+ "登记:服务器已收下却回 500、或响应中途断开、或 503 时,提示可能已登记并先刷新列表;不自动重读、不自动再登记(上传计数验证);点刷新列表后能指出新出现的 cg- 编号条目并标「只有编号,缺本地资料」;若无新条目则说明稍等再刷、确认没有再登记,确认后再登记一次只发一次;参考声音与已填内容在失败后保留",
+ "回归:既有上传→登记→试听 canplay→应用流程、换声音只换发声的说明、录音点击开启/停止保留/取消释放麦克风、通话字幕与结束清理均保持;演示入口打开外接服务、她的声音与交谈零 /api 请求"
+ ],
+ "deferred": [
+ "后台新错误映射(本地元数据落盘失败 OSError→503/422 中文文案等,HEL-902 §5-2)由另卡处理;本卡先兼容既有 500/503,后续统一整合时按合同补核前端文案",
+ "语音识别没有独立健康状态接口,外接服务只能如实写「没有单独的健康检查」;如需显示识别服务真实状态需后台另行提供",
+ "编号条目的删除/回收(经 8090 面板自有删除)、自定义音色删除与完整备份恢复(CG-009/R6)继续由后续卡承接,本卡不新增删除入口",
+ "_capture_voice→worker 读文件的秒级竞态(HEL-902 §5-6)属后台;前端显示的是服务器声明的本次捕获值",
+ "真实 CosyVoice/FunASR/LiveKit、真机(iPhone/Android/Safari)与 R5 整体验收、R1–R7 完整功能继续承接;HEL-901 仍停放;本卡不部署、不合并"
+ ],
+ "requirement_changes": [],
+ "verification": [
+ "web(复用原作者 HEL-900 工作区已存在的同版 node_modules,package-lock SHA256 F950DE58…7A27 一致,经目录联接使用,零下载安装):tsc --noEmit 0 错;eslint src 0 错 1 既有警告(ChatPanel fullButton,非本卡);vitest 148/148(基线 137 + 本卡 11);vite build 通过",
+ "浏览器:node tests/hel903_mock.mjs 8903 + node tests/hel903_shots.mjs <msedge.exe> <outDir> 8903,headless Edge + 假 LiveKit(tests/fake-livekit.js)+ Chromium 假音频设备,90/90 通过:月光/雾光 × 电脑 1440×900/手机 390×844 各 18 项(外接服务、应用声音、本次通话声音固定、下一通更新、字幕、挂断释放、登记 500 后恢复),状态五分支、缺 voice 字段/default、响应断连与 503 恢复、录音释放、演示零请求",
+ "对照截图:同一 mock 跑 b09a907 原样构建(产物 index-C8UHROe1.js 与 HEL-900 作者构建同名)before 模式三张:外接服务、通话条、登记 500",
+ "需求检查:python -I scripts/check_requirements.py 与 --handoff docs/requirements/handoff-HEL-903.json 本机执行;本运行环境无 git,--base-ref origin/main 与 --base-ref b09a907 均无法执行,改以与 b09a907 源码包逐文件 SHA256 比对确认 docs/requirements 除新增本交接文件外零改动,--base-ref 四组检查交总管在仓库补跑",
+ "未验证如实声明:假麦克风是浏览器内置提示音;假 LiveKit 不是 WebRTC;试听是 mock 音调不是 TTS;未接真实 8090/9880/FunASR/LiveKit,未部署,未写共享音色,未访问 4080;Safari 与真机未测"
+ ]
+}
diff --git a/web/src/chat/ChatPanel.tsx b/web/src/chat/ChatPanel.tsx
--- a/web/src/chat/ChatPanel.tsx
+++ b/web/src/chat/ChatPanel.tsx
@@ -11,6 +11,7 @@
import { LineStage, type PresentHow } from './lineStage';
import { anchorOf, cursorAt, groupTurns, pagesOf, ReadingClock, type Anchor, type Metrics, type ShownMessage } from './reading';
import { mergeTurns, OFF, recentRows, VoiceController, voiceLine, voiceStatus, WORDS, type VoiceView } from '../voice/session';
+import { callVoiceName, listVoices } from '../voice/custom';
import { loadTransport } from '../voice/livekit';
import { newestId, serverId } from '../adapters/inbox';
@@ -89,6 +90,17 @@
voiceStatus(c.signal).then(s => setVoiceOff(s.available ? '' : s.reason), () => {});
return () => c.abort();
}, [real]);
+ // The voice this call was opened with (HEL-903). Its name is looked up by that id only — never the choice settings
+ // hold now, which is the next call's — and an answer for another id (an older call) is never shown.
+ const pinned = real ? voiceView.voice : null, [named, setNamed] = useState<{ id: string; label: string } | null>(null);
+ useEffect(() => {
+ const id = pinned?.id; if (!id || id === 'default' || named?.id === id) return;
+ const c = new AbortController();
+ listVoices(c.signal).then(list => { const v = !c.signal.aborted && list.configured ? list.voices.find(x => x.id === id) : undefined; if (v) setNamed({ id, label: v.label }); }, () => { /* The id stays on show. */ });
+ return () => c.abort();
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one lookup per pinned id.
+ }, [pinned?.id]);
+ const pinnedName = pinned && callVoiceName(pinned.id, named);
const voiceOn = real ? voiceView.phase !== 'off' : voice;
const voiceLabel = { off: '', opening: '正在接通…', joining: `在等${name}接起…`, listening: '在听你说', thinking: `${name}在想`, speaking: `${name}在说 · 开口就能打断` }[voiceView.phase];
useEffect(() => {
@@ -334,6 +346,7 @@
{real && voiceOn && <div className="voice-row real" data-phase={voiceView.phase} role="status" aria-live="polite">
<span className="voice-wave" aria-hidden="true"><i/><i/><i/><i/></span>
<span className="voice-state">{voiceLabel}</span>
+ {pinned && pinnedName && <span className={`voice-pin ${pinned.id ? '' : 'unknown'}`} title={pinned.id ? `本次通话用「${pinnedName}」,到这次通话结束都不变;设置里换的声音下一次通话才生效。` : '服务器这次没有说明用的是哪个声音,这里不猜。设置里换的声音下一次通话才生效。'}><small>本次<span className="vp-wide">通话</span></small>{pinnedName}</span>}
{voiceView.spoken ? <span className="voice-heard said" title={voiceView.spoken}>{name}:{voiceView.spoken}<em>未记入聊天</em></span> : voiceView.heard && <span className="voice-heard" title={voiceView.heard}>「{voiceView.heard}」</span>}
{voiceView.blocked && <button onClick={() => void voiceCtl.current?.resumeAudio()}>点一下听{name}的声音</button>}
<button className="voice-end" onClick={() => { void voiceCtl.current?.end(); setNotice('语音已结束,麦克风已关闭。'); }}>结束语音</button>
diff --git a/web/src/manage/ManagePanel.tsx b/web/src/manage/ManagePanel.tsx
--- a/web/src/manage/ManagePanel.tsx
+++ b/web/src/manage/ManagePanel.tsx
@@ -5,12 +5,21 @@
import { exportDemo, parseState } from '../adapters/storage';
import { BaseColor, Button, Field, Icon, Modal, Status } from '../shared/ui';
import { VoiceSection, type VoiceCap } from './VoiceSection';
+import { readVoiceStatus, type VoiceStatusKind } from '../voice/session';
const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['voice', 'mic', '她的声音'], ['city', 'pin', '城市'], ['appearance', 'image', '外观素材'], ['backup', 'box', '本地演示导出'], ['services', 'plug', '外接服务'], ['display', 'moon', '显示']];
// What each section can actually do here (HEL-815 capability tags): read-only, adjustable, demo only, not connected, this device.
type Cap = 'ro' | 'edit' | 'demo' | 'off' | 'live' | 'local';
const CAP: Record<Cap, [string, string]> = { ro: ['只读', ''], edit: ['可以调整', 'accent'], demo: ['演示数据', 'demo'], off: ['未接入', 'off'], live: ['文字已接通', 'ok'], local: ['本机生效', 'ok'] };
const capOf = (id: string, real: boolean, voice: VoiceCap = 'unknown'): Cap => id === 'her' ? 'ro' : id === 'relation' ? 'edit' : id === 'voice' ? real ? voice === 'off' ? 'off' : 'edit' : 'demo' : id === 'display' ? 'local' : id === 'services' && real ? 'live' : real ? 'off' : 'demo';
const NOTE: Record<string, string> = { her: '只读资料', relation: '可以调整', voice: '上传 · 录音 · 试听', display: '本机生效' };
+// 外接服务 · voice rows (HEL-903): exactly what GET /api/voice/status can tell — no route, not configured, configured with
+// the TTS health probe passing or failing, or unreadable. Recognition has no probe of its own there, so it never claims one.
+type VoiceSvc = VoiceStatusKind | 'loading';
+const VOICE_SVC: Record<'asr' | 'tts' | 'config', Record<VoiceSvc, string>> = {
+ tts: { loading: '正在读取语音状态…', none: '这台服务没有语音状态接口', off: '未配置 · 这台服务没有语音配置', ok: '已配置 · 合成服务健康检查可连接', down: '已配置 · 合成服务这会儿暂不可用', unknown: '状态未知 · 这次没读到语音状态' },
+ asr: { loading: '正在读取语音状态…', none: '这台服务没有语音状态接口', off: '未配置 · 这台服务没有语音配置', ok: '已配置 · 没有单独的健康检查,以实际通话为准', down: '已配置 · 没有单独的健康检查,以实际通话为准', unknown: '状态未知 · 这次没读到语音状态' },
+ config: { loading: '读取中', none: '服务尚未配置', off: '服务尚未配置', ok: '由服务器管理', down: '由服务器管理', unknown: '暂时读不到' },
+};
function CapTag({ cap }: { cap: Cap }) { const [text, tone] = CAP[cap]; return <span className={`tag cap ${tone}`}>{text}</span>; }
// Honest placeholder for a capability that is not connected: what it will do once connected, and that nothing happens now.
function Off({ title, will, now }: { title: string; will: string; now: string }) { return <div className="off-card"><p className="off-t">{title}</p><p>{will}</p><p className="d-dim">{now}</p></div>; }
@@ -33,6 +42,14 @@
const approvedNavigation = useRef(false);
const pendingNavigation = useRef<(() => void) | null>(null);
const [serviceHealth, setServiceHealth] = useState<Health>();
+ // Read on opening 外接服务 and on an explicit re-read; real only (the demo never asks a server).
+ const [voiceSvc, setVoiceSvc] = useState<VoiceSvc>('loading'), [voiceSvcRead, setVoiceSvcRead] = useState(0);
+ useEffect(() => {
+ if (!real || !open || section !== 'services') return;
+ const c = new AbortController(); setVoiceSvc('loading');
+ readVoiceStatus(c.signal).then(kind => { if (!c.signal.aborted) setVoiceSvc(kind); }, () => { /* Aborted: left the section. */ });
+ return () => c.abort();
+ }, [real, open, section, voiceSvcRead]);
// 她的声音 (HEL-900): an unregistered reference counts as unsaved; the tag follows what the server reports.
const [voiceDirty, setVoiceDirty] = useState(false), [voiceCap, setVoiceCap] = useState<VoiceCap>('unknown');
const request = useRef<AbortController | null>(null), generation = useRef(0), feedback = useRef<HTMLDivElement>(null);
@@ -56,7 +73,7 @@
}, [open, anyDirty]);
async function checkService() {
setBusy(true); setError(false); setMessage('正在检查文字服务…');
+ try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。视觉观察和长期记忆仍未接通;语音状态以上面两行为准。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
- try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。语音、视觉和长期记忆仍未接通。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
catch (e) { setMessage((e as Error).message); setError(true); }
finally { setBusy(false); }
}
@@ -187,7 +204,7 @@
{real && section === 'city' && <><Off title="城市与天气还没有接上。" will="接上以后,你选一个真实城市,她房间里的天气、白天黑夜和作息都跟着那里走;换城市不需要每次重新定位。" now="现在窗外的雨是界面氛围,不是真实天气。尚未设置可核实的城市,天气来源尚未确定。"/><Field label="搜索城市" hint="可浏览候选目录;当前无法保存,不会更改她的城市。"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button disabled>{c}<small>候选城市 · 保存待接入</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <div className="empty">当前目录没有匹配的城市。</div>}<div className="actions"><Button disabled>保存城市 · 待接入</Button></div></>}
{real && section === 'appearance' && <><dl className="kv"><dt>现在的样子</dt><dd>工程外观 v2<small>参考静帧 · 尚无成套日常与交谈视频</small></dd></dl><Off title="换外观还没有接上。" will="接上以后:选择素材包 → 检查素材 → 看新旧对比 → 确认「她还是她」。只换样子,名字、底色、记忆和关系都不变。" now="素材包规范与导入检查尚未定稿。这里不会导入、预览或替换任何素材。"/><Field label="选择外观素材包" hint="素材包格式与媒体检查将在后续接入。当前不上传、不读取文件。"><input type="file" disabled/></Field><ul className="phases">{['清单结构与版本', '媒体格式与时长', '人物一致性', '必要场景覆盖', '可用性与兼容性'].map(label => <li key={label}>待检查 · {label}</li>)}</ul><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前内置参考静帧"/><figcaption>当前参考</figcaption></figure><figure><div className="empty">导入并通过检查后显示新外观</div><figcaption>新旧对比 · 待接入</figcaption></figure></div><Button disabled>确认应用外观 · 待接入</Button></>}
{real && section === 'backup' && <><Off title="备份还没有接上。" will="接上以后:服务器每天自动备份;你可以随时把她完整导出到自己的电脑(含底色、性格资料、对话、记忆、关系、生活进度和所用素材),也能从备份恢复同一个她。" now="现在没有任何备份在运行,这里也不会假装导出或恢复。"/><div className="card"><h4>每日自动备份</h4><p>暂无可核实的备份记录。自动备份、保留份数与运行结果将在接入后显示。</p><div className="empty">尚未接通备份服务</div></div><div className="card"><h4>完整导出</h4><p>将包含服务器档案、聊天及届时接通的记录与素材。当前不生成伪备份文件。</p><Button disabled>导出完整备份 · 待接入</Button></div><Field label="恢复同一位她的备份" hint="恢复前会检查完整性、版本和同一档案,要求输入“恢复”,并先备份当前资料。当前不接收文件或执行覆盖。"><input type="file" disabled/></Field><ul className="phases">{['检查完整性与版本', '核对是否同一位她', '先备份当前资料', '输入确认并恢复'].map(label => <li key={label}>待接入 · {label}</li>)}</ul><button className="btn-danger" disabled>检查备份并继续 · 待接入</button></>}
+ {real && section === 'services' && <>{([['text', '文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['voice', '语音识别', VOICE_SVC.asr[voiceSvc]], ['voice', '语音合成', VOICE_SVC.tts[voiceSvc]], ['off', '视觉观察', '尚未接通']] as const).map(([kind, label, status]) => <div className="svc" key={label} data-state={kind === 'voice' ? voiceSvc : undefined}><div className="nm">{label}<small>{status}</small></div>{kind === 'text' ? <Button disabled={busy} onClick={() => void checkService()}>{busy ? '检查中…' : '重新检查连接'}</Button> : kind === 'voice' ? <Button disabled={voiceSvc === 'loading'} onClick={() => setVoiceSvcRead(n => n + 1)}>{voiceSvc === 'loading' ? '读取中…' : '重新读取状态'}</Button> : <Button disabled>测试连接 · 待接入</Button>}<input className="input" aria-label={`${label}配置`} value={kind === 'text' ? '由服务器管理' : kind === 'voice' ? VOICE_SVC.config[voiceSvc] : '服务尚未配置'} readOnly/></div>)}<p className="d-dim">语音两项读自服务器的语音状态:「可连接」只说明她的声音(合成)这会儿通过了健康检查,不代表整套语音通话已经验收;语音识别这里查不到单独的状态。这里只读状态,不申请麦克风。</p><Off title="视觉观察还没有接上。" will="接上以后:视觉观察只看人在不在、明显物品和房间变化,过期的画面不再当作眼前。语音由你在交谈里主动开启,结束后停止收音,文字聊天一直保留。" now="这里不申请摄像头,不采集影像。"/><div className="card"><h4>其余能力</h4><p>{life?.wired ? `生活:${life.label}。${life.note}长期记忆、主动消息和天气尚未接通。` : '生活、长期记忆、主动消息和天气尚未接通。'}文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</p></div></>}
- {real && section === 'services' && <>{[['文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['语音识别', '尚未接通'], ['语音合成', '尚未接通'], ['视觉观察', '尚未接通']].map(([label, status], index) => <div className="svc" key={label}><div className="nm">{label}<small>{status}</small></div><Button disabled={index !== 0 || busy} onClick={() => void checkService()}>{index === 0 ? busy ? '检查中…' : '重新检查连接' : '测试连接 · 待接入'}</Button><input className="input" aria-label={`${label}配置`} value={index === 0 ? '由服务器管理' : '服务尚未配置'} readOnly/></div>)}<Off title="语音与视觉观察还没有接上。" will="接上以后:语音由你主动开启,结束后停止收音,文字聊天一直保留;视觉观察只看人在不在、明显物品和房间变化,过期的画面不再当作眼前。" now="这里不申请麦克风或摄像头,不采集声音与影像。"/><div className="card"><h4>其余能力</h4><p>{life?.wired ? `生活:${life.label}。${life.note}长期记忆、主动消息和天气尚未接通。` : '生活、长期记忆、主动消息和天气尚未接通。'}文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</p></div></>}
<div ref={feedback}><Status text={message} error={error}/></div>{(real ? ['relation', 'display'] : ['relation', 'city', 'services', 'display']).includes(section) && <div className="actions"><span className={`grow ${dirty ? 'dirty' : ''}`}>{dirty ? '有未保存的修改' : '尚未修改'}</span><Button disabled={!dirty || busy} onClick={reset}>取消修改</Button><Button primary disabled={!dirty || busy} onClick={() => void save()}>{busy ? '正在保存…' : error ? '重试保存' : '保存'}</Button></div>}</div></div></div>
{confirm && <Modal title={confirm === 'leave' ? '有未保存的修改' : confirm === 'relation' ? `把关系从「${state.identity.relation}」改成「${draft.identity.relation}」?` : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'leave') { request.current?.abort(); setBusy(false); setDraft({ identity: structuredClone(state.identity), settings: structuredClone(state.settings) }); const action = pendingNavigation.current; pendingNavigation.current = null; approvedNavigation.current = true; try { action?.(); } finally { approvedNavigation.current = false; } } if (which === 'relation')
void save(true); if (which === 'appearance') {
diff --git a/web/src/manage/VoiceSection.tsx b/web/src/manage/VoiceSection.tsx
--- a/web/src/manage/VoiceSection.tsx
+++ b/web/src/manage/VoiceSection.tsx
@@ -4,7 +4,7 @@
// (request aborted, audio stopped, object URL revoked), so an old sample can never pass for the new one.
import { useEffect, useRef, useState, type RefObject } from 'react';
import { clockOf } from '../adapters/real';
+import { applyVoice, idOnly, LABEL_MAX, listVoices, PREVIEW_DEFAULT, PREVIEW_MAX, previewVoice, Recorder, registerVoice, SAY, SUGGEST_MAX, SUGGEST_MIN, TEXT_MAX, toReference, VoiceError, type CustomVoice, type RecView, type VoiceSelection } from '../voice/custom';
-import { applyVoice, LABEL_MAX, listVoices, PREVIEW_DEFAULT, PREVIEW_MAX, previewVoice, Recorder, registerVoice, SAY, SUGGEST_MAX, SUGGEST_MIN, TEXT_MAX, toReference, VoiceError, type CustomVoice, type RecView, type VoiceSelection } from '../voice/custom';
import { Button, Field, Icon, Status } from '../shared/ui';
export type VoiceCap = 'edit' | 'off' | 'unknown';
@@ -37,6 +37,11 @@
// One controller per kind of request; a new one aborts the old. `takeGen` guards decoding a file or a recording.
const ctl = useRef<Record<'list' | 'register' | 'preview' | 'apply', AbortController | null>>({ list: null, register: null, preview: null, apply: null });
const takeGen = useRef(0), urls = useRef(new Set<string>()), listed = useRef(false);
+ // HEL-903: a registration that failed without a clear answer may still have been filed. `unsure` holds the custom ids
+ // listed when it failed, so an explicit refresh can say which entry is new (never a silent second registration).
+ const [unsure, setUnsure] = useState(false), unsureBefore = useRef<Set<string> | null>(null);
+ const settleUnsure = () => { unsureBefore.current = null; setUnsure(false); };
+ const loadNow = useRef(load); loadNow.current = load;
const fresh = (kind: keyof typeof ctl.current) => { ctl.current[kind]?.abort(); const c = new AbortController(); ctl.current[kind] = c; return c; };
const own = (blob: Blob) => { const url = URL.createObjectURL(blob); urls.current.add(url); return url; };
const drop = (url?: string) => { if (url && urls.current.delete(url)) URL.revokeObjectURL(url); };
@@ -69,6 +74,12 @@
if (c.signal.aborted) return;
if (!list.configured) { setLoad({ state: 'off', text: SAY.notConfigured }); onCap('off'); return; }
listed.current = true; setNote(n => n.error ? { text: '', error: false } : n); setLoad({ state: 'ok', voices: list.voices, selection: list.selection }); onCap('edit');
+ const before = unsureBefore.current;
+ if (before) {
+ const fresh = list.voices.filter(v => v.source === 'custom' && !before.has(v.id));
+ if (fresh.length) { settleUnsure(); setRegNote({ text: `列表里多了${fresh.map(v => `「${v.label}」`).join('、')},${SAY.refreshFound}`, error: false }); }
+ else setRegNote({ text: SAY.refreshNone, error: false });
+ }
}, e => {
if (c.signal.aborted) return;
const status = e instanceof VoiceError ? e.status : 0;
@@ -86,6 +97,7 @@
try {
const ref = await toReference(input);
if (gen !== takeGen.current) return;
+ settleUnsure();
const url = own(ref.wav);
setTake(old => { drop(old?.url); return { wav: ref.wav, url, seconds: ref.seconds, from: kind, name: label }; });
setRegNote({ text: '', error: false });
@@ -99,7 +111,7 @@
if (!file || registering) return;
void accept(file, 'file', file.name.slice(0, 60));
}
+ function discardTake() { takeGen.current++; recorder.current?.cancel(); settleUnsure(); setTake(old => { drop(old?.url); return null; }); setRegNote({ text: '', error: false }); }
- function discardTake() { takeGen.current++; recorder.current?.cancel(); setTake(old => { drop(old?.url); return null; }); setRegNote({ text: '', error: false }); }
function switchFrom(next: 'file' | 'record') { if (next === from) return; recorder.current?.cancel(); setFrom(next); setRegNote({ text: '', error: false }); }
const text = form.text.trim(), needText = !text && !form.allowAsr;
@@ -111,6 +123,7 @@
try {
const done = await registerVoice(sent.wav, form, c.signal);
if (c.signal.aborted) return;
+ settleUnsure();
setTake(old => { if (old === sent) { drop(old.url); return null; } return old; });
setForm({ label: '', text: '', allowAsr: false });
setRegNote({ text: done.prompt_text_source === 'asr' ? `登记好了:「${done.label}」。服务器转写的参考文字是:${done.prompt_text || '(空)'}` : `登记好了:「${done.label}」。`, error: false });
@@ -119,8 +132,15 @@
setReads(n => n + 1);
} catch (e) {
if (c.signal.aborted) return;
+ // Nothing is lost on failure: the take, the label and the text all stay for another try. When the server may
+ // have filed it anyway, say so and offer a look at the list first; the list is not re-read behind your back.
+ if (e instanceof VoiceError && e.uncertain) {
+ const l = loadNow.current;
+ if (l.state === 'ok' && !unsureBefore.current) unsureBefore.current = new Set(l.voices.filter(v => v.source === 'custom').map(v => v.id));
+ setUnsure(true);
+ // The server's own words when it gave any; a bare "try again later" would contradict "look first".
+ setRegNote({ text: e.message === SAY.failed || e.message === SAY.odd ? SAY.maybeRegistered : `${e.message}${SAY.maybeRegistered}`, error: true });
+ } else setRegNote({ text: message(e), error: true });
- // Nothing is lost on failure: the take, the label and the text all stay for another try.
- setRegNote({ text: message(e), error: true });
} finally { if (ctl.current.register === c) { ctl.current.register = null; setRegistering(false); } }
}
@@ -174,7 +194,7 @@
const mine = voices.filter(v => v.source === 'custom'), builtin = voices.filter(v => v.source === 'builtin');
const chosen = voices.find(v => v.id === candidate), ready = sample?.voice === candidate && sample.state === 'ready';
const recording = rec.phase === 'asking' || rec.phase === 'recording';
+ const row = (v: CustomVoice) => <li key={v.id}><button aria-pressed={candidate === v.id} onClick={() => pick(v.id)} disabled={applying}><span>{v.label}{v.selected && <span className="tag accent">正在用</span>}</span><small>{idOnly(v) ? '自定义 · 只有编号,缺本地资料' : v.source === 'custom' ? `自定义${v.registered_at ? ` · ${clockOf(v.registered_at).date}` : ''}` : '服务自带'}{v.previewed ? ' · 试听过' : ''}</small></button></li>;
- const row = (v: CustomVoice) => <li key={v.id}><button aria-pressed={candidate === v.id} onClick={() => pick(v.id)} disabled={applying}><span>{v.label}{v.selected && <span className="tag accent">正在用</span>}</span><small>{v.source === 'custom' ? `自定义${v.registered_at ? ` · ${clockOf(v.registered_at).date}` : ''}` : '服务自带'}{v.previewed ? ' · 试听过' : ''}</small></button></li>;
return <div className="voice-sec">
<dl className="kv"><dt>现在的声音</dt><dd>{selection.voice ? <>{current?.label || selection.label || selection.voice}<small>{current?.source === 'custom' ? '自定义' : current ? '服务自带' : '这个声音已不在列表里,通话时会用默认声音'}{selection.updated_at ? ` · ${clockOf(selection.updated_at).date} ${clockOf(selection.updated_at).time} 换上` : ''}</small></> : <>还没有为她选过<small>通话时用服务默认的声音</small></>}</dd>
@@ -197,7 +217,7 @@
<Field label="给这个声音起个名字" hint={`最多 ${LABEL_MAX} 个字,只用来在这里区分;不填就用编号。`}><input className="input" value={form.label} maxLength={LABEL_MAX} placeholder="比如:轻一点的她" disabled={registering} onChange={e => setForm(f => ({ ...f, label: e.target.value.replace(/[<>\p{Cc}]/gu, '') }))}/></Field>
<Field label="参考文字" hint="这段声音里念的原话。写得越准,克隆越像。"><textarea className="textarea" value={form.text} maxLength={TEXT_MAX} rows={2} disabled={registering} placeholder="照着录音里念的内容写下来" onChange={e => setForm(f => ({ ...f, text: e.target.value }))}/></Field>
<label className="switch-row"><span>参考文字留空时,允许服务器转写<small>{text ? '已经填写了参考文字,不会转写。' : '会用这台服务器上的语音识别听一遍这段声音;不打开就必须填写参考文字。'}</small></span><input type="checkbox" className="switch" checked={form.allowAsr && !text} disabled={!!text || registering} onChange={e => setForm(f => ({ ...f, allowAsr: e.target.checked }))}/></label>
+ <div className="take-acts">{unsure && <Button primary disabled={registering} onClick={() => setReads(n => n + 1)}><Icon name="list"/>刷新列表</Button>}<Button primary={!unsure} disabled={registering || needText} onClick={() => void register()}>{registering ? '正在登记…' : unsure ? '确认没有,再登记' : '登记这段声音'}</Button><Button disabled={registering} onClick={discardTake}>换一段</Button></div>
- <div className="take-acts"><Button primary disabled={registering || needText} onClick={() => void register()}>{registering ? '正在登记…' : '登记这段声音'}</Button><Button disabled={registering} onClick={discardTake}>换一段</Button></div>
{needText && !registering && <p className="d-dim">填写参考文字,或打开上面的转写开关,才能登记。</p>}
</div>}
<Status text={regNote.text} error={regNote.error}/>
@@ -205,6 +225,7 @@
<h4 className="sub-h">可以给她用的声音<small>先试听,再给她用</small></h4>
<p className="voice-group">你给的声音</p>
{mine.length ? <ul className="city-list voice-list">{mine.map(row)}</ul> : <div className="empty sm">还没有登记过自己的声音。</div>}
+ {mine.some(idOnly) && <p className="d-dim voice-idonly">{SAY.idOnly}</p>}
{builtin.length > 0 && <details className="voice-builtin" open={!!chosen && chosen.source === 'builtin'}><summary>服务自带的声音 · {builtin.length} 个</summary><ul className="city-list voice-list">{builtin.map(row)}</ul></details>}
{chosen && <div className="card audition" aria-live="polite">
diff --git a/web/src/styles/components.css b/web/src/styles/components.css
--- a/web/src/styles/components.css
+++ b/web/src/styles/components.css
@@ -1035,3 +1035,15 @@
.voice-sec .take-acts .btn-primary,.voice-sec .take-acts .btn-ghost,.voice-sec .rec-acts button{flex:1}
.voice-sec .rec .btn-ghost{width:100%}
}
+/* ── HEL-903: the call's own voice on the call line, the voice status rows, id-only entries. Tokens only. ── */
+.voice-row.real .voice-pin{display:inline-block;flex:0 1 auto;min-width:0;max-width:12em;padding-left:10px;border-left:1px solid var(--glass-line);color:var(--text-1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.voice-row.real .voice-pin small{margin-right:6px;font-size:11px;color:var(--text-3);letter-spacing:.04em}
+.voice-row.real .voice-pin.unknown{color:var(--text-3)}
+.svc[data-state="ok"] .nm small{color:var(--ok)}
+.svc[data-state="down"] .nm small{color:var(--warn)}
+.voice-idonly{margin:8px 0 0;font-size:12.5px}
+@media (max-width:720px){
+ .voice-sec .take-acts .btn-primary,.voice-sec .take-acts .btn-ghost{flex:1 1 auto;white-space:nowrap}
+ .voice-row.real .voice-pin{max-width:9.5em;padding-left:8px}
+ .voice-row.real .voice-pin .vp-wide{display:none}
+}
diff --git a/web/src/voice/custom.ts b/web/src/voice/custom.ts
--- a/web/src/voice/custom.ts
+++ b/web/src/voice/custom.ts
@@ -37,9 +37,16 @@
noRecorder: '这个浏览器不支持在网页里录音。可以改用上传文件。',
micGone: '麦克风被关掉了,这段没有录完,已经丢弃。',
recordFailed: '这次录音没有成功,已经丢弃,可以再录一次。',
+ // HEL-903: the upload may have reached the shared voice panel even though no answer came back (server error, a
+ // dropped connection, local metadata not saved). Look before registering again, so one voice is not filed twice.
+ maybeRegistered: '这段声音可能已经登记上了,只是这边没收到结果。先点「刷新列表」,看看「你给的声音」里有没有新出现的编号条目(cg- 开头):有就直接选它试听、使用,不用再登记一次。',
+ refreshFound: '很可能就是刚才那段:直接在下面选它试听、使用就好,不用再登记一次。',
+ refreshNone: '列表里暂时没有新出现的条目。服务器可能还在处理,稍等几秒再刷新一次;确认没有再登记。',
+ idOnly: '只有编号的条目:声音已经登记在共享面板上,但这台服务器没存下它的名字和参考文字,多半来自一次没收到结果的登记。它仍可试听、使用;这里暂时不能删除。',
} as const;
+// `uncertain`: the request may have been carried out even though it failed here (sent, then no usable answer).
+export class VoiceError extends Error { constructor(message: string, readonly status = 0, readonly uncertain = false) { super(message); } }
-export class VoiceError extends Error { constructor(message: string, readonly status = 0) { super(message); } }
// ── WAV ──
export function encodeWav(samples: Float32Array, rate = RATE): ArrayBuffer {
@@ -111,7 +118,8 @@
async function call(f: Fetch, path: string, init: RequestInit): Promise<Response> {
if (!online()) throw new VoiceError(SAY.offline);
try { return await f(`/api/voice/${path}`, init); }
+ // Gone after sending started: whether the server acted on it is unknown (an offline check above sent nothing).
+ catch (e) { if (init.signal?.aborted) throw e; throw new VoiceError(online() ? SAY.unreachable : SAY.offline, 0, true); }
- catch (e) { if (init.signal?.aborted) throw e; throw new VoiceError(online() ? SAY.unreachable : SAY.offline); }
}
async function refusal(r: Response, fallback: string = SAY.failed): Promise<VoiceError> {
const data = await r.json().catch(() => null) as { detail?: unknown } | null;
@@ -136,11 +144,22 @@
export async function registerVoice(wav: Blob, form: { label: string; text: string; allowAsr: boolean }, signal: AbortSignal, f: Fetch = fetch): Promise<Registered> {
const q = new URLSearchParams({ label: form.label.trim(), prompt_text: form.text.trim(), allow_asr: form.allowAsr && !form.text.trim() ? 'true' : 'false' });
const r = await call(f, `voices?${q}`, { method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: wav, signal });
+ // A 4xx is a clear refusal (nothing filed). A 5xx, or a success whose body never arrived whole, may sit after the
+ // panel already took the voice (HEL-902 §4: metadata write failing → 500, panel down → 503).
+ if (!r.ok) { const e = await refusal(r); throw r.status >= 500 ? new VoiceError(e.message, r.status, true) : e; }
- if (!r.ok) throw await refusal(r);
const data = await r.json().catch(() => null) as Partial<Registered> | null;
+ if (!data || typeof data.id !== 'string' || !data.id) throw new VoiceError(SAY.odd, r.status, true);
- if (!data || typeof data.id !== 'string' || !data.id) throw new VoiceError(SAY.odd);
return { id: data.id, label: typeof data.label === 'string' && data.label ? data.label : data.id, prompt_text: typeof data.prompt_text === 'string' ? data.prompt_text : '', prompt_text_source: data.prompt_text_source === 'asr' ? 'asr' : 'user' };
}
+// A voice the server lists with no local metadata (label falls back to the id, no registration time): the panel has it,
+// this server never recorded it — typically a registration whose answer was lost.
+export const idOnly = (v: CustomVoice) => v.source === 'custom' && v.registered_at === null && v.label === v.id;
+// The name a call's pinned voice is shown by: its own label if the list knows that id, else the id itself; never a guess.
+export function callVoiceName(id: string | null, named: { id: string; label: string } | null): string {
+ if (!id) return '声音未说明';
+ if (id === 'default') return '默认声音';
+ return named?.id === id ? named.label : id;
+}
// A fresh synthesis every time. Only a real WAV comes back as playable; anything else is refused, never played.
export async function previewVoice(voice: string, text: string, signal: AbortSignal, f: Fetch = fetch): Promise<Blob> {
const r = await call(f, 'preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(text.trim() ? { voice, text: text.trim() } : { voice }), signal });
diff --git a/web/src/voice/session.ts b/web/src/voice/session.ts
--- a/web/src/voice/session.ts
+++ b/web/src/voice/session.ts
@@ -24,12 +24,16 @@
notice: string;
// The browser held her audio back until a tap (autoplay rules); `resume` lets it out.
blocked: boolean;
+ // The voice the server pinned for THIS call (HEL-903, session response `voice`): null until a room is granted;
+ // `id: null` when the server did not say. It stays for the whole call; a choice made in settings is the next call's.
+ voice: CallVoice | null;
}
+export interface CallVoice { id: string | null }
// One "she speaks" (an SDK speech): her reply to your voice turn, or a stored reach-out read aloud. `open` grows by
// replacement (each interim is the whole text so far); `final` is the stored reply's exact text; `done` is closed and
// stays only until the record (or the next turn) takes its place.
export interface HerLine { speech: string; text: string; state: 'open' | 'final' | 'done'; turnId?: string; messageId?: number; proactive?: boolean }
+export const OFF: VoiceView = { phase: 'off', heard: '', spoken: '', her: null, notice: '', blocked: false, voice: null };
-export const OFF: VoiceView = { phase: 'off', heard: '', spoken: '', her: null, notice: '', blocked: false };
// ── cg.subtitle contract v2 (docs/voice-subtitle-contract-v2.md). Anything that is not exactly v2 is dropped. ──
const USER_KINDS = ['interim', 'user_final'] as const;
@@ -69,7 +73,9 @@
export interface VoiceTransport { capture(): Promise<MicTrack>; join(url: string, token: string, on: RoomEvents, signal: AbortSignal): Promise<VoiceRoom> }
export interface VoiceAvailability { available: boolean; reason: string }
+export interface SessionGrant { url: string; token: string; room: string; session_id: string; epoch: number; expires_at: number; voice?: unknown }
+// Only an id the voice list could hold is taken as the call's voice; anything else is "not said", never guessed.
+export const callVoiceOf = (grant: { voice?: unknown }): CallVoice => ({ id: typeof grant.voice === 'string' && /^[\w-]{1,40}$/u.test(grant.voice) ? grant.voice : null });
-export interface SessionGrant { url: string; token: string; room: string; session_id: string; epoch: number; expires_at: number }
export const WORDS = {
notConfigured: '语音还没有在这台服务上配置,文字仍可使用。',
@@ -109,16 +115,24 @@
const AGENT_PHASE: Record<string, VoicePhase> = { initializing: 'joining', listening: 'listening', thinking: 'thinking', speaking: 'speaking' };
+// What GET /api/voice/status can actually tell (HEL-903): no route on this server, not configured, configured with the
+// TTS health probe passing ('ok') or failing ('down'), or unreadable. It says nothing about recognition on its own and
+// never that a whole call works end to end (`voice` stays false until real-device acceptance).
+export type VoiceStatusKind = 'none' | 'off' | 'ok' | 'down' | 'unknown';
+export async function readVoiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceStatusKind> {
-export async function voiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceAvailability> {
let response: Response;
+ try { response = await f('/api/voice/status', { signal }); } catch (e) { if (signal.aborted) throw e; return 'unknown'; }
- try { response = await f('/api/voice/status', { signal }); } catch (e) { if (signal.aborted) throw e; return { available: false, reason: WORDS.unknown }; }
// A server from before R5 has no such route: that is "not wired", not a failure.
+ if (response.status === 404 || response.status === 405) return 'none';
- if (response.status === 404 || response.status === 405) return { available: false, reason: WORDS.noRoute };
const data = await response.json().catch(() => null) as { configured?: unknown; tts_ok?: unknown } | null;
+ if (!response.ok || !data) return 'unknown';
+ if (data.configured !== true) return 'off';
+ return data.tts_ok === true ? 'ok' : 'down';
+}
+const REASON: Record<VoiceStatusKind, string> = { none: WORDS.noRoute, off: WORDS.notConfigured, ok: '', down: WORDS.ttsDown, unknown: WORDS.unknown };
+export async function voiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceAvailability> {
+ const kind = await readVoiceStatus(signal, f);
+ return { available: kind === 'ok', reason: REASON[kind] };
- if (!response.ok || !data) return { available: false, reason: WORDS.unknown };
- if (data.configured !== true) return { available: false, reason: WORDS.notConfigured };
- if (data.tts_ok !== true) return { available: false, reason: WORDS.ttsDown };
- return { available: true, reason: '' };
}
function micFailure(e: unknown, secure: boolean) {
@@ -202,8 +216,9 @@
if (!response.ok || !data?.token || !data.url || !live.session) return fail(typeof data?.detail === 'string' && data.detail ? data.detail : WORDS.unreachable);
const grant = data;
+ // 4. Join and speak. The voice this room was opened with is shown from here to the end of this call; a late grant
+ // of an older call never gets here (stale), and every end or failure resets the view.
+ this.show({ phase: 'joining', voice: callVoiceOf(grant) }, gen);
- // 4. Join and speak.
- this.show({ phase: 'joining' }, gen);
const events: RoomEvents = {
agentJoined: () => { if (stale() || live.agent) return; live.agent = true; clearTimeout(live.timer); if (!live.stated) this.show({ phase: 'listening' }, gen); },
// Her worker gone with the room still open: no one would ever answer, so the call ends honestly, exactly as a
diff --git a/web/tests/hel898_agent_left.test.ts b/web/tests/hel898_agent_left.test.ts
--- a/web/tests/hel898_agent_left.test.ts
+++ b/web/tests/hel898_agent_left.test.ts
@@ -62,7 +62,7 @@
room.say({ v: 2, seq: 3, kind: 'assistant_interim', role: 'assistant', text: '在听呢。', speech_id: 'sp' });
expect(r.view()).toMatchObject({ phase: 'thinking', her: { speech: 'sp', text: '在听呢。', state: 'open' } });
room.leave(worker.identity); await flush();
+ expect(r.view()).toEqual({ phase: 'off', heard: '', spoken: '', her: null, notice: WORDS.agentLeft, blocked: false, voice: null });
- expect(r.view()).toEqual({ phase: 'off', heard: '', spoken: '', her: null, notice: WORDS.agentLeft, blocked: false });
expect(mics[0].stopped).toBeGreaterThan(0);
expect(room.disconnects).toBe(1);
expect(deletes(r.calls)).toEqual(['DELETE /api/voice/session/s1']);
diff --git a/web/tests/hel903_mock.mjs b/web/tests/hel903_mock.mjs
new file mode 100644
--- /dev/null
+++ b/web/tests/hel903_mock.mjs
@@ -0,0 +1,130 @@
+// HEL-903 isolated contract mock: the HEL-878 voice session (fake LiveKit via tests/fake-livekit.js) and the HEL-900
+// custom-voice endpoints in one process, answering as src/cybergril/voice/router.py does at b09a907 — the session
+// answer carries `voice` (selection if still listed, else "default"), /api/voice/status carries configured/tts_ok.
+// Adds the failures this card handles: a registration the panel filed while the answer was lost (500 / dropped).
+// Synthetic data only: no voice panel, ASR, TTS, LiveKit or network beyond 127.0.0.1.
+// Usage: node tests/hel903_mock.mjs <port>. Control GETs: /__status?mode=ok|down|off|none|500|net,
+// /__session-voice?v=<id>|omit|(empty: as the server), /__fail?register=503|500|net|commit500|commitnet,
+// /__select?voice=<id> (another device applies a voice), /__reset, /__log (drains), /__uploads, /__state, /__pid.
+import { createServer } from 'node:http';
+import { readFile } from 'node:fs/promises';
+import { extname, join, normalize } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const dist = fileURLToPath(new URL('../dist/', import.meta.url)), fake = fileURLToPath(new URL('./fake-livekit.js', import.meta.url));
+const port = Number(process.argv[2] || 8903);
+const T0 = Date.UTC(2026, 9, 27, 12, 0);
+const identity = { id: '1', name: '栖', birth: '2000-03-14', hour: '子', relation: '朋友', nickname: '', boundary: '', color: { pillars: { year: '庚辰', month: '己卯', day: '丙午', hour: '戊子' }, traits: '合成测试档案' } };
+const BUILTIN = ['bailu', 'default', 'female_soft', 'male_warm', 'rain', 'xiaoyun'];
+const log = [], uploads = [], sessions = new Map();
+let statusMode = 'ok', sessionVoice = '', fail = {}, custom = new Map(), orphans = new Set(), previewed = new Set(), selection = { voice: null, label: '', updated_at: null }, made = 0, rooms = 0;
+const reset = () => { statusMode = 'ok'; sessionVoice = ''; fail = {}; custom = new Map(); orphans = new Set(); previewed = new Set(); selection = { voice: null, label: '', updated_at: null }; made = 0; log.splice(0); uploads.splice(0); sessions.clear(); };
+const send = (res, status, body, type = 'application/json; charset=utf-8') => { res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)); };
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const body = async req => { const parts = []; for await (const c of req) parts.push(c); return Buffer.concat(parts); };
+function wavInfo(b) {
+ if (b.length < 44 || b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WAVE') return null;
+ let at = 12, fmt = null;
+ while (at + 8 <= b.length) {
+ const id = b.toString('ascii', at, at + 4), size = b.readUInt32LE(at + 4);
+ if (id === 'fmt ') fmt = { channels: b.readUInt16LE(at + 10), rate: b.readUInt32LE(at + 12), bits: b.readUInt16LE(at + 22) };
+ if (id === 'data') return fmt && { ...fmt, seconds: Math.min(size, b.length - at - 8) / (fmt.rate * fmt.channels * fmt.bits / 8) };
+ at += 8 + size + (size & 1);
+ }
+ return null;
+}
+function tone(seconds = 1.6, rate = 24000) {
+ const n = Math.round(seconds * rate), b = Buffer.alloc(44 + n * 2);
+ b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
+ b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
+ for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 9) * 6000 * Math.min(1, i / 2000, (n - i) / 2000)), 44 + i * 2);
+ return b;
+}
+const listed = () => [...BUILTIN, ...custom.keys(), ...orphans];
+const known = id => listed().includes(id);
+// An orphan is in the shared panel only: no CyberGril metadata, so the router lists it with label = id.
+const item = id => { const r = custom.get(id); return { id, label: r ? r.label : id, source: id.startsWith('cg-') ? 'custom' : 'builtin', selected: selection.voice === id, previewed: previewed.has(id), registered_at: r ? r.registered_at : null, prompt_text: r ? r.prompt_text : null, prompt_wav: r ? r.prompt_wav : null }; };
+const take = kind => { const f = fail[kind]; delete fail[kind]; return f; };
+const newId = () => `cg-${(0x5e7a0000 + ++made).toString(16)}`;
+
+const handle = async (req, res) => {
+ const url = new URL(req.url, 'http://x'), p = url.pathname;
+ if (p.startsWith('/api/')) log.push(`${req.method} ${p}${url.search}`);
+ if (p === '/__pid') return send(res, 200, { pid: process.pid, mock: 'hel903' });
+ if (p === '/__log') return send(res, 200, log.splice(0));
+ if (p === '/__uploads') return send(res, 200, uploads);
+ if (p === '/__reset') { reset(); return send(res, 200, { ok: true }); }
+ if (p === '/__status') { statusMode = url.searchParams.get('mode') || 'ok'; return send(res, 200, { statusMode }); }
+ if (p === '/__session-voice') { sessionVoice = url.searchParams.get('v') || ''; return send(res, 200, { sessionVoice }); }
+ if (p === '/__fail') { fail = Object.fromEntries(url.searchParams); return send(res, 200, fail); }
+ if (p === '/__select') { const v = url.searchParams.get('voice'); selection = { voice: v, label: custom.get(v)?.label || v, updated_at: Date.now() }; return send(res, 200, selection); }
+ if (p === '/__state') return send(res, 200, { selection, custom: [...custom.keys()], orphans: [...orphans], open: [...sessions.keys()], rooms });
+ if (p.startsWith('/vendor/livekit-client')) return send(res, 200, await readFile(fake), 'text/javascript');
+
+ if (p === '/api/health') return send(res, 200, { ok: true, epoch: 3, llm: { ok: true, model: 'mock' }, backend: { life: true, persona: true, receipt: true, web_connected: true }, life_runtime: { state: 'running', timezone: 'Asia/Shanghai', budget_warning: false, budget_stopped: false, last: null }, capabilities: { text: true, proactive: true, voice: false, memory: true } });
+ if (p === '/api/companion') return send(res, 200, identity);
+ if (p === '/api/messages') return send(res, 200, { messages: [{ id: 1, ts: T0, role: 'user', text: '今天下雨了吗?', turn_id: 't1', status: 'sent', lifeIds: [] }, { id: 2, ts: T0 + 6e4, role: 'companion', text: '下了一点,窗台上还湿着。', turn_id: 't1', status: 'sent', lifeIds: [] }], next_before: null, epoch: 3 });
+ if (p === '/api/persona') return send(res, 200, { state: 'ready', interpretation: null, seeds: [], growth: [] });
+ if (p === '/api/life' || p === '/api/memories') return send(res, 200, { events: [], memories: [], next_before: null, epoch: 3 });
+
+ if (p === '/api/voice/status') {
+ if (statusMode === 'none') return send(res, 404, { detail: 'Not Found' });
+ if (statusMode === '500') return send(res, 500, 'Internal Server Error', 'text/plain');
+ if (statusMode === 'net') { res.destroy(); return; }
+ if (statusMode === 'off') return send(res, 200, { configured: false, tts_ok: false, agent_name: null, voice: false });
+ return send(res, 200, { configured: true, tts_ok: statusMode === 'ok', agent_name: 'cybergril-voice-agent', voice: false, livekit_url: 'wss://lk.invalid' });
+ }
+ if (p === '/api/voice/session' && req.method === 'POST') {
+ if (statusMode !== 'ok') return send(res, 503, { detail: '她的声音服务这会儿不在,稍后再试试。' });
+ await sleep(120);
+ // As _capture_voice: a still-listed selection, else "default"; captured once, at opening.
+ const captured = selection.voice && known(selection.voice) ? selection.voice : 'default';
+ const id = `sess-${++rooms}`; sessions.set(id, captured);
+ const grant = { url: 'wss://lk.invalid', token: `token-${id}`, room: `cg-voice-${rooms}`, session_id: id, agent_name: 'cybergril-voice-agent', epoch: 3, expires_at: Date.now() + 2 * 3600e3, voice: sessionVoice && sessionVoice !== 'omit' ? sessionVoice : captured };
+ if (sessionVoice === 'omit') delete grant.voice;
+ return send(res, 201, grant);
+ }
+ if (p.startsWith('/api/voice/session/') && req.method === 'DELETE') { const id = decodeURIComponent(p.slice(19)); return sessions.delete(id) ? send(res, 200, { ended: true }) : send(res, 404, { detail: '这次通话已经不在了。' }); }
+
+ if (p === '/api/voice/voices' && req.method === 'GET') return send(res, 200, { configured: true, voices: listed().map(item), selection: { ...selection } });
+ if (p === '/api/voice/voices' && req.method === 'POST') {
+ const data = await body(req), q = url.searchParams, info = wavInfo(data), f = take('register');
+ uploads.push({ type: req.headers['content-type'], bytes: data.length, info, label: q.get('label'), prompt_text: q.get('prompt_text'), allow_asr: q.get('allow_asr'), fail: f || '' });
+ // The panel took it, then the local metadata write failed (HEL-902 §4: OSError → 500) or the answer was lost.
+ // `commitnet` cuts the answer mid-body (a reset before any byte would let Chromium silently resend the POST on a
+ // reused socket, which is not the case this card covers).
+ if (f === 'commit500' || f === 'commitnet') { const lost = newId(); orphans.add(lost); if (f === 'commitnet') { res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': '400' }); res.write(`{"id":"${lost}","la`); setTimeout(() => res.destroy(), 50); return; } return send(res, 500, 'Internal Server Error', 'text/plain'); }
+ if (f === 'net') { res.destroy(); return; }
+ if (f === '503') return send(res, 503, { detail: '音色登记服务这会儿不在,稍后再试试。' });
+ if (f === '500') return send(res, 500, 'Internal Server Error', 'text/plain');
+ if (!info) return send(res, 422, { detail: '只支持 WAV 格式的参考音频。' });
+ const text = (q.get('prompt_text') || '').trim(), asr = q.get('allow_asr') === 'true';
+ if (!text && !asr) return send(res, 422, { detail: '请填写这段音频的参考文字,或明确选择让系统转写(会用到语音识别)。' });
+ const id = newId(), label = (q.get('label') || '').trim() || id;
+ const record = { id, label, prompt_text: text || '(模拟转写)今天天气很好。', prompt_text_source: text ? 'user' : 'asr', prompt_wav: `/opt/voice-agent/cosyvoice/voice_samples/${id}.wav`, registered_at: Date.now() };
+ custom.set(id, record);
+ return send(res, 201, record);
+ }
+ if (p === '/api/voice/preview' && req.method === 'POST') {
+ const { voice } = JSON.parse((await body(req)).toString() || '{}');
+ if (!known(voice)) return send(res, 404, { detail: '这个声音现在不在列表里。' });
+ previewed.add(voice);
+ return send(res, 200, tone(), 'audio/wav');
+ }
+ if (p === '/api/voice/selection' && req.method === 'GET') return send(res, 200, { ...selection });
+ if (p === '/api/voice/selection' && req.method === 'POST') {
+ const { voice } = JSON.parse((await body(req)).toString() || '{}');
+ if (!known(voice)) return send(res, 404, { detail: '这个声音现在不在列表里。' });
+ if (voice.startsWith('cg-') && !previewed.has(voice)) return send(res, 409, { detail: '这个声音还没有试听过,先试听再给她用。' });
+ selection = { voice, label: custom.get(voice)?.label || voice, updated_at: Date.now() };
+ return send(res, 200, { ...selection });
+ }
+ if (p.startsWith('/api/')) return send(res, 404, { detail: 'Not Found' });
+ const file = normalize(join(dist, p === '/' ? 'index.html' : p));
+ if (!file.startsWith(normalize(dist))) return send(res, 403, 'no', 'text/plain');
+ try { send(res, 200, await readFile(file), { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }[extname(file)] || 'application/octet-stream'); }
+ catch { send(res, 200, await readFile(join(dist, 'index.html')), 'text/html; charset=utf-8'); }
+};
+createServer((req, res) => { handle(req, res).catch(e => { console.log(`client gave up: ${req.method} ${req.url} (${e.code || e.message})`); try { res.destroy(); } catch { /* gone */ } }); })
+ .on('error', e => { console.error(`hel903 mock could not listen on ${port}: ${e.code || e.message}`); process.exit(2); })
+ .listen(port, '127.0.0.1', () => console.log(`hel903 mock on ${port} pid ${process.pid}`));
diff --git a/web/tests/hel903_shots.mjs b/web/tests/hel903_shots.mjs
new file mode 100644
--- /dev/null
+++ b/web/tests/hel903_shots.mjs
@@ -0,0 +1,226 @@
+// HEL-903 browser check: 外接服务 voice rows from /api/voice/status, 本次通话声音 on the call line, and recovery after a
+// registration with no clear answer. Drives headless Edge over CDP against tests/hel903_mock.mjs, with
+// tests/fake-livekit.js in place of the LiveKit client and Chromium's fake audio device in place of a microphone (a beep,
+// not a person). No voice panel, ASR, TTS, LiveKit or real clone is involved.
+// Usage: node tests/hel903_shots.mjs <edge.exe> <outDir> [port] [before]. `before` only takes the comparison shots
+// (run it against a build of the code before this card; it asserts nothing about the new behaviour).
+import { spawn } from 'node:child_process';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+
+const [edge, out, portArg, mode] = process.argv.slice(2);
+const port = Number(portArg || 8903), base = `http://127.0.0.1:${port}`, cdpPort = 9503;
+mkdirSync(out, { recursive: true });
+const profile = join(tmpdir(), `hel903-edge-${Date.now()}`);
+const browser = spawn(edge, ['--headless=new', `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, '--no-first-run', '--hide-scrollbars', '--force-color-profile=srgb', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', 'about:blank'], { stdio: 'ignore' });
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+let targets;
+for (let i = 0; i < 60 && !targets; i++) { try { targets = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json(); } catch { await sleep(250); } }
+const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
+await new Promise(r => ws.addEventListener('open', r, { once: true }));
+let id = 0; const waiting = new Map();
+ws.addEventListener('message', ({ data }) => { const m = JSON.parse(data); if (m.id && waiting.has(m.id)) { const { ok, ko } = waiting.get(m.id); waiting.delete(m.id); m.error ? ko(new Error(m.error.message)) : ok(m.result); } });
+const cdp = (method, params = {}) => new Promise((ok, ko) => { const n = ++id; waiting.set(n, { ok, ko }); ws.send(JSON.stringify({ id: n, method, params })); });
+const js = async expr => { const r = await cdp('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'js error'); return r.result.value; };
+const results = [];
+const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail && !ok ? ` — ${String(detail).slice(0, 300)}` : ''}`); };
+const ctl = async path => (await fetch(`${base}${path}`)).json();
+const shot = async name => { const { data } = await cdp('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }); writeFileSync(join(out, `${name}.png`), Buffer.from(data, 'base64')); };
+const click = async (selector, label = '') => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.offsetParent !== null && e.textContent.includes(${JSON.stringify(label)})); if (!el) return false; el.scrollIntoView({ block: 'center' }); el.click(); return true; })()`);
+const exists = sel => js(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); return !!e && e.offsetParent !== null; })()`);
+const text = sel => js(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.offsetParent !== null).map(e => e.innerText).join('\\n')`);
+const scrollTo = sel => js(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); if (e) e.scrollIntoView({ block: 'center' }); return !!e; })()`);
+const until = async (fn, ms = 6000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; };
+const fill = (sel, value) => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(sel)})].find(e => e.offsetParent !== null); if (!el) return false; const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(value)}); el.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`);
+const views = { desktop: { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, phone: { width: 390, height: 844, deviceScaleFactor: 2, mobile: true } };
+async function view(v) { await cdp('Emulation.setDeviceMetricsOverride', views[v]); await cdp('Emulation.setTouchEmulationEnabled', { enabled: views[v].mobile, maxTouchPoints: 5 }); }
+const PROBE = `(() => { const md = navigator.mediaDevices; if (!md) return; const orig = md.getUserMedia.bind(md); window.__tracks = [];
+ md.getUserMedia = async c => { const s = await orig(c); s.getTracks().forEach(t => window.__tracks.push(t)); return s; };
+ window.__live = () => window.__tracks.filter(t => t.readyState === 'live').length; })();`;
+const live = () => js('window.__live ? window.__live() : 0');
+async function boot(theme, path = '/') {
+ await ctl('/__reset');
+ await cdp('Page.navigate', { url: `${base}${path}` }); await sleep(500);
+ await js(`localStorage.setItem('cg.theme', ${JSON.stringify(theme)}); localStorage.removeItem('cg.voice.owed'); sessionStorage.clear(); true`);
+ await cdp('Page.navigate', { url: `${base}${path}` }); await sleep(1500);
+}
+async function settings(section) { if (!(await exists('.pane-manage.open'))) { await click('.nav-item', '设置'); await sleep(700); } await click('.snav button', section); await sleep(400); }
+async function closeSettings() { await click('.pane-manage button[aria-label="关闭设置"]'); await sleep(500); if (await exists('.modal.open')) { await click('.modal.open button', '放弃修改并离开'); await sleep(400); } }
+const svc = label => js(`(() => { const r = [...document.querySelectorAll('.svc')].find(e => e.offsetParent !== null && e.querySelector('.nm')?.firstChild?.textContent === ${JSON.stringify(label)}); return r ? r.querySelector('.nm small').innerText : null; })()`);
+const wav = (seconds, rate = 22050) => {
+ const n = Math.round(seconds * rate), b = Buffer.alloc(44 + n * 2);
+ b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
+ b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
+ for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 11) * 5000), 44 + i * 2);
+ return b;
+};
+const files = join(tmpdir(), `hel903-files-${Date.now()}`); mkdirSync(files, { recursive: true });
+writeFileSync(join(files, 'reference.wav'), wav(6));
+async function upload() {
+ const { result } = await cdp('Runtime.evaluate', { expression: `[...document.querySelectorAll('.voice-sec input[type=file]')].find(e => e.offsetParent !== null)` });
+ await cdp('DOM.setFileInputFiles', { files: [join(files, 'reference.wav')], objectId: result.objectId });
+ return until(() => exists('.voice-sec .take .pill'));
+}
+async function takeReady(label) { await upload(); await fill('.voice-sec .take input.input', label); await fill('.voice-sec .take textarea', '今天天气很好,我们出去走走吧。'); }
+async function applyVoice(label) {
+ await takeReady(label);
+ await click('.voice-sec .take button', '登记这段声音');
+ await until(async () => (await text('.voice-sec .voice-list')).includes(label));
+ await click('.voice-sec .audition button', '生成试听'); await until(() => exists('.voice-sec .sample.ready'));
+ await click('.voice-sec .audition button', '给栖用这个声音');
+ return until(async () => (await text('.voice-sec .kv')).includes(label));
+}
+const phase = () => js(`document.querySelector('.voice-row.real')?.dataset.phase || 'off'`);
+const pin = () => js(`document.querySelector('.voice-row.real .voice-pin')?.innerText || ''`);
+async function startCall() {
+ await click('.voice-button'); await until(() => exists('.modal.open'));
+ await click('.modal.open .btn-primary', '开始语音');
+ await until(async () => (await phase()) === 'joining');
+ await until(() => js(`window.__lk?.rooms.at(-1)?.state === 'connected'`));
+ await js('window.__lk.join()'); await sleep(300);
+}
+async function endCall() { await click('.voice-row.real .voice-end', '结束语音'); await until(async () => (await phase()) === 'off'); await sleep(300); }
+async function talk() { await click('.nav-item', '交谈'); await sleep(1400); }
+
+await cdp('Page.enable'); await cdp('Runtime.enable'); await cdp('Network.enable'); await cdp('DOM.enable');
+await cdp('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
+check('mock is this hel903 mock', (await ctl('/__pid')).mock === 'hel903');
+
+if (mode === 'before') {
+ // The same three places before this card: services rows, the call line, a registration that failed after filing.
+ await view('desktop'); await boot('moon');
+ await settings('外接服务'); await sleep(600); await shot('before-01-services');
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list')); await applyVoice('轻一点的她');
+ await closeSettings(); await talk(); await startCall(); await shot('before-02-call'); await endCall();
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+ await takeReady('第二个'); await ctl('/__fail?register=commit500'); await click('.voice-sec .take button', '登记这段声音'); await sleep(1200); await scrollTo('.voice-sec .take'); await shot('before-03-register-500');
+ browser.kill(); process.exit(0);
+}
+
+// ── Main path, both themes × desktop/phone ──
+for (const [theme, v] of [['moon', 'desktop'], ['fog', 'desktop'], ['moon', 'phone'], ['fog', 'phone']]) {
+ const tag = `${theme}-${v}`;
+ await view(v); await boot(theme);
+ // 1. 外接服务: read from /api/voice/status, never "尚未接通" and never "已接通" for voice.
+ await settings('外接服务');
+ await until(async () => (await svc('语音合成')) && !(await svc('语音合成')).includes('正在读取'));
+ const tts = await svc('语音合成'), asr = await svc('语音识别');
+ check(`${tag}: services — TTS row says configured + health probe reachable`, tts === '已配置 · 合成服务健康检查可连接', tts);
+ check(`${tag}: services — ASR row claims no probe it does not have`, asr.includes('没有单独的健康检查'), asr);
+ check(`${tag}: services — no voice row says "尚未接通" or "已接通"`, !/尚未接通|已接通/u.test(`${tts}${asr}`));
+ check(`${tag}: services — reading status opens no microphone`, (await live()) === 0 && (await js('window.__tracks.length')) === 0);
+ await scrollTo('.svc'); await shot(`${tag}-01-services`);
+
+ // 2. Apply a voice, then call: the call line shows the voice the session answer pinned.
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+ check(`${tag}: a voice is applied through the existing flow`, await applyVoice('轻一点的她'));
+ check(`${tag}: settings still says a change takes effect at the next call`, (await text('.voice-sec .kv')).includes('下一次通话'));
+ await closeSettings(); await talk();
+ await startCall();
+ check(`${tag}: call line shows 本次通话 voice from the session answer`, (await pin()).includes('轻一点的她'), await pin());
+ // 3. Another device applies a different voice mid-call: this call keeps its own.
+ await ctl('/__select?voice=rain');
+ await js(`window.__lk.say({ kind: 'interim', role: 'user', text: '你听得到吗' })`); await sleep(300);
+ check(`${tag}: subtitles still show beside it`, (await text('.voice-row.real')).includes('你听得到吗'));
+ check(`${tag}: a choice made during the call does not change this call`, (await pin()).includes('轻一点的她'), await pin());
+ await shot(`${tag}-02-call-pinned`);
+ await endCall();
+ check(`${tag}: ending the call releases the microphone and drops the pin`, (await live()) === 0 && !(await exists('.voice-row.real .voice-pin')));
+ // 4. The next call takes the new choice.
+ await startCall();
+ check(`${tag}: the next call shows the new voice`, (await pin()).includes('rain'), await pin());
+ await shot(`${tag}-03-next-call`);
+ await endCall();
+
+ // 5. A registration filed by the panel whose answer was lost: say so, offer the list, find the numbered entry.
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+ await takeReady('第二个');
+ await ctl('/__fail?register=commit500');
+ await click('.voice-sec .take button', '登记这段声音');
+ check(`${tag}: 500 after filing — says it may be registered and to refresh first`, await until(async () => (await text('.voice-sec .status')).includes('可能已经登记上了')) && (await text('.voice-sec .status')).includes('刷新列表'), await text('.voice-sec .status'));
+ check(`${tag}: offers 刷新列表 and words a second registration as a checked choice`, (await exists('.voice-sec .take')) && (await text('.voice-sec .take .take-acts')).includes('刷新列表') && (await text('.voice-sec .take .take-acts')).includes('确认没有,再登记'));
+ check(`${tag}: the list is not re-read or re-registered behind your back`, (await ctl('/__uploads')).length === 2);
+ await scrollTo('.voice-sec .take .take-acts'); await shot(`${tag}-04-register-unsure`);
+ await click('.voice-sec .take button', '刷新列表');
+ check(`${tag}: refreshing names the new numbered entry`, await until(async () => /列表里多了「cg-/u.test(await text('.voice-sec .status'))), await text('.voice-sec .status'));
+ check(`${tag}: the numbered entry is marked as missing local metadata`, (await text('.voice-sec .voice-list')).includes('只有编号,缺本地资料') && (await exists('.voice-sec .voice-idonly')));
+ check(`${tag}: after it is found the cautious button goes away`, !(await text('.voice-sec .take .take-acts')).includes('刷新列表'));
+ await scrollTo('.voice-sec .voice-idonly'); await shot(`${tag}-05-found-numbered`);
+ await closeSettings();
+}
+
+// ── Branches, desktop moon unless noted ──
+await view('desktop'); await boot('moon');
+for (const [m, tts, asr, name] of [
+ ['down', '已配置 · 合成服务这会儿暂不可用', '没有单独的健康检查', 'down'],
+ ['off', '未配置 · 这台服务没有语音配置', '未配置', 'off'],
+ ['none', '这台服务没有语音状态接口', '这台服务没有语音状态接口', 'no-route'],
+ ['500', '状态未知 · 这次没读到语音状态', '状态未知', 'unknown-500'],
+ ['net', '状态未知 · 这次没读到语音状态', '状态未知', 'unknown-net'],
+]) {
+ await ctl(`/__status?mode=${m}`);
+ await settings('外接服务'); await click('.svc button', '重新读取状态');
+ await until(async () => !((await svc('语音合成')) || '').includes('正在读取'));
+ const t = await svc('语音合成'), a = await svc('语音识别');
+ check(`status ${name}: TTS "${tts}"`, t === tts, t);
+ check(`status ${name}: ASR row honest`, a.includes(asr), a);
+ if (m === 'down' || m === 'off') { await scrollTo('.svc'); await shot(`branch-services-${name}`); }
+}
+await ctl('/__status?mode=ok');
+await view('phone'); await boot('fog'); await ctl('/__status?mode=down'); await settings('外接服务'); await until(async () => (await svc('语音合成')) === '已配置 · 合成服务这会儿暂不可用'); await scrollTo('.svc'); await shot('branch-services-down-fog-phone'); await ctl('/__status?mode=ok');
+await view('desktop'); await boot('moon');
+
+// The session answer without a voice field (an older server): shown as not said, never guessed from settings.
+await settings('她的声音'); await until(() => exists('.voice-sec .voice-list')); await applyVoice('轻一点的她');
+await ctl('/__session-voice?v=omit'); await closeSettings(); await talk(); await startCall();
+check('session without voice: "声音未说明", not the settings choice', (await pin()).includes('声音未说明') && !(await pin()).includes('轻一点的她'), await pin());
+await shot('branch-call-voice-unknown');
+await endCall();
+await ctl('/__session-voice?v=default'); await startCall();
+check('session voice "default": shown as 默认声音', (await pin()).includes('默认声音'), await pin());
+await endCall(); await ctl('/__session-voice');
+
+// A dropped answer after filing; a 503 before filing (refresh finds nothing; a checked retry then succeeds).
+await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+await takeReady('断线那次'); await ctl('/__fail?register=commitnet');
+await click('.voice-sec .take button', '登记这段声音');
+check('dropped answer: may be registered, refresh first', await until(async () => (await text('.voice-sec .status')).includes('可能已经登记上了')), await text('.voice-sec .status'));
+await click('.voice-sec .take button', '刷新列表');
+check('dropped answer: refresh finds the numbered entry', await until(async () => /列表里多了「cg-/u.test(await text('.voice-sec .status'))));
+await click('.voice-sec .take button', '换一段');
+await takeReady('面板不在'); await ctl('/__fail?register=503');
+await click('.voice-sec .take button', '登记这段声音');
+check('503: keeps the server words and adds the refresh advice', await until(async () => (await text('.voice-sec .status')).includes('音色登记服务')) && (await text('.voice-sec .status')).includes('刷新列表'), await text('.voice-sec .status'));
+await click('.voice-sec .take button', '刷新列表');
+check('503: refresh finds nothing new and says to wait before registering again', await until(async () => (await text('.voice-sec .status')).includes('暂时没有新出现的条目')), await text('.voice-sec .status'));
+await shot('branch-register-503-none');
+const before = (await ctl('/__uploads')).length;
+await click('.voice-sec .take button', '确认没有,再登记');
+check('503: the checked retry registers once and the take is gone', await until(async () => (await text('.voice-sec .voice-list')).includes('面板不在')) && (await ctl('/__uploads')).length === before + 1 && !(await exists('.voice-sec .take')));
+// Recording regression: the microphone opens on a tap and closes on stop / cancel.
+await click('.voice-sec .seg button', '现在录一段');
+await click('.voice-sec .rec button', '开始录音');
+await until(async () => (await text('.voice-sec .rec')).includes('正在录音'));
+await sleep(2600);
+check('recording: microphone live while recording', (await live()) === 1);
+await click('.voice-sec .rec button', '停止');
+check('recording: stop keeps the take and releases the microphone', await until(() => exists('.voice-sec .take .pill'), 8000) && (await live()) === 0);
+await click('.voice-sec .take button', '换一段');
+await click('.voice-sec .rec button', '开始录音'); await until(async () => (await text('.voice-sec .rec')).includes('正在录音'));
+await click('.voice-sec .rec button', '取消');
+check('recording: cancel releases the microphone', (await live()) === 0);
+await closeSettings();
+
+// Demo entry: nothing goes to any server from settings (services, her voice) or the conversation.
+await boot('moon', '/?demo=1'); await ctl('/__log');
+await settings('外接服务'); await sleep(500); await settings('她的声音'); await sleep(500); await closeSettings();
+await click('.nav-item', '交谈'); await sleep(1200);
+const demoLog = await ctl('/__log');
+check('demo: zero /api requests from settings and the conversation', demoLog.length === 0, demoLog.join(' | '));
+await settings('外接服务'); await shot('demo-services');
+
+const passed = results.filter(Boolean).length;
+console.log(`\n${passed}/${results.length} passed`);
+browser.kill();
+process.exit(passed === results.length ? 0 : 1);
diff --git a/web/tests/hel903_voice_rework.test.ts b/web/tests/hel903_voice_rework.test.ts
new file mode 100644
--- /dev/null
+++ b/web/tests/hel903_voice_rework.test.ts
@@ -0,0 +1,122 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { callVoiceOf, readVoiceStatus, VoiceController, type MicTrack, type RoomEvents, type VoiceRoom, type VoiceView } from '../src/voice/session';
+import { callVoiceName, idOnly, registerVoice, SAY, VoiceError, type CustomVoice } from '../src/voice/custom';
+
+// HEL-903 limited rework, offline only: fake fetch, fake room and microphone. No LiveKit, voice panel, ASR or TTS.
+const gate = () => { let open!: () => void; const promise = new Promise<void>(r => { open = r; }); return { promise, open }; };
+const flush = async (n = 8) => { for (let i = 0; i < n; i++) await Promise.resolve(); await new Promise(r => setTimeout(r, 0)); };
+const signal = () => new AbortController().signal;
+const reply = (body: unknown, status = 200) => new Response(typeof body === 'string' ? body : JSON.stringify(body), { status });
+afterEach(() => { vi.unstubAllGlobals(); });
+
+describe('外接服务 reads only what /api/voice/status can tell', () => {
+ const kind = (answer: () => Response | Promise<Response>) => readVoiceStatus(signal(), (async () => answer()) as unknown as typeof fetch);
+ it('tells not configured, reachable, down, no route and unreadable apart', async () => {
+ expect(await kind(() => reply({ configured: false, tts_ok: false, agent_name: null, voice: false }))).toBe('off');
+ expect(await kind(() => reply({ configured: true, tts_ok: true, voice: false }))).toBe('ok');
+ expect(await kind(() => reply({ configured: true, tts_ok: false, voice: false }))).toBe('down');
+ expect(await kind(() => reply({ detail: 'Not Found' }, 404))).toBe('none');
+ expect(await kind(() => reply('oops', 500))).toBe('unknown');
+ expect(await kind(() => reply('not json'))).toBe('unknown');
+ expect(await kind(() => { throw new TypeError('Failed to fetch'); })).toBe('unknown');
+ });
+ it('a configured flag alone is never "reachable"', async () => {
+ expect(await kind(() => reply({ configured: true }))).toBe('down');
+ });
+});
+
+// ── The call's own voice ──
+class Mic implements MicTrack { stopped = 0; stop() { this.stopped++; } onEnded() {} }
+class Room implements VoiceRoom { closed = 0; on!: RoomEvents; async publish() {} hush() {} async resumeAudio() {} async close() { this.closed++; } }
+function rig() {
+ const views: VoiceView[] = [], rooms: Room[] = [], voices: unknown[] = [], gates: (ReturnType<typeof gate> | undefined)[] = [];
+ let made = 0;
+ const fetch = vi.fn(async (url: string, init: RequestInit = {}) => {
+ if (url === '/api/voice/status') return reply({ configured: true, tts_ok: true, voice: false });
+ if (url === '/api/voice/session' && init.method === 'POST') {
+ const n = made++, g = gates[n]; if (g) await g.promise;
+ const grant: Record<string, unknown> = { url: 'wss://lk.test', token: `t${n}`, room: `r${n}`, session_id: `s${n}`, epoch: 3, expires_at: 0 };
+ if (voices[n] !== undefined) grant.voice = voices[n];
+ return reply(grant, 201);
+ }
+ return reply({ ended: true });
+ });
+ const transport = { capture: async () => new Mic(), join: async (_u: string, _t: string, on: RoomEvents) => { const r = new Room(); r.on = on; rooms.push(r); return r; } };
+ const c = new VoiceController({ transport: async () => transport, onView: v => views.push(v), onRecord: () => {}, onSpoke: () => {}, fetch: fetch as unknown as typeof fetch, secure: () => true, agentWaitMs: 1000 });
+ return { c, views, rooms, voices, gates, view: () => views.at(-1)! };
+}
+
+describe('本次通话声音 comes from the session answer and stays for the call', () => {
+ it('shown from the granted room, unchanged through the call, gone when it ends', async () => {
+ const r = rig(); r.voices[0] = 'cg-1a2b3c4d';
+ await r.c.start();
+ expect(r.views.find(v => v.phase === 'opening')?.voice).toBeNull();
+ expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-1a2b3c4d' } });
+ r.rooms[0].on.agentJoined(); r.rooms[0].on.agentState('speaking'); r.rooms[0].on.agentState('listening');
+ expect(r.views.filter(v => v.phase !== 'opening').every(v => v.voice?.id === 'cg-1a2b3c4d')).toBe(true);
+ await r.c.end();
+ expect(r.view()).toMatchObject({ phase: 'off', voice: null });
+ });
+ it('a missing or odd field is "not said", never a guess', () => {
+ expect(callVoiceOf({})).toEqual({ id: null });
+ expect(callVoiceOf({ voice: '' })).toEqual({ id: null });
+ expect(callVoiceOf({ voice: 42 })).toEqual({ id: null });
+ expect(callVoiceOf({ voice: '<b>x</b>' })).toEqual({ id: null });
+ expect(callVoiceOf({ voice: 'default' })).toEqual({ id: 'default' });
+ });
+ it('an older call answering late never overwrites the newer call', async () => {
+ const r = rig(); r.voices[0] = 'old-voice'; r.voices[1] = 'cg-00000002'; r.gates[0] = gate();
+ const first = r.c.start(); await flush();
+ await r.c.start();
+ expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-00000002' } });
+ r.gates[0]!.open(); await first; await flush();
+ expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-00000002' } });
+ expect(r.views.some(v => v.voice?.id === 'old-voice')).toBe(false);
+ });
+ it('a failure after the room clears the voice with the call', async () => {
+ const r = rig(); r.voices[0] = 'rain';
+ await r.c.start(); r.rooms[0].on.agentJoined();
+ r.rooms[0].on.dropped(); await flush();
+ expect(r.view()).toMatchObject({ phase: 'off', voice: null });
+ });
+ it('names: its own label for that id only, the id otherwise, default and unknown in words', () => {
+ expect(callVoiceName('cg-1', { id: 'cg-1', label: '轻一点的她' })).toBe('轻一点的她');
+ expect(callVoiceName('cg-2', { id: 'cg-1', label: '轻一点的她' })).toBe('cg-2');
+ expect(callVoiceName('default', null)).toBe('默认声音');
+ expect(callVoiceName(null, null)).toBe('声音未说明');
+ });
+});
+
+describe('a registration with no clear answer says to look before registering again', () => {
+ const wav = new Blob([new Uint8Array(64)], { type: 'audio/wav' }), form = { label: 'x', text: '你好', allowAsr: false };
+ const attempt = async (answer: () => Response | Promise<Response>) => {
+ try { await registerVoice(wav, form, signal(), (async () => answer()) as unknown as typeof fetch); } catch (e) { return e as VoiceError; }
+ throw new Error('expected a failure');
+ };
+ it('500 / 503 / a dropped answer / an unreadable success may have been filed', async () => {
+ for (const e of [await attempt(() => reply({ detail: 'Internal Server Error' }, 500)), await attempt(() => reply({ detail: '音色登记服务这会儿不在,稍后再试试。' }, 503)), await attempt(() => { throw new TypeError('network'); }), await attempt(() => reply('', 201))]) {
+ expect(e).toBeInstanceOf(VoiceError);
+ expect(e.uncertain).toBe(true);
+ }
+ expect((await attempt(() => reply({ detail: '音色登记服务这会儿不在,稍后再试试。' }, 503))).message).toContain('音色登记服务');
+ });
+ it('a clear refusal (4xx) or a request never sent is not "maybe filed"', async () => {
+ expect((await attempt(() => reply({ detail: '参考音频太短(至少 2 秒),请录 5–20 秒。' }, 422))).uncertain).toBe(false);
+ expect((await attempt(() => reply({ detail: '还没有创建真实伴侣,语音也不会开始。' }, 409))).uncertain).toBe(false);
+ vi.stubGlobal('navigator', { onLine: false });
+ const offline = await attempt(() => reply({}, 201));
+ expect(offline.message).toBe(SAY.offline);
+ expect(offline.uncertain).toBe(false);
+ });
+ it('the advice points at refreshing the list and the numbered entries', () => {
+ expect(SAY.maybeRegistered).toContain('刷新列表');
+ expect(SAY.maybeRegistered).toContain('cg-');
+ expect(SAY.maybeRegistered).toContain('不用再登记');
+ });
+ it('an entry without local metadata is recognised as id-only', () => {
+ const v = (x: Partial<CustomVoice>): CustomVoice => ({ id: 'cg-9', label: 'cg-9', source: 'custom', selected: false, previewed: false, registered_at: null, ...x });
+ expect(idOnly(v({}))).toBe(true);
+ expect(idOnly(v({ label: '轻一点的她', registered_at: 1 }))).toBe(false);
+ expect(idOnly(v({ id: 'rain', label: 'rain', source: 'builtin' }))).toBe(false);
+ });
+});
@@ -0,0 +1,932 @@
diff --git a/docs/requirements/handoff-HEL-903.json b/docs/requirements/handoff-HEL-903.json
new file mode 100644
index 0000000000000000000000000000000000000000..a99a4c9329cb34030c48cd9b2138b8b4a0fd25d5
--- /dev/null
+++ b/docs/requirements/handoff-HEL-903.json
@@ -0,0 +1,57 @@
+{
+ "baseline_version": "1.12",
+ "read_commit": "b09a907747f9836c4aea04016757321d28b7cb6b",
+ "task": "HEL-903:HEL-902 统一审核后的原作者有限返修(只改 web/ 与本卡交接/证据)——外接服务语音两行按 /api/voice/status 如实区分状态;通话栏显示 session 响应 voice 固定的「本次通话声音」;登记后无明确结果(500/503/响应断连)时提示先刷新列表看编号条目,列表标出缺本地元数据的 cg- 条目。基于 origin/hel-902-integration @ b09a907,保留 HEL-879 真实窗口修复与 HEL-900 作者源码,不推写统一母线、不回退 ce7279e,零真实调用",
+ "requirement_ids": [
+ "CG-006",
+ "CG-009",
+ "CG-010",
+ "CG-013",
+ "CG-014",
+ "CG-015",
+ "CG-016",
+ "CG-018",
+ "CG-019",
+ "CG-020",
+ "CG-021",
+ "VIS-003",
+ "VIS-004",
+ "VIS-008",
+ "VIS-010",
+ "VIS-013",
+ "UX-001",
+ "UX-003",
+ "UX-005",
+ "UX-006",
+ "AM-001",
+ "GOV-001",
+ "GOV-002",
+ "GOV-003",
+ "GOV-004",
+ "GOV-005",
+ "GOV-006",
+ "DEL-001"
+ ],
+ "deliverable": "web/src/voice/session.ts:readVoiceStatus 把 GET /api/voice/status 分成 none(无接口)/off(未配置)/ok(已配置且合成健康检查通过)/down(已配置但健康检查未过)/unknown(读不到)五态,voiceStatus 改为由它派生、行为不变;VoiceView 新增 voice(本次通话固定声音,开房前 null,服务器未给或不合规为 {id:null}),只在拿到本代 grant 时写入,结束/失败/迟到旧请求一律随 view 复位;callVoiceOf 只接受列表可容纳的 id。web/src/chat/ChatPanel.tsx:在既有通话条加「本次通话 · 声音名」(default→默认声音、缺字段→声音未说明,名字只按该 id 查列表,查不到显示编号,不用设置里的当前选择),title 写明到本次结束不变、设置里的新选择下一次通话生效。web/src/manage/ManagePanel.tsx:外接服务的语音识别/语音合成两行改为进入分区及点「重新读取状态」时读 /api/voice/status(仅真实入口),按五态如实说明,识别一行写明没有单独健康检查;配置框、按钮、说明卡与文字检查提示随之更正,视觉观察仍写尚未接通。web/src/voice/custom.ts + VoiceSection.tsx:VoiceError 增加 uncertain(已发出后断连、5xx、成功响应体读不全),登记遇到时提示「可能已经登记上了,先刷新列表看 cg- 编号条目,不用再登记」,提供「刷新列表」并把再登记改为「确认没有,再登记」;刷新后对比失败时的自定义列表,指出新出现条目或说明暂无新条目需稍等;列表把无本地元数据的 cg- 条目标「只有编号,缺本地资料」并加说明。4xx 与离线未发出仍为原明确提示。不新增删除入口、不自动删声音。components.css 追加通话条声音标签、状态行配色、编号条目说明与手机按钮换行(只用既有 token,两主题)。测试:tests/hel903_voice_rework.test.ts(11 例)、tests/hel903_mock.mjs、tests/hel903_shots.mjs;tests/hel898_agent_left.test.ts 期望视图补 voice:null。",
+ "acceptance": [
+ "外接服务:语音合成在 ok/down/off/none/unknown(500)/unknown(断连) 下分别显示「已配置 · 合成服务健康检查可连接」「已配置 · 合成服务这会儿暂不可用」「未配置 · 这台服务没有语音配置」「这台服务没有语音状态接口」「状态未知 · 这次没读到语音状态」;语音识别不声称有自己的健康检查;任一状态下语音两行都不出现「尚未接通」或「已接通」;读取状态不申请麦克风;说明写明「可连接」不代表整套语音通话已验收",
+ "通话:开房后通话条显示 session 响应 voice 对应的「本次通话 · 名称」;通话中另一端改选声音,本次通话显示不变;挂断后标签消失、麦克风释放;下一次通话显示新选择;响应缺 voice 字段显示「声音未说明」而非设置当前选择;default 显示「默认声音」;更早一次通话迟到的 grant 不覆盖新通话(单测)",
+ "登记:服务器已收下却回 500、或响应中途断开、或 503 时,提示可能已登记并先刷新列表;不自动重读、不自动再登记(上传计数验证);点刷新列表后能指出新出现的 cg- 编号条目并标「只有编号,缺本地资料」;若无新条目则说明稍等再刷、确认没有再登记,确认后再登记一次只发一次;参考声音与已填内容在失败后保留",
+ "回归:既有上传→登记→试听 canplay→应用流程、换声音只换发声的说明、录音点击开启/停止保留/取消释放麦克风、通话字幕与结束清理均保持;演示入口打开外接服务、她的声音与交谈零 /api 请求"
+ ],
+ "deferred": [
+ "后台新错误映射(本地元数据落盘失败 OSError→503/422 中文文案等,HEL-902 §5-2)由另卡处理;本卡先兼容既有 500/503,后续统一整合时按合同补核前端文案",
+ "语音识别没有独立健康状态接口,外接服务只能如实写「没有单独的健康检查」;如需显示识别服务真实状态需后台另行提供",
+ "编号条目的删除/回收(经 8090 面板自有删除)、自定义音色删除与完整备份恢复(CG-009/R6)继续由后续卡承接,本卡不新增删除入口",
+ "_capture_voice→worker 读文件的秒级竞态(HEL-902 §5-6)属后台;前端显示的是服务器声明的本次捕获值",
+ "真实 CosyVoice/FunASR/LiveKit、真机(iPhone/Android/Safari)与 R5 整体验收、R1–R7 完整功能继续承接;HEL-901 仍停放;本卡不部署、不合并"
+ ],
+ "requirement_changes": [],
+ "verification": [
+ "web(复用原作者 HEL-900 工作区已存在的同版 node_modules,package-lock SHA256 F950DE58…7A27 一致,经目录联接使用,零下载安装):tsc --noEmit 0 错;eslint src 0 错 1 既有警告(ChatPanel fullButton,非本卡);vitest 148/148(基线 137 + 本卡 11);vite build 通过",
+ "浏览器:node tests/hel903_mock.mjs 8903 + node tests/hel903_shots.mjs <msedge.exe> <outDir> 8903,headless Edge + 假 LiveKit(tests/fake-livekit.js)+ Chromium 假音频设备,90/90 通过:月光/雾光 × 电脑 1440×900/手机 390×844 各 18 项(外接服务、应用声音、本次通话声音固定、下一通更新、字幕、挂断释放、登记 500 后恢复),状态五分支、缺 voice 字段/default、响应断连与 503 恢复、录音释放、演示零请求",
+ "对照截图:同一 mock 跑 b09a907 原样构建(产物 index-C8UHROe1.js 与 HEL-900 作者构建同名)before 模式三张:外接服务、通话条、登记 500",
+ "需求检查:python -I scripts/check_requirements.py 与 --handoff docs/requirements/handoff-HEL-903.json 本机执行;本运行环境无 git,--base-ref origin/main 与 --base-ref b09a907 均无法执行,改以与 b09a907 源码包逐文件 SHA256 比对确认 docs/requirements 除新增本交接文件外零改动,--base-ref 四组检查交总管在仓库补跑",
+ "未验证如实声明:假麦克风是浏览器内置提示音;假 LiveKit 不是 WebRTC;试听是 mock 音调不是 TTS;未接真实 8090/9880/FunASR/LiveKit,未部署,未写共享音色,未访问 4080;Safari 与真机未测"
+ ]
+}
diff --git a/web/src/chat/ChatPanel.tsx b/web/src/chat/ChatPanel.tsx
index 8267924ec7860eb410bc52b16744f0fc16599ed5..84659d15d1e0bd8452815806bc38e7d0492673d3 100644
--- a/web/src/chat/ChatPanel.tsx
+++ b/web/src/chat/ChatPanel.tsx
@@ -11,6 +11,7 @@ import { motionOff } from '../stage/roomScene';
import { LineStage, type PresentHow } from './lineStage';
import { anchorOf, cursorAt, groupTurns, pagesOf, ReadingClock, type Anchor, type Metrics, type ShownMessage } from './reading';
import { mergeTurns, OFF, recentRows, VoiceController, voiceLine, voiceStatus, WORDS, type VoiceView } from '../voice/session';
+import { callVoiceName, listVoices } from '../voice/custom';
import { loadTransport } from '../voice/livekit';
import { newestId, serverId } from '../adapters/inbox';
@@ -89,6 +90,17 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
voiceStatus(c.signal).then(s => setVoiceOff(s.available ? '' : s.reason), () => {});
return () => c.abort();
}, [real]);
+ // The voice this call was opened with (HEL-903). Its name is looked up by that id only — never the choice settings
+ // hold now, which is the next call's — and an answer for another id (an older call) is never shown.
+ const pinned = real ? voiceView.voice : null, [named, setNamed] = useState<{ id: string; label: string } | null>(null);
+ useEffect(() => {
+ const id = pinned?.id; if (!id || id === 'default' || named?.id === id) return;
+ const c = new AbortController();
+ listVoices(c.signal).then(list => { const v = !c.signal.aborted && list.configured ? list.voices.find(x => x.id === id) : undefined; if (v) setNamed({ id, label: v.label }); }, () => { /* The id stays on show. */ });
+ return () => c.abort();
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- one lookup per pinned id.
+ }, [pinned?.id]);
+ const pinnedName = pinned && callVoiceName(pinned.id, named);
const voiceOn = real ? voiceView.phase !== 'off' : voice;
const voiceLabel = { off: '', opening: '正在接通…', joining: `在等${name}接起…`, listening: '在听你说', thinking: `${name}在想`, speaking: `${name}在说 · 开口就能打断` }[voiceView.phase];
useEffect(() => {
@@ -334,6 +346,7 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
{real && voiceOn && <div className="voice-row real" data-phase={voiceView.phase} role="status" aria-live="polite">
<span className="voice-wave" aria-hidden="true"><i/><i/><i/><i/></span>
<span className="voice-state">{voiceLabel}</span>
+ {pinned && pinnedName && <span className={`voice-pin ${pinned.id ? '' : 'unknown'}`} title={pinned.id ? `本次通话用「${pinnedName}」,到这次通话结束都不变;设置里换的声音下一次通话才生效。` : '服务器这次没有说明用的是哪个声音,这里不猜。设置里换的声音下一次通话才生效。'}><small>本次<span className="vp-wide">通话</span></small>{pinnedName}</span>}
{voiceView.spoken ? <span className="voice-heard said" title={voiceView.spoken}>{name}:{voiceView.spoken}<em>未记入聊天</em></span> : voiceView.heard && <span className="voice-heard" title={voiceView.heard}>「{voiceView.heard}」</span>}
{voiceView.blocked && <button onClick={() => void voiceCtl.current?.resumeAudio()}>点一下听{name}的声音</button>}
<button className="voice-end" onClick={() => { void voiceCtl.current?.end(); setNotice('语音已结束,麦克风已关闭。'); }}>结束语音</button>
diff --git a/web/src/manage/ManagePanel.tsx b/web/src/manage/ManagePanel.tsx
index 4f7b0092b35ec2c6b30ced395c684a9fc1cc0f24..78aee2f15f8d2b990f64926092b66a41e15adc68 100644
--- a/web/src/manage/ManagePanel.tsx
+++ b/web/src/manage/ManagePanel.tsx
@@ -5,12 +5,21 @@ import { delay } from '../adapters/demo';
import { exportDemo, parseState } from '../adapters/storage';
import { BaseColor, Button, Field, Icon, Modal, Status } from '../shared/ui';
import { VoiceSection, type VoiceCap } from './VoiceSection';
+import { readVoiceStatus, type VoiceStatusKind } from '../voice/session';
const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['voice', 'mic', '她的声音'], ['city', 'pin', '城市'], ['appearance', 'image', '外观素材'], ['backup', 'box', '本地演示导出'], ['services', 'plug', '外接服务'], ['display', 'moon', '显示']];
// What each section can actually do here (HEL-815 capability tags): read-only, adjustable, demo only, not connected, this device.
type Cap = 'ro' | 'edit' | 'demo' | 'off' | 'live' | 'local';
const CAP: Record<Cap, [string, string]> = { ro: ['只读', ''], edit: ['可以调整', 'accent'], demo: ['演示数据', 'demo'], off: ['未接入', 'off'], live: ['文字已接通', 'ok'], local: ['本机生效', 'ok'] };
const capOf = (id: string, real: boolean, voice: VoiceCap = 'unknown'): Cap => id === 'her' ? 'ro' : id === 'relation' ? 'edit' : id === 'voice' ? real ? voice === 'off' ? 'off' : 'edit' : 'demo' : id === 'display' ? 'local' : id === 'services' && real ? 'live' : real ? 'off' : 'demo';
const NOTE: Record<string, string> = { her: '只读资料', relation: '可以调整', voice: '上传 · 录音 · 试听', display: '本机生效' };
+// 外接服务 · voice rows (HEL-903): exactly what GET /api/voice/status can tell — no route, not configured, configured with
+// the TTS health probe passing or failing, or unreadable. Recognition has no probe of its own there, so it never claims one.
+type VoiceSvc = VoiceStatusKind | 'loading';
+const VOICE_SVC: Record<'asr' | 'tts' | 'config', Record<VoiceSvc, string>> = {
+ tts: { loading: '正在读取语音状态…', none: '这台服务没有语音状态接口', off: '未配置 · 这台服务没有语音配置', ok: '已配置 · 合成服务健康检查可连接', down: '已配置 · 合成服务这会儿暂不可用', unknown: '状态未知 · 这次没读到语音状态' },
+ asr: { loading: '正在读取语音状态…', none: '这台服务没有语音状态接口', off: '未配置 · 这台服务没有语音配置', ok: '已配置 · 没有单独的健康检查,以实际通话为准', down: '已配置 · 没有单独的健康检查,以实际通话为准', unknown: '状态未知 · 这次没读到语音状态' },
+ config: { loading: '读取中', none: '服务尚未配置', off: '服务尚未配置', ok: '由服务器管理', down: '由服务器管理', unknown: '暂时读不到' },
+};
function CapTag({ cap }: { cap: Cap }) { const [text, tone] = CAP[cap]; return <span className={`tag cap ${tone}`}>{text}</span>; }
// Honest placeholder for a capability that is not connected: what it will do once connected, and that nothing happens now.
function Off({ title, will, now }: { title: string; will: string; now: string }) { return <div className="off-card"><p className="off-t">{title}</p><p>{will}</p><p className="d-dim">{now}</p></div>; }
@@ -33,6 +42,14 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
const approvedNavigation = useRef(false);
const pendingNavigation = useRef<(() => void) | null>(null);
const [serviceHealth, setServiceHealth] = useState<Health>();
+ // Read on opening 外接服务 and on an explicit re-read; real only (the demo never asks a server).
+ const [voiceSvc, setVoiceSvc] = useState<VoiceSvc>('loading'), [voiceSvcRead, setVoiceSvcRead] = useState(0);
+ useEffect(() => {
+ if (!real || !open || section !== 'services') return;
+ const c = new AbortController(); setVoiceSvc('loading');
+ readVoiceStatus(c.signal).then(kind => { if (!c.signal.aborted) setVoiceSvc(kind); }, () => { /* Aborted: left the section. */ });
+ return () => c.abort();
+ }, [real, open, section, voiceSvcRead]);
// 她的声音 (HEL-900): an unregistered reference counts as unsaved; the tag follows what the server reports.
const [voiceDirty, setVoiceDirty] = useState(false), [voiceCap, setVoiceCap] = useState<VoiceCap>('unknown');
const request = useRef<AbortController | null>(null), generation = useRef(0), feedback = useRef<HTMLDivElement>(null);
@@ -56,7 +73,7 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
}, [open, anyDirty]);
async function checkService() {
setBusy(true); setError(false); setMessage('正在检查文字服务…');
- try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。语音、视觉和长期记忆仍未接通。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
+ try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。视觉观察和长期记忆仍未接通;语音状态以上面两行为准。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
catch (e) { setMessage((e as Error).message); setError(true); }
finally { setBusy(false); }
}
@@ -187,7 +204,7 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
{real && section === 'city' && <><Off title="城市与天气还没有接上。" will="接上以后,你选一个真实城市,她房间里的天气、白天黑夜和作息都跟着那里走;换城市不需要每次重新定位。" now="现在窗外的雨是界面氛围,不是真实天气。尚未设置可核实的城市,天气来源尚未确定。"/><Field label="搜索城市" hint="可浏览候选目录;当前无法保存,不会更改她的城市。"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button disabled>{c}<small>候选城市 · 保存待接入</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <div className="empty">当前目录没有匹配的城市。</div>}<div className="actions"><Button disabled>保存城市 · 待接入</Button></div></>}
{real && section === 'appearance' && <><dl className="kv"><dt>现在的样子</dt><dd>工程外观 v2<small>参考静帧 · 尚无成套日常与交谈视频</small></dd></dl><Off title="换外观还没有接上。" will="接上以后:选择素材包 → 检查素材 → 看新旧对比 → 确认「她还是她」。只换样子,名字、底色、记忆和关系都不变。" now="素材包规范与导入检查尚未定稿。这里不会导入、预览或替换任何素材。"/><Field label="选择外观素材包" hint="素材包格式与媒体检查将在后续接入。当前不上传、不读取文件。"><input type="file" disabled/></Field><ul className="phases">{['清单结构与版本', '媒体格式与时长', '人物一致性', '必要场景覆盖', '可用性与兼容性'].map(label => <li key={label}>待检查 · {label}</li>)}</ul><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前内置参考静帧"/><figcaption>当前参考</figcaption></figure><figure><div className="empty">导入并通过检查后显示新外观</div><figcaption>新旧对比 · 待接入</figcaption></figure></div><Button disabled>确认应用外观 · 待接入</Button></>}
{real && section === 'backup' && <><Off title="备份还没有接上。" will="接上以后:服务器每天自动备份;你可以随时把她完整导出到自己的电脑(含底色、性格资料、对话、记忆、关系、生活进度和所用素材),也能从备份恢复同一个她。" now="现在没有任何备份在运行,这里也不会假装导出或恢复。"/><div className="card"><h4>每日自动备份</h4><p>暂无可核实的备份记录。自动备份、保留份数与运行结果将在接入后显示。</p><div className="empty">尚未接通备份服务</div></div><div className="card"><h4>完整导出</h4><p>将包含服务器档案、聊天及届时接通的记录与素材。当前不生成伪备份文件。</p><Button disabled>导出完整备份 · 待接入</Button></div><Field label="恢复同一位她的备份" hint="恢复前会检查完整性、版本和同一档案,要求输入“恢复”,并先备份当前资料。当前不接收文件或执行覆盖。"><input type="file" disabled/></Field><ul className="phases">{['检查完整性与版本', '核对是否同一位她', '先备份当前资料', '输入确认并恢复'].map(label => <li key={label}>待接入 · {label}</li>)}</ul><button className="btn-danger" disabled>检查备份并继续 · 待接入</button></>}
- {real && section === 'services' && <>{[['文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['语音识别', '尚未接通'], ['语音合成', '尚未接通'], ['视觉观察', '尚未接通']].map(([label, status], index) => <div className="svc" key={label}><div className="nm">{label}<small>{status}</small></div><Button disabled={index !== 0 || busy} onClick={() => void checkService()}>{index === 0 ? busy ? '检查中…' : '重新检查连接' : '测试连接 · 待接入'}</Button><input className="input" aria-label={`${label}配置`} value={index === 0 ? '由服务器管理' : '服务尚未配置'} readOnly/></div>)}<Off title="语音与视觉观察还没有接上。" will="接上以后:语音由你主动开启,结束后停止收音,文字聊天一直保留;视觉观察只看人在不在、明显物品和房间变化,过期的画面不再当作眼前。" now="这里不申请麦克风或摄像头,不采集声音与影像。"/><div className="card"><h4>其余能力</h4><p>{life?.wired ? `生活:${life.label}。${life.note}长期记忆、主动消息和天气尚未接通。` : '生活、长期记忆、主动消息和天气尚未接通。'}文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</p></div></>}
+ {real && section === 'services' && <>{([['text', '文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['voice', '语音识别', VOICE_SVC.asr[voiceSvc]], ['voice', '语音合成', VOICE_SVC.tts[voiceSvc]], ['off', '视觉观察', '尚未接通']] as const).map(([kind, label, status]) => <div className="svc" key={label} data-state={kind === 'voice' ? voiceSvc : undefined}><div className="nm">{label}<small>{status}</small></div>{kind === 'text' ? <Button disabled={busy} onClick={() => void checkService()}>{busy ? '检查中…' : '重新检查连接'}</Button> : kind === 'voice' ? <Button disabled={voiceSvc === 'loading'} onClick={() => setVoiceSvcRead(n => n + 1)}>{voiceSvc === 'loading' ? '读取中…' : '重新读取状态'}</Button> : <Button disabled>测试连接 · 待接入</Button>}<input className="input" aria-label={`${label}配置`} value={kind === 'text' ? '由服务器管理' : kind === 'voice' ? VOICE_SVC.config[voiceSvc] : '服务尚未配置'} readOnly/></div>)}<p className="d-dim">语音两项读自服务器的语音状态:「可连接」只说明她的声音(合成)这会儿通过了健康检查,不代表整套语音通话已经验收;语音识别这里查不到单独的状态。这里只读状态,不申请麦克风。</p><Off title="视觉观察还没有接上。" will="接上以后:视觉观察只看人在不在、明显物品和房间变化,过期的画面不再当作眼前。语音由你在交谈里主动开启,结束后停止收音,文字聊天一直保留。" now="这里不申请摄像头,不采集影像。"/><div className="card"><h4>其余能力</h4><p>{life?.wired ? `生活:${life.label}。${life.note}长期记忆、主动消息和天气尚未接通。` : '生活、长期记忆、主动消息和天气尚未接通。'}文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</p></div></>}
<div ref={feedback}><Status text={message} error={error}/></div>{(real ? ['relation', 'display'] : ['relation', 'city', 'services', 'display']).includes(section) && <div className="actions"><span className={`grow ${dirty ? 'dirty' : ''}`}>{dirty ? '有未保存的修改' : '尚未修改'}</span><Button disabled={!dirty || busy} onClick={reset}>取消修改</Button><Button primary disabled={!dirty || busy} onClick={() => void save()}>{busy ? '正在保存…' : error ? '重试保存' : '保存'}</Button></div>}</div></div></div>
{confirm && <Modal title={confirm === 'leave' ? '有未保存的修改' : confirm === 'relation' ? `把关系从「${state.identity.relation}」改成「${draft.identity.relation}」?` : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'leave') { request.current?.abort(); setBusy(false); setDraft({ identity: structuredClone(state.identity), settings: structuredClone(state.settings) }); const action = pendingNavigation.current; pendingNavigation.current = null; approvedNavigation.current = true; try { action?.(); } finally { approvedNavigation.current = false; } } if (which === 'relation')
void save(true); if (which === 'appearance') {
diff --git a/web/src/manage/VoiceSection.tsx b/web/src/manage/VoiceSection.tsx
index 36a88c1a631c24e429842da23d975a4082a4f813..9075a83c04d0699a97c01bf049d79e828addad04 100644
--- a/web/src/manage/VoiceSection.tsx
+++ b/web/src/manage/VoiceSection.tsx
@@ -4,7 +4,7 @@
// (request aborted, audio stopped, object URL revoked), so an old sample can never pass for the new one.
import { useEffect, useRef, useState, type RefObject } from 'react';
import { clockOf } from '../adapters/real';
-import { applyVoice, LABEL_MAX, listVoices, PREVIEW_DEFAULT, PREVIEW_MAX, previewVoice, Recorder, registerVoice, SAY, SUGGEST_MAX, SUGGEST_MIN, TEXT_MAX, toReference, VoiceError, type CustomVoice, type RecView, type VoiceSelection } from '../voice/custom';
+import { applyVoice, idOnly, LABEL_MAX, listVoices, PREVIEW_DEFAULT, PREVIEW_MAX, previewVoice, Recorder, registerVoice, SAY, SUGGEST_MAX, SUGGEST_MIN, TEXT_MAX, toReference, VoiceError, type CustomVoice, type RecView, type VoiceSelection } from '../voice/custom';
import { Button, Field, Icon, Status } from '../shared/ui';
export type VoiceCap = 'edit' | 'off' | 'unknown';
@@ -37,6 +37,11 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
// One controller per kind of request; a new one aborts the old. `takeGen` guards decoding a file or a recording.
const ctl = useRef<Record<'list' | 'register' | 'preview' | 'apply', AbortController | null>>({ list: null, register: null, preview: null, apply: null });
const takeGen = useRef(0), urls = useRef(new Set<string>()), listed = useRef(false);
+ // HEL-903: a registration that failed without a clear answer may still have been filed. `unsure` holds the custom ids
+ // listed when it failed, so an explicit refresh can say which entry is new (never a silent second registration).
+ const [unsure, setUnsure] = useState(false), unsureBefore = useRef<Set<string> | null>(null);
+ const settleUnsure = () => { unsureBefore.current = null; setUnsure(false); };
+ const loadNow = useRef(load); loadNow.current = load;
const fresh = (kind: keyof typeof ctl.current) => { ctl.current[kind]?.abort(); const c = new AbortController(); ctl.current[kind] = c; return c; };
const own = (blob: Blob) => { const url = URL.createObjectURL(blob); urls.current.add(url); return url; };
const drop = (url?: string) => { if (url && urls.current.delete(url)) URL.revokeObjectURL(url); };
@@ -69,6 +74,12 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
if (c.signal.aborted) return;
if (!list.configured) { setLoad({ state: 'off', text: SAY.notConfigured }); onCap('off'); return; }
listed.current = true; setNote(n => n.error ? { text: '', error: false } : n); setLoad({ state: 'ok', voices: list.voices, selection: list.selection }); onCap('edit');
+ const before = unsureBefore.current;
+ if (before) {
+ const fresh = list.voices.filter(v => v.source === 'custom' && !before.has(v.id));
+ if (fresh.length) { settleUnsure(); setRegNote({ text: `列表里多了${fresh.map(v => `「${v.label}」`).join('、')},${SAY.refreshFound}`, error: false }); }
+ else setRegNote({ text: SAY.refreshNone, error: false });
+ }
}, e => {
if (c.signal.aborted) return;
const status = e instanceof VoiceError ? e.status : 0;
@@ -86,6 +97,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
try {
const ref = await toReference(input);
if (gen !== takeGen.current) return;
+ settleUnsure();
const url = own(ref.wav);
setTake(old => { drop(old?.url); return { wav: ref.wav, url, seconds: ref.seconds, from: kind, name: label }; });
setRegNote({ text: '', error: false });
@@ -99,7 +111,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
if (!file || registering) return;
void accept(file, 'file', file.name.slice(0, 60));
}
- function discardTake() { takeGen.current++; recorder.current?.cancel(); setTake(old => { drop(old?.url); return null; }); setRegNote({ text: '', error: false }); }
+ function discardTake() { takeGen.current++; recorder.current?.cancel(); settleUnsure(); setTake(old => { drop(old?.url); return null; }); setRegNote({ text: '', error: false }); }
function switchFrom(next: 'file' | 'record') { if (next === from) return; recorder.current?.cancel(); setFrom(next); setRegNote({ text: '', error: false }); }
const text = form.text.trim(), needText = !text && !form.allowAsr;
@@ -111,6 +123,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
try {
const done = await registerVoice(sent.wav, form, c.signal);
if (c.signal.aborted) return;
+ settleUnsure();
setTake(old => { if (old === sent) { drop(old.url); return null; } return old; });
setForm({ label: '', text: '', allowAsr: false });
setRegNote({ text: done.prompt_text_source === 'asr' ? `登记好了:「${done.label}」。服务器转写的参考文字是:${done.prompt_text || '(空)'}` : `登记好了:「${done.label}」。`, error: false });
@@ -119,8 +132,15 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
setReads(n => n + 1);
} catch (e) {
if (c.signal.aborted) return;
- // Nothing is lost on failure: the take, the label and the text all stay for another try.
- setRegNote({ text: message(e), error: true });
+ // Nothing is lost on failure: the take, the label and the text all stay for another try. When the server may
+ // have filed it anyway, say so and offer a look at the list first; the list is not re-read behind your back.
+ if (e instanceof VoiceError && e.uncertain) {
+ const l = loadNow.current;
+ if (l.state === 'ok' && !unsureBefore.current) unsureBefore.current = new Set(l.voices.filter(v => v.source === 'custom').map(v => v.id));
+ setUnsure(true);
+ // The server's own words when it gave any; a bare "try again later" would contradict "look first".
+ setRegNote({ text: e.message === SAY.failed || e.message === SAY.odd ? SAY.maybeRegistered : `${e.message}${SAY.maybeRegistered}`, error: true });
+ } else setRegNote({ text: message(e), error: true });
} finally { if (ctl.current.register === c) { ctl.current.register = null; setRegistering(false); } }
}
@@ -174,7 +194,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
const mine = voices.filter(v => v.source === 'custom'), builtin = voices.filter(v => v.source === 'builtin');
const chosen = voices.find(v => v.id === candidate), ready = sample?.voice === candidate && sample.state === 'ready';
const recording = rec.phase === 'asking' || rec.phase === 'recording';
- const row = (v: CustomVoice) => <li key={v.id}><button aria-pressed={candidate === v.id} onClick={() => pick(v.id)} disabled={applying}><span>{v.label}{v.selected && <span className="tag accent">正在用</span>}</span><small>{v.source === 'custom' ? `自定义${v.registered_at ? ` · ${clockOf(v.registered_at).date}` : ''}` : '服务自带'}{v.previewed ? ' · 试听过' : ''}</small></button></li>;
+ const row = (v: CustomVoice) => <li key={v.id}><button aria-pressed={candidate === v.id} onClick={() => pick(v.id)} disabled={applying}><span>{v.label}{v.selected && <span className="tag accent">正在用</span>}</span><small>{idOnly(v) ? '自定义 · 只有编号,缺本地资料' : v.source === 'custom' ? `自定义${v.registered_at ? ` · ${clockOf(v.registered_at).date}` : ''}` : '服务自带'}{v.previewed ? ' · 试听过' : ''}</small></button></li>;
return <div className="voice-sec">
<dl className="kv"><dt>现在的声音</dt><dd>{selection.voice ? <>{current?.label || selection.label || selection.voice}<small>{current?.source === 'custom' ? '自定义' : current ? '服务自带' : '这个声音已不在列表里,通话时会用默认声音'}{selection.updated_at ? ` · ${clockOf(selection.updated_at).date} ${clockOf(selection.updated_at).time} 换上` : ''}</small></> : <>还没有为她选过<small>通话时用服务默认的声音</small></>}</dd>
@@ -197,7 +217,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
<Field label="给这个声音起个名字" hint={`最多 ${LABEL_MAX} 个字,只用来在这里区分;不填就用编号。`}><input className="input" value={form.label} maxLength={LABEL_MAX} placeholder="比如:轻一点的她" disabled={registering} onChange={e => setForm(f => ({ ...f, label: e.target.value.replace(/[<>\p{Cc}]/gu, '') }))}/></Field>
<Field label="参考文字" hint="这段声音里念的原话。写得越准,克隆越像。"><textarea className="textarea" value={form.text} maxLength={TEXT_MAX} rows={2} disabled={registering} placeholder="照着录音里念的内容写下来" onChange={e => setForm(f => ({ ...f, text: e.target.value }))}/></Field>
<label className="switch-row"><span>参考文字留空时,允许服务器转写<small>{text ? '已经填写了参考文字,不会转写。' : '会用这台服务器上的语音识别听一遍这段声音;不打开就必须填写参考文字。'}</small></span><input type="checkbox" className="switch" checked={form.allowAsr && !text} disabled={!!text || registering} onChange={e => setForm(f => ({ ...f, allowAsr: e.target.checked }))}/></label>
- <div className="take-acts"><Button primary disabled={registering || needText} onClick={() => void register()}>{registering ? '正在登记…' : '登记这段声音'}</Button><Button disabled={registering} onClick={discardTake}>换一段</Button></div>
+ <div className="take-acts">{unsure && <Button primary disabled={registering} onClick={() => setReads(n => n + 1)}><Icon name="list"/>刷新列表</Button>}<Button primary={!unsure} disabled={registering || needText} onClick={() => void register()}>{registering ? '正在登记…' : unsure ? '确认没有,再登记' : '登记这段声音'}</Button><Button disabled={registering} onClick={discardTake}>换一段</Button></div>
{needText && !registering && <p className="d-dim">填写参考文字,或打开上面的转写开关,才能登记。</p>}
</div>}
<Status text={regNote.text} error={regNote.error}/>
@@ -205,6 +225,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a
<h4 className="sub-h">可以给她用的声音<small>先试听,再给她用</small></h4>
<p className="voice-group">你给的声音</p>
{mine.length ? <ul className="city-list voice-list">{mine.map(row)}</ul> : <div className="empty sm">还没有登记过自己的声音。</div>}
+ {mine.some(idOnly) && <p className="d-dim voice-idonly">{SAY.idOnly}</p>}
{builtin.length > 0 && <details className="voice-builtin" open={!!chosen && chosen.source === 'builtin'}><summary>服务自带的声音 · {builtin.length} 个</summary><ul className="city-list voice-list">{builtin.map(row)}</ul></details>}
{chosen && <div className="card audition" aria-live="polite">
diff --git a/web/src/styles/components.css b/web/src/styles/components.css
index 6e8412c0d346d5cbae90df14f4d20e5555c9079e..0988bdf2aa8ee8cdc35f5d7328910885e3d794e7 100644
--- a/web/src/styles/components.css
+++ b/web/src/styles/components.css
@@ -1034,4 +1034,16 @@ body.creating .now-chip{display:none}
@media (max-width:720px){
.voice-sec .take-acts .btn-primary,.voice-sec .take-acts .btn-ghost,.voice-sec .rec-acts button{flex:1}
.voice-sec .rec .btn-ghost{width:100%}
+}
+/* ── HEL-903: the call's own voice on the call line, the voice status rows, id-only entries. Tokens only. ── */
+.voice-row.real .voice-pin{display:inline-block;flex:0 1 auto;min-width:0;max-width:12em;padding-left:10px;border-left:1px solid var(--glass-line);color:var(--text-1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.voice-row.real .voice-pin small{margin-right:6px;font-size:11px;color:var(--text-3);letter-spacing:.04em}
+.voice-row.real .voice-pin.unknown{color:var(--text-3)}
+.svc[data-state="ok"] .nm small{color:var(--ok)}
+.svc[data-state="down"] .nm small{color:var(--warn)}
+.voice-idonly{margin:8px 0 0;font-size:12.5px}
+@media (max-width:720px){
+ .voice-sec .take-acts .btn-primary,.voice-sec .take-acts .btn-ghost{flex:1 1 auto;white-space:nowrap}
+ .voice-row.real .voice-pin{max-width:9.5em;padding-left:8px}
+ .voice-row.real .voice-pin .vp-wide{display:none}
}
\ No newline at end of file
diff --git a/web/src/voice/custom.ts b/web/src/voice/custom.ts
index 6a815694a5c1e4fc00816c46cf53ebae5104ff21..afc2b7bee7b0d02f34b1f445456fbce3cb4f2113 100644
--- a/web/src/voice/custom.ts
+++ b/web/src/voice/custom.ts
@@ -37,9 +37,16 @@ export const SAY = {
noRecorder: '这个浏览器不支持在网页里录音。可以改用上传文件。',
micGone: '麦克风被关掉了,这段没有录完,已经丢弃。',
recordFailed: '这次录音没有成功,已经丢弃,可以再录一次。',
+ // HEL-903: the upload may have reached the shared voice panel even though no answer came back (server error, a
+ // dropped connection, local metadata not saved). Look before registering again, so one voice is not filed twice.
+ maybeRegistered: '这段声音可能已经登记上了,只是这边没收到结果。先点「刷新列表」,看看「你给的声音」里有没有新出现的编号条目(cg- 开头):有就直接选它试听、使用,不用再登记一次。',
+ refreshFound: '很可能就是刚才那段:直接在下面选它试听、使用就好,不用再登记一次。',
+ refreshNone: '列表里暂时没有新出现的条目。服务器可能还在处理,稍等几秒再刷新一次;确认没有再登记。',
+ idOnly: '只有编号的条目:声音已经登记在共享面板上,但这台服务器没存下它的名字和参考文字,多半来自一次没收到结果的登记。它仍可试听、使用;这里暂时不能删除。',
} as const;
-export class VoiceError extends Error { constructor(message: string, readonly status = 0) { super(message); } }
+// `uncertain`: the request may have been carried out even though it failed here (sent, then no usable answer).
+export class VoiceError extends Error { constructor(message: string, readonly status = 0, readonly uncertain = false) { super(message); } }
// ── WAV ──
export function encodeWav(samples: Float32Array, rate = RATE): ArrayBuffer {
@@ -111,7 +118,8 @@ const online = () => typeof navigator === 'undefined' || navigator.onLine !== fa
async function call(f: Fetch, path: string, init: RequestInit): Promise<Response> {
if (!online()) throw new VoiceError(SAY.offline);
try { return await f(`/api/voice/${path}`, init); }
- catch (e) { if (init.signal?.aborted) throw e; throw new VoiceError(online() ? SAY.unreachable : SAY.offline); }
+ // Gone after sending started: whether the server acted on it is unknown (an offline check above sent nothing).
+ catch (e) { if (init.signal?.aborted) throw e; throw new VoiceError(online() ? SAY.unreachable : SAY.offline, 0, true); }
}
async function refusal(r: Response, fallback: string = SAY.failed): Promise<VoiceError> {
const data = await r.json().catch(() => null) as { detail?: unknown } | null;
@@ -136,11 +144,22 @@ export async function listVoices(signal: AbortSignal, f: Fetch = fetch): Promise
export async function registerVoice(wav: Blob, form: { label: string; text: string; allowAsr: boolean }, signal: AbortSignal, f: Fetch = fetch): Promise<Registered> {
const q = new URLSearchParams({ label: form.label.trim(), prompt_text: form.text.trim(), allow_asr: form.allowAsr && !form.text.trim() ? 'true' : 'false' });
const r = await call(f, `voices?${q}`, { method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: wav, signal });
- if (!r.ok) throw await refusal(r);
+ // A 4xx is a clear refusal (nothing filed). A 5xx, or a success whose body never arrived whole, may sit after the
+ // panel already took the voice (HEL-902 §4: metadata write failing → 500, panel down → 503).
+ if (!r.ok) { const e = await refusal(r); throw r.status >= 500 ? new VoiceError(e.message, r.status, true) : e; }
const data = await r.json().catch(() => null) as Partial<Registered> | null;
- if (!data || typeof data.id !== 'string' || !data.id) throw new VoiceError(SAY.odd);
+ if (!data || typeof data.id !== 'string' || !data.id) throw new VoiceError(SAY.odd, r.status, true);
return { id: data.id, label: typeof data.label === 'string' && data.label ? data.label : data.id, prompt_text: typeof data.prompt_text === 'string' ? data.prompt_text : '', prompt_text_source: data.prompt_text_source === 'asr' ? 'asr' : 'user' };
}
+// A voice the server lists with no local metadata (label falls back to the id, no registration time): the panel has it,
+// this server never recorded it — typically a registration whose answer was lost.
+export const idOnly = (v: CustomVoice) => v.source === 'custom' && v.registered_at === null && v.label === v.id;
+// The name a call's pinned voice is shown by: its own label if the list knows that id, else the id itself; never a guess.
+export function callVoiceName(id: string | null, named: { id: string; label: string } | null): string {
+ if (!id) return '声音未说明';
+ if (id === 'default') return '默认声音';
+ return named?.id === id ? named.label : id;
+}
// A fresh synthesis every time. Only a real WAV comes back as playable; anything else is refused, never played.
export async function previewVoice(voice: string, text: string, signal: AbortSignal, f: Fetch = fetch): Promise<Blob> {
const r = await call(f, 'preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(text.trim() ? { voice, text: text.trim() } : { voice }), signal });
diff --git a/web/src/voice/session.ts b/web/src/voice/session.ts
index 5b34bc68625914c00c002e85b88f945de35bbfa4..7328b8b3a7f300a3abdee158bae7cce09024808d 100644
--- a/web/src/voice/session.ts
+++ b/web/src/voice/session.ts
@@ -24,12 +24,16 @@ export interface VoiceView {
notice: string;
// The browser held her audio back until a tap (autoplay rules); `resume` lets it out.
blocked: boolean;
+ // The voice the server pinned for THIS call (HEL-903, session response `voice`): null until a room is granted;
+ // `id: null` when the server did not say. It stays for the whole call; a choice made in settings is the next call's.
+ voice: CallVoice | null;
}
+export interface CallVoice { id: string | null }
// One "she speaks" (an SDK speech): her reply to your voice turn, or a stored reach-out read aloud. `open` grows by
// replacement (each interim is the whole text so far); `final` is the stored reply's exact text; `done` is closed and
// stays only until the record (or the next turn) takes its place.
export interface HerLine { speech: string; text: string; state: 'open' | 'final' | 'done'; turnId?: string; messageId?: number; proactive?: boolean }
-export const OFF: VoiceView = { phase: 'off', heard: '', spoken: '', her: null, notice: '', blocked: false };
+export const OFF: VoiceView = { phase: 'off', heard: '', spoken: '', her: null, notice: '', blocked: false, voice: null };
// ── cg.subtitle contract v2 (docs/voice-subtitle-contract-v2.md). Anything that is not exactly v2 is dropped. ──
const USER_KINDS = ['interim', 'user_final'] as const;
@@ -69,7 +73,9 @@ export interface VoiceRoom { publish(mic: MicTrack): Promise<void>; hush(on: boo
export interface VoiceTransport { capture(): Promise<MicTrack>; join(url: string, token: string, on: RoomEvents, signal: AbortSignal): Promise<VoiceRoom> }
export interface VoiceAvailability { available: boolean; reason: string }
-export interface SessionGrant { url: string; token: string; room: string; session_id: string; epoch: number; expires_at: number }
+export interface SessionGrant { url: string; token: string; room: string; session_id: string; epoch: number; expires_at: number; voice?: unknown }
+// Only an id the voice list could hold is taken as the call's voice; anything else is "not said", never guessed.
+export const callVoiceOf = (grant: { voice?: unknown }): CallVoice => ({ id: typeof grant.voice === 'string' && /^[\w-]{1,40}$/u.test(grant.voice) ? grant.voice : null });
export const WORDS = {
notConfigured: '语音还没有在这台服务上配置,文字仍可使用。',
@@ -109,16 +115,24 @@ interface Live { gen: number; abort: AbortController; mic?: MicTrack; room?: Voi
const AGENT_PHASE: Record<string, VoicePhase> = { initializing: 'joining', listening: 'listening', thinking: 'thinking', speaking: 'speaking' };
-export async function voiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceAvailability> {
+// What GET /api/voice/status can actually tell (HEL-903): no route on this server, not configured, configured with the
+// TTS health probe passing ('ok') or failing ('down'), or unreadable. It says nothing about recognition on its own and
+// never that a whole call works end to end (`voice` stays false until real-device acceptance).
+export type VoiceStatusKind = 'none' | 'off' | 'ok' | 'down' | 'unknown';
+export async function readVoiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceStatusKind> {
let response: Response;
- try { response = await f('/api/voice/status', { signal }); } catch (e) { if (signal.aborted) throw e; return { available: false, reason: WORDS.unknown }; }
+ try { response = await f('/api/voice/status', { signal }); } catch (e) { if (signal.aborted) throw e; return 'unknown'; }
// A server from before R5 has no such route: that is "not wired", not a failure.
- if (response.status === 404 || response.status === 405) return { available: false, reason: WORDS.noRoute };
+ if (response.status === 404 || response.status === 405) return 'none';
const data = await response.json().catch(() => null) as { configured?: unknown; tts_ok?: unknown } | null;
- if (!response.ok || !data) return { available: false, reason: WORDS.unknown };
- if (data.configured !== true) return { available: false, reason: WORDS.notConfigured };
- if (data.tts_ok !== true) return { available: false, reason: WORDS.ttsDown };
- return { available: true, reason: '' };
+ if (!response.ok || !data) return 'unknown';
+ if (data.configured !== true) return 'off';
+ return data.tts_ok === true ? 'ok' : 'down';
+}
+const REASON: Record<VoiceStatusKind, string> = { none: WORDS.noRoute, off: WORDS.notConfigured, ok: '', down: WORDS.ttsDown, unknown: WORDS.unknown };
+export async function voiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceAvailability> {
+ const kind = await readVoiceStatus(signal, f);
+ return { available: kind === 'ok', reason: REASON[kind] };
}
function micFailure(e: unknown, secure: boolean) {
@@ -202,8 +216,9 @@ export class VoiceController {
if (!response.ok || !data?.token || !data.url || !live.session) return fail(typeof data?.detail === 'string' && data.detail ? data.detail : WORDS.unreachable);
const grant = data;
- // 4. Join and speak.
- this.show({ phase: 'joining' }, gen);
+ // 4. Join and speak. The voice this room was opened with is shown from here to the end of this call; a late grant
+ // of an older call never gets here (stale), and every end or failure resets the view.
+ this.show({ phase: 'joining', voice: callVoiceOf(grant) }, gen);
const events: RoomEvents = {
agentJoined: () => { if (stale() || live.agent) return; live.agent = true; clearTimeout(live.timer); if (!live.stated) this.show({ phase: 'listening' }, gen); },
// Her worker gone with the room still open: no one would ever answer, so the call ends honestly, exactly as a
diff --git a/web/tests/hel898_agent_left.test.ts b/web/tests/hel898_agent_left.test.ts
index daacaa069de2781403d9fafdbc823e60b8199270..092c8c98aa02d10f86bb18c3afa7de85cb9188e1 100644
--- a/web/tests/hel898_agent_left.test.ts
+++ b/web/tests/hel898_agent_left.test.ts
@@ -62,7 +62,7 @@ describe('her worker leaves while the room stays open (real adapter, SDK event n
room.say({ v: 2, seq: 3, kind: 'assistant_interim', role: 'assistant', text: '在听呢。', speech_id: 'sp' });
expect(r.view()).toMatchObject({ phase: 'thinking', her: { speech: 'sp', text: '在听呢。', state: 'open' } });
room.leave(worker.identity); await flush();
- expect(r.view()).toEqual({ phase: 'off', heard: '', spoken: '', her: null, notice: WORDS.agentLeft, blocked: false });
+ expect(r.view()).toEqual({ phase: 'off', heard: '', spoken: '', her: null, notice: WORDS.agentLeft, blocked: false, voice: null });
expect(mics[0].stopped).toBeGreaterThan(0);
expect(room.disconnects).toBe(1);
expect(deletes(r.calls)).toEqual(['DELETE /api/voice/session/s1']);
diff --git a/web/tests/hel903_mock.mjs b/web/tests/hel903_mock.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..b8d2a117482b0c7dee656f7c84508963e48f4b81
--- /dev/null
+++ b/web/tests/hel903_mock.mjs
@@ -0,0 +1,130 @@
+// HEL-903 isolated contract mock: the HEL-878 voice session (fake LiveKit via tests/fake-livekit.js) and the HEL-900
+// custom-voice endpoints in one process, answering as src/cybergril/voice/router.py does at b09a907 — the session
+// answer carries `voice` (selection if still listed, else "default"), /api/voice/status carries configured/tts_ok.
+// Adds the failures this card handles: a registration the panel filed while the answer was lost (500 / dropped).
+// Synthetic data only: no voice panel, ASR, TTS, LiveKit or network beyond 127.0.0.1.
+// Usage: node tests/hel903_mock.mjs <port>. Control GETs: /__status?mode=ok|down|off|none|500|net,
+// /__session-voice?v=<id>|omit|(empty: as the server), /__fail?register=503|500|net|commit500|commitnet,
+// /__select?voice=<id> (another device applies a voice), /__reset, /__log (drains), /__uploads, /__state, /__pid.
+import { createServer } from 'node:http';
+import { readFile } from 'node:fs/promises';
+import { extname, join, normalize } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const dist = fileURLToPath(new URL('../dist/', import.meta.url)), fake = fileURLToPath(new URL('./fake-livekit.js', import.meta.url));
+const port = Number(process.argv[2] || 8903);
+const T0 = Date.UTC(2026, 9, 27, 12, 0);
+const identity = { id: '1', name: '栖', birth: '2000-03-14', hour: '子', relation: '朋友', nickname: '', boundary: '', color: { pillars: { year: '庚辰', month: '己卯', day: '丙午', hour: '戊子' }, traits: '合成测试档案' } };
+const BUILTIN = ['bailu', 'default', 'female_soft', 'male_warm', 'rain', 'xiaoyun'];
+const log = [], uploads = [], sessions = new Map();
+let statusMode = 'ok', sessionVoice = '', fail = {}, custom = new Map(), orphans = new Set(), previewed = new Set(), selection = { voice: null, label: '', updated_at: null }, made = 0, rooms = 0;
+const reset = () => { statusMode = 'ok'; sessionVoice = ''; fail = {}; custom = new Map(); orphans = new Set(); previewed = new Set(); selection = { voice: null, label: '', updated_at: null }; made = 0; log.splice(0); uploads.splice(0); sessions.clear(); };
+const send = (res, status, body, type = 'application/json; charset=utf-8') => { res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)); };
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+const body = async req => { const parts = []; for await (const c of req) parts.push(c); return Buffer.concat(parts); };
+function wavInfo(b) {
+ if (b.length < 44 || b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WAVE') return null;
+ let at = 12, fmt = null;
+ while (at + 8 <= b.length) {
+ const id = b.toString('ascii', at, at + 4), size = b.readUInt32LE(at + 4);
+ if (id === 'fmt ') fmt = { channels: b.readUInt16LE(at + 10), rate: b.readUInt32LE(at + 12), bits: b.readUInt16LE(at + 22) };
+ if (id === 'data') return fmt && { ...fmt, seconds: Math.min(size, b.length - at - 8) / (fmt.rate * fmt.channels * fmt.bits / 8) };
+ at += 8 + size + (size & 1);
+ }
+ return null;
+}
+function tone(seconds = 1.6, rate = 24000) {
+ const n = Math.round(seconds * rate), b = Buffer.alloc(44 + n * 2);
+ b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
+ b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
+ for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 9) * 6000 * Math.min(1, i / 2000, (n - i) / 2000)), 44 + i * 2);
+ return b;
+}
+const listed = () => [...BUILTIN, ...custom.keys(), ...orphans];
+const known = id => listed().includes(id);
+// An orphan is in the shared panel only: no CyberGril metadata, so the router lists it with label = id.
+const item = id => { const r = custom.get(id); return { id, label: r ? r.label : id, source: id.startsWith('cg-') ? 'custom' : 'builtin', selected: selection.voice === id, previewed: previewed.has(id), registered_at: r ? r.registered_at : null, prompt_text: r ? r.prompt_text : null, prompt_wav: r ? r.prompt_wav : null }; };
+const take = kind => { const f = fail[kind]; delete fail[kind]; return f; };
+const newId = () => `cg-${(0x5e7a0000 + ++made).toString(16)}`;
+
+const handle = async (req, res) => {
+ const url = new URL(req.url, 'http://x'), p = url.pathname;
+ if (p.startsWith('/api/')) log.push(`${req.method} ${p}${url.search}`);
+ if (p === '/__pid') return send(res, 200, { pid: process.pid, mock: 'hel903' });
+ if (p === '/__log') return send(res, 200, log.splice(0));
+ if (p === '/__uploads') return send(res, 200, uploads);
+ if (p === '/__reset') { reset(); return send(res, 200, { ok: true }); }
+ if (p === '/__status') { statusMode = url.searchParams.get('mode') || 'ok'; return send(res, 200, { statusMode }); }
+ if (p === '/__session-voice') { sessionVoice = url.searchParams.get('v') || ''; return send(res, 200, { sessionVoice }); }
+ if (p === '/__fail') { fail = Object.fromEntries(url.searchParams); return send(res, 200, fail); }
+ if (p === '/__select') { const v = url.searchParams.get('voice'); selection = { voice: v, label: custom.get(v)?.label || v, updated_at: Date.now() }; return send(res, 200, selection); }
+ if (p === '/__state') return send(res, 200, { selection, custom: [...custom.keys()], orphans: [...orphans], open: [...sessions.keys()], rooms });
+ if (p.startsWith('/vendor/livekit-client')) return send(res, 200, await readFile(fake), 'text/javascript');
+
+ if (p === '/api/health') return send(res, 200, { ok: true, epoch: 3, llm: { ok: true, model: 'mock' }, backend: { life: true, persona: true, receipt: true, web_connected: true }, life_runtime: { state: 'running', timezone: 'Asia/Shanghai', budget_warning: false, budget_stopped: false, last: null }, capabilities: { text: true, proactive: true, voice: false, memory: true } });
+ if (p === '/api/companion') return send(res, 200, identity);
+ if (p === '/api/messages') return send(res, 200, { messages: [{ id: 1, ts: T0, role: 'user', text: '今天下雨了吗?', turn_id: 't1', status: 'sent', lifeIds: [] }, { id: 2, ts: T0 + 6e4, role: 'companion', text: '下了一点,窗台上还湿着。', turn_id: 't1', status: 'sent', lifeIds: [] }], next_before: null, epoch: 3 });
+ if (p === '/api/persona') return send(res, 200, { state: 'ready', interpretation: null, seeds: [], growth: [] });
+ if (p === '/api/life' || p === '/api/memories') return send(res, 200, { events: [], memories: [], next_before: null, epoch: 3 });
+
+ if (p === '/api/voice/status') {
+ if (statusMode === 'none') return send(res, 404, { detail: 'Not Found' });
+ if (statusMode === '500') return send(res, 500, 'Internal Server Error', 'text/plain');
+ if (statusMode === 'net') { res.destroy(); return; }
+ if (statusMode === 'off') return send(res, 200, { configured: false, tts_ok: false, agent_name: null, voice: false });
+ return send(res, 200, { configured: true, tts_ok: statusMode === 'ok', agent_name: 'cybergril-voice-agent', voice: false, livekit_url: 'wss://lk.invalid' });
+ }
+ if (p === '/api/voice/session' && req.method === 'POST') {
+ if (statusMode !== 'ok') return send(res, 503, { detail: '她的声音服务这会儿不在,稍后再试试。' });
+ await sleep(120);
+ // As _capture_voice: a still-listed selection, else "default"; captured once, at opening.
+ const captured = selection.voice && known(selection.voice) ? selection.voice : 'default';
+ const id = `sess-${++rooms}`; sessions.set(id, captured);
+ const grant = { url: 'wss://lk.invalid', token: `token-${id}`, room: `cg-voice-${rooms}`, session_id: id, agent_name: 'cybergril-voice-agent', epoch: 3, expires_at: Date.now() + 2 * 3600e3, voice: sessionVoice && sessionVoice !== 'omit' ? sessionVoice : captured };
+ if (sessionVoice === 'omit') delete grant.voice;
+ return send(res, 201, grant);
+ }
+ if (p.startsWith('/api/voice/session/') && req.method === 'DELETE') { const id = decodeURIComponent(p.slice(19)); return sessions.delete(id) ? send(res, 200, { ended: true }) : send(res, 404, { detail: '这次通话已经不在了。' }); }
+
+ if (p === '/api/voice/voices' && req.method === 'GET') return send(res, 200, { configured: true, voices: listed().map(item), selection: { ...selection } });
+ if (p === '/api/voice/voices' && req.method === 'POST') {
+ const data = await body(req), q = url.searchParams, info = wavInfo(data), f = take('register');
+ uploads.push({ type: req.headers['content-type'], bytes: data.length, info, label: q.get('label'), prompt_text: q.get('prompt_text'), allow_asr: q.get('allow_asr'), fail: f || '' });
+ // The panel took it, then the local metadata write failed (HEL-902 §4: OSError → 500) or the answer was lost.
+ // `commitnet` cuts the answer mid-body (a reset before any byte would let Chromium silently resend the POST on a
+ // reused socket, which is not the case this card covers).
+ if (f === 'commit500' || f === 'commitnet') { const lost = newId(); orphans.add(lost); if (f === 'commitnet') { res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': '400' }); res.write(`{"id":"${lost}","la`); setTimeout(() => res.destroy(), 50); return; } return send(res, 500, 'Internal Server Error', 'text/plain'); }
+ if (f === 'net') { res.destroy(); return; }
+ if (f === '503') return send(res, 503, { detail: '音色登记服务这会儿不在,稍后再试试。' });
+ if (f === '500') return send(res, 500, 'Internal Server Error', 'text/plain');
+ if (!info) return send(res, 422, { detail: '只支持 WAV 格式的参考音频。' });
+ const text = (q.get('prompt_text') || '').trim(), asr = q.get('allow_asr') === 'true';
+ if (!text && !asr) return send(res, 422, { detail: '请填写这段音频的参考文字,或明确选择让系统转写(会用到语音识别)。' });
+ const id = newId(), label = (q.get('label') || '').trim() || id;
+ const record = { id, label, prompt_text: text || '(模拟转写)今天天气很好。', prompt_text_source: text ? 'user' : 'asr', prompt_wav: `/opt/voice-agent/cosyvoice/voice_samples/${id}.wav`, registered_at: Date.now() };
+ custom.set(id, record);
+ return send(res, 201, record);
+ }
+ if (p === '/api/voice/preview' && req.method === 'POST') {
+ const { voice } = JSON.parse((await body(req)).toString() || '{}');
+ if (!known(voice)) return send(res, 404, { detail: '这个声音现在不在列表里。' });
+ previewed.add(voice);
+ return send(res, 200, tone(), 'audio/wav');
+ }
+ if (p === '/api/voice/selection' && req.method === 'GET') return send(res, 200, { ...selection });
+ if (p === '/api/voice/selection' && req.method === 'POST') {
+ const { voice } = JSON.parse((await body(req)).toString() || '{}');
+ if (!known(voice)) return send(res, 404, { detail: '这个声音现在不在列表里。' });
+ if (voice.startsWith('cg-') && !previewed.has(voice)) return send(res, 409, { detail: '这个声音还没有试听过,先试听再给她用。' });
+ selection = { voice, label: custom.get(voice)?.label || voice, updated_at: Date.now() };
+ return send(res, 200, { ...selection });
+ }
+ if (p.startsWith('/api/')) return send(res, 404, { detail: 'Not Found' });
+ const file = normalize(join(dist, p === '/' ? 'index.html' : p));
+ if (!file.startsWith(normalize(dist))) return send(res, 403, 'no', 'text/plain');
+ try { send(res, 200, await readFile(file), { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }[extname(file)] || 'application/octet-stream'); }
+ catch { send(res, 200, await readFile(join(dist, 'index.html')), 'text/html; charset=utf-8'); }
+};
+createServer((req, res) => { handle(req, res).catch(e => { console.log(`client gave up: ${req.method} ${req.url} (${e.code || e.message})`); try { res.destroy(); } catch { /* gone */ } }); })
+ .on('error', e => { console.error(`hel903 mock could not listen on ${port}: ${e.code || e.message}`); process.exit(2); })
+ .listen(port, '127.0.0.1', () => console.log(`hel903 mock on ${port} pid ${process.pid}`));
diff --git a/web/tests/hel903_shots.mjs b/web/tests/hel903_shots.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..4a4f16d7133c3dc56fda93729383bb1746c480e3
--- /dev/null
+++ b/web/tests/hel903_shots.mjs
@@ -0,0 +1,226 @@
+// HEL-903 browser check: 外接服务 voice rows from /api/voice/status, 本次通话声音 on the call line, and recovery after a
+// registration with no clear answer. Drives headless Edge over CDP against tests/hel903_mock.mjs, with
+// tests/fake-livekit.js in place of the LiveKit client and Chromium's fake audio device in place of a microphone (a beep,
+// not a person). No voice panel, ASR, TTS, LiveKit or real clone is involved.
+// Usage: node tests/hel903_shots.mjs <edge.exe> <outDir> [port] [before]. `before` only takes the comparison shots
+// (run it against a build of the code before this card; it asserts nothing about the new behaviour).
+import { spawn } from 'node:child_process';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { tmpdir } from 'node:os';
+
+const [edge, out, portArg, mode] = process.argv.slice(2);
+const port = Number(portArg || 8903), base = `http://127.0.0.1:${port}`, cdpPort = 9503;
+mkdirSync(out, { recursive: true });
+const profile = join(tmpdir(), `hel903-edge-${Date.now()}`);
+const browser = spawn(edge, ['--headless=new', `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, '--no-first-run', '--hide-scrollbars', '--force-color-profile=srgb', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', 'about:blank'], { stdio: 'ignore' });
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+let targets;
+for (let i = 0; i < 60 && !targets; i++) { try { targets = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json(); } catch { await sleep(250); } }
+const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
+await new Promise(r => ws.addEventListener('open', r, { once: true }));
+let id = 0; const waiting = new Map();
+ws.addEventListener('message', ({ data }) => { const m = JSON.parse(data); if (m.id && waiting.has(m.id)) { const { ok, ko } = waiting.get(m.id); waiting.delete(m.id); m.error ? ko(new Error(m.error.message)) : ok(m.result); } });
+const cdp = (method, params = {}) => new Promise((ok, ko) => { const n = ++id; waiting.set(n, { ok, ko }); ws.send(JSON.stringify({ id: n, method, params })); });
+const js = async expr => { const r = await cdp('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'js error'); return r.result.value; };
+const results = [];
+const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail && !ok ? ` — ${String(detail).slice(0, 300)}` : ''}`); };
+const ctl = async path => (await fetch(`${base}${path}`)).json();
+const shot = async name => { const { data } = await cdp('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }); writeFileSync(join(out, `${name}.png`), Buffer.from(data, 'base64')); };
+const click = async (selector, label = '') => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.offsetParent !== null && e.textContent.includes(${JSON.stringify(label)})); if (!el) return false; el.scrollIntoView({ block: 'center' }); el.click(); return true; })()`);
+const exists = sel => js(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); return !!e && e.offsetParent !== null; })()`);
+const text = sel => js(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.offsetParent !== null).map(e => e.innerText).join('\\n')`);
+const scrollTo = sel => js(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); if (e) e.scrollIntoView({ block: 'center' }); return !!e; })()`);
+const until = async (fn, ms = 6000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; };
+const fill = (sel, value) => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(sel)})].find(e => e.offsetParent !== null); if (!el) return false; const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(value)}); el.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`);
+const views = { desktop: { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, phone: { width: 390, height: 844, deviceScaleFactor: 2, mobile: true } };
+async function view(v) { await cdp('Emulation.setDeviceMetricsOverride', views[v]); await cdp('Emulation.setTouchEmulationEnabled', { enabled: views[v].mobile, maxTouchPoints: 5 }); }
+const PROBE = `(() => { const md = navigator.mediaDevices; if (!md) return; const orig = md.getUserMedia.bind(md); window.__tracks = [];
+ md.getUserMedia = async c => { const s = await orig(c); s.getTracks().forEach(t => window.__tracks.push(t)); return s; };
+ window.__live = () => window.__tracks.filter(t => t.readyState === 'live').length; })();`;
+const live = () => js('window.__live ? window.__live() : 0');
+async function boot(theme, path = '/') {
+ await ctl('/__reset');
+ await cdp('Page.navigate', { url: `${base}${path}` }); await sleep(500);
+ await js(`localStorage.setItem('cg.theme', ${JSON.stringify(theme)}); localStorage.removeItem('cg.voice.owed'); sessionStorage.clear(); true`);
+ await cdp('Page.navigate', { url: `${base}${path}` }); await sleep(1500);
+}
+async function settings(section) { if (!(await exists('.pane-manage.open'))) { await click('.nav-item', '设置'); await sleep(700); } await click('.snav button', section); await sleep(400); }
+async function closeSettings() { await click('.pane-manage button[aria-label="关闭设置"]'); await sleep(500); if (await exists('.modal.open')) { await click('.modal.open button', '放弃修改并离开'); await sleep(400); } }
+const svc = label => js(`(() => { const r = [...document.querySelectorAll('.svc')].find(e => e.offsetParent !== null && e.querySelector('.nm')?.firstChild?.textContent === ${JSON.stringify(label)}); return r ? r.querySelector('.nm small').innerText : null; })()`);
+const wav = (seconds, rate = 22050) => {
+ const n = Math.round(seconds * rate), b = Buffer.alloc(44 + n * 2);
+ b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
+ b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
+ for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 11) * 5000), 44 + i * 2);
+ return b;
+};
+const files = join(tmpdir(), `hel903-files-${Date.now()}`); mkdirSync(files, { recursive: true });
+writeFileSync(join(files, 'reference.wav'), wav(6));
+async function upload() {
+ const { result } = await cdp('Runtime.evaluate', { expression: `[...document.querySelectorAll('.voice-sec input[type=file]')].find(e => e.offsetParent !== null)` });
+ await cdp('DOM.setFileInputFiles', { files: [join(files, 'reference.wav')], objectId: result.objectId });
+ return until(() => exists('.voice-sec .take .pill'));
+}
+async function takeReady(label) { await upload(); await fill('.voice-sec .take input.input', label); await fill('.voice-sec .take textarea', '今天天气很好,我们出去走走吧。'); }
+async function applyVoice(label) {
+ await takeReady(label);
+ await click('.voice-sec .take button', '登记这段声音');
+ await until(async () => (await text('.voice-sec .voice-list')).includes(label));
+ await click('.voice-sec .audition button', '生成试听'); await until(() => exists('.voice-sec .sample.ready'));
+ await click('.voice-sec .audition button', '给栖用这个声音');
+ return until(async () => (await text('.voice-sec .kv')).includes(label));
+}
+const phase = () => js(`document.querySelector('.voice-row.real')?.dataset.phase || 'off'`);
+const pin = () => js(`document.querySelector('.voice-row.real .voice-pin')?.innerText || ''`);
+async function startCall() {
+ await click('.voice-button'); await until(() => exists('.modal.open'));
+ await click('.modal.open .btn-primary', '开始语音');
+ await until(async () => (await phase()) === 'joining');
+ await until(() => js(`window.__lk?.rooms.at(-1)?.state === 'connected'`));
+ await js('window.__lk.join()'); await sleep(300);
+}
+async function endCall() { await click('.voice-row.real .voice-end', '结束语音'); await until(async () => (await phase()) === 'off'); await sleep(300); }
+async function talk() { await click('.nav-item', '交谈'); await sleep(1400); }
+
+await cdp('Page.enable'); await cdp('Runtime.enable'); await cdp('Network.enable'); await cdp('DOM.enable');
+await cdp('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
+check('mock is this hel903 mock', (await ctl('/__pid')).mock === 'hel903');
+
+if (mode === 'before') {
+ // The same three places before this card: services rows, the call line, a registration that failed after filing.
+ await view('desktop'); await boot('moon');
+ await settings('外接服务'); await sleep(600); await shot('before-01-services');
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list')); await applyVoice('轻一点的她');
+ await closeSettings(); await talk(); await startCall(); await shot('before-02-call'); await endCall();
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+ await takeReady('第二个'); await ctl('/__fail?register=commit500'); await click('.voice-sec .take button', '登记这段声音'); await sleep(1200); await scrollTo('.voice-sec .take'); await shot('before-03-register-500');
+ browser.kill(); process.exit(0);
+}
+
+// ── Main path, both themes × desktop/phone ──
+for (const [theme, v] of [['moon', 'desktop'], ['fog', 'desktop'], ['moon', 'phone'], ['fog', 'phone']]) {
+ const tag = `${theme}-${v}`;
+ await view(v); await boot(theme);
+ // 1. 外接服务: read from /api/voice/status, never "尚未接通" and never "已接通" for voice.
+ await settings('外接服务');
+ await until(async () => (await svc('语音合成')) && !(await svc('语音合成')).includes('正在读取'));
+ const tts = await svc('语音合成'), asr = await svc('语音识别');
+ check(`${tag}: services — TTS row says configured + health probe reachable`, tts === '已配置 · 合成服务健康检查可连接', tts);
+ check(`${tag}: services — ASR row claims no probe it does not have`, asr.includes('没有单独的健康检查'), asr);
+ check(`${tag}: services — no voice row says "尚未接通" or "已接通"`, !/尚未接通|已接通/u.test(`${tts}${asr}`));
+ check(`${tag}: services — reading status opens no microphone`, (await live()) === 0 && (await js('window.__tracks.length')) === 0);
+ await scrollTo('.svc'); await shot(`${tag}-01-services`);
+
+ // 2. Apply a voice, then call: the call line shows the voice the session answer pinned.
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+ check(`${tag}: a voice is applied through the existing flow`, await applyVoice('轻一点的她'));
+ check(`${tag}: settings still says a change takes effect at the next call`, (await text('.voice-sec .kv')).includes('下一次通话'));
+ await closeSettings(); await talk();
+ await startCall();
+ check(`${tag}: call line shows 本次通话 voice from the session answer`, (await pin()).includes('轻一点的她'), await pin());
+ // 3. Another device applies a different voice mid-call: this call keeps its own.
+ await ctl('/__select?voice=rain');
+ await js(`window.__lk.say({ kind: 'interim', role: 'user', text: '你听得到吗' })`); await sleep(300);
+ check(`${tag}: subtitles still show beside it`, (await text('.voice-row.real')).includes('你听得到吗'));
+ check(`${tag}: a choice made during the call does not change this call`, (await pin()).includes('轻一点的她'), await pin());
+ await shot(`${tag}-02-call-pinned`);
+ await endCall();
+ check(`${tag}: ending the call releases the microphone and drops the pin`, (await live()) === 0 && !(await exists('.voice-row.real .voice-pin')));
+ // 4. The next call takes the new choice.
+ await startCall();
+ check(`${tag}: the next call shows the new voice`, (await pin()).includes('rain'), await pin());
+ await shot(`${tag}-03-next-call`);
+ await endCall();
+
+ // 5. A registration filed by the panel whose answer was lost: say so, offer the list, find the numbered entry.
+ await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+ await takeReady('第二个');
+ await ctl('/__fail?register=commit500');
+ await click('.voice-sec .take button', '登记这段声音');
+ check(`${tag}: 500 after filing — says it may be registered and to refresh first`, await until(async () => (await text('.voice-sec .status')).includes('可能已经登记上了')) && (await text('.voice-sec .status')).includes('刷新列表'), await text('.voice-sec .status'));
+ check(`${tag}: offers 刷新列表 and words a second registration as a checked choice`, (await exists('.voice-sec .take')) && (await text('.voice-sec .take .take-acts')).includes('刷新列表') && (await text('.voice-sec .take .take-acts')).includes('确认没有,再登记'));
+ check(`${tag}: the list is not re-read or re-registered behind your back`, (await ctl('/__uploads')).length === 2);
+ await scrollTo('.voice-sec .take .take-acts'); await shot(`${tag}-04-register-unsure`);
+ await click('.voice-sec .take button', '刷新列表');
+ check(`${tag}: refreshing names the new numbered entry`, await until(async () => /列表里多了「cg-/u.test(await text('.voice-sec .status'))), await text('.voice-sec .status'));
+ check(`${tag}: the numbered entry is marked as missing local metadata`, (await text('.voice-sec .voice-list')).includes('只有编号,缺本地资料') && (await exists('.voice-sec .voice-idonly')));
+ check(`${tag}: after it is found the cautious button goes away`, !(await text('.voice-sec .take .take-acts')).includes('刷新列表'));
+ await scrollTo('.voice-sec .voice-idonly'); await shot(`${tag}-05-found-numbered`);
+ await closeSettings();
+}
+
+// ── Branches, desktop moon unless noted ──
+await view('desktop'); await boot('moon');
+for (const [m, tts, asr, name] of [
+ ['down', '已配置 · 合成服务这会儿暂不可用', '没有单独的健康检查', 'down'],
+ ['off', '未配置 · 这台服务没有语音配置', '未配置', 'off'],
+ ['none', '这台服务没有语音状态接口', '这台服务没有语音状态接口', 'no-route'],
+ ['500', '状态未知 · 这次没读到语音状态', '状态未知', 'unknown-500'],
+ ['net', '状态未知 · 这次没读到语音状态', '状态未知', 'unknown-net'],
+]) {
+ await ctl(`/__status?mode=${m}`);
+ await settings('外接服务'); await click('.svc button', '重新读取状态');
+ await until(async () => !((await svc('语音合成')) || '').includes('正在读取'));
+ const t = await svc('语音合成'), a = await svc('语音识别');
+ check(`status ${name}: TTS "${tts}"`, t === tts, t);
+ check(`status ${name}: ASR row honest`, a.includes(asr), a);
+ if (m === 'down' || m === 'off') { await scrollTo('.svc'); await shot(`branch-services-${name}`); }
+}
+await ctl('/__status?mode=ok');
+await view('phone'); await boot('fog'); await ctl('/__status?mode=down'); await settings('外接服务'); await until(async () => (await svc('语音合成')) === '已配置 · 合成服务这会儿暂不可用'); await scrollTo('.svc'); await shot('branch-services-down-fog-phone'); await ctl('/__status?mode=ok');
+await view('desktop'); await boot('moon');
+
+// The session answer without a voice field (an older server): shown as not said, never guessed from settings.
+await settings('她的声音'); await until(() => exists('.voice-sec .voice-list')); await applyVoice('轻一点的她');
+await ctl('/__session-voice?v=omit'); await closeSettings(); await talk(); await startCall();
+check('session without voice: "声音未说明", not the settings choice', (await pin()).includes('声音未说明') && !(await pin()).includes('轻一点的她'), await pin());
+await shot('branch-call-voice-unknown');
+await endCall();
+await ctl('/__session-voice?v=default'); await startCall();
+check('session voice "default": shown as 默认声音', (await pin()).includes('默认声音'), await pin());
+await endCall(); await ctl('/__session-voice');
+
+// A dropped answer after filing; a 503 before filing (refresh finds nothing; a checked retry then succeeds).
+await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
+await takeReady('断线那次'); await ctl('/__fail?register=commitnet');
+await click('.voice-sec .take button', '登记这段声音');
+check('dropped answer: may be registered, refresh first', await until(async () => (await text('.voice-sec .status')).includes('可能已经登记上了')), await text('.voice-sec .status'));
+await click('.voice-sec .take button', '刷新列表');
+check('dropped answer: refresh finds the numbered entry', await until(async () => /列表里多了「cg-/u.test(await text('.voice-sec .status'))));
+await click('.voice-sec .take button', '换一段');
+await takeReady('面板不在'); await ctl('/__fail?register=503');
+await click('.voice-sec .take button', '登记这段声音');
+check('503: keeps the server words and adds the refresh advice', await until(async () => (await text('.voice-sec .status')).includes('音色登记服务')) && (await text('.voice-sec .status')).includes('刷新列表'), await text('.voice-sec .status'));
+await click('.voice-sec .take button', '刷新列表');
+check('503: refresh finds nothing new and says to wait before registering again', await until(async () => (await text('.voice-sec .status')).includes('暂时没有新出现的条目')), await text('.voice-sec .status'));
+await shot('branch-register-503-none');
+const before = (await ctl('/__uploads')).length;
+await click('.voice-sec .take button', '确认没有,再登记');
+check('503: the checked retry registers once and the take is gone', await until(async () => (await text('.voice-sec .voice-list')).includes('面板不在')) && (await ctl('/__uploads')).length === before + 1 && !(await exists('.voice-sec .take')));
+// Recording regression: the microphone opens on a tap and closes on stop / cancel.
+await click('.voice-sec .seg button', '现在录一段');
+await click('.voice-sec .rec button', '开始录音');
+await until(async () => (await text('.voice-sec .rec')).includes('正在录音'));
+await sleep(2600);
+check('recording: microphone live while recording', (await live()) === 1);
+await click('.voice-sec .rec button', '停止');
+check('recording: stop keeps the take and releases the microphone', await until(() => exists('.voice-sec .take .pill'), 8000) && (await live()) === 0);
+await click('.voice-sec .take button', '换一段');
+await click('.voice-sec .rec button', '开始录音'); await until(async () => (await text('.voice-sec .rec')).includes('正在录音'));
+await click('.voice-sec .rec button', '取消');
+check('recording: cancel releases the microphone', (await live()) === 0);
+await closeSettings();
+
+// Demo entry: nothing goes to any server from settings (services, her voice) or the conversation.
+await boot('moon', '/?demo=1'); await ctl('/__log');
+await settings('外接服务'); await sleep(500); await settings('她的声音'); await sleep(500); await closeSettings();
+await click('.nav-item', '交谈'); await sleep(1200);
+const demoLog = await ctl('/__log');
+check('demo: zero /api requests from settings and the conversation', demoLog.length === 0, demoLog.join(' | '));
+await settings('外接服务'); await shot('demo-services');
+
+const passed = results.filter(Boolean).length;
+console.log(`\n${passed}/${results.length} passed`);
+browser.kill();
+process.exit(passed === results.length ? 0 : 1);
diff --git a/web/tests/hel903_voice_rework.test.ts b/web/tests/hel903_voice_rework.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4a8783db6a3b1f4796480cd3bbe96bef8bf61760
--- /dev/null
+++ b/web/tests/hel903_voice_rework.test.ts
@@ -0,0 +1,122 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { callVoiceOf, readVoiceStatus, VoiceController, type MicTrack, type RoomEvents, type VoiceRoom, type VoiceView } from '../src/voice/session';
+import { callVoiceName, idOnly, registerVoice, SAY, VoiceError, type CustomVoice } from '../src/voice/custom';
+
+// HEL-903 limited rework, offline only: fake fetch, fake room and microphone. No LiveKit, voice panel, ASR or TTS.
+const gate = () => { let open!: () => void; const promise = new Promise<void>(r => { open = r; }); return { promise, open }; };
+const flush = async (n = 8) => { for (let i = 0; i < n; i++) await Promise.resolve(); await new Promise(r => setTimeout(r, 0)); };
+const signal = () => new AbortController().signal;
+const reply = (body: unknown, status = 200) => new Response(typeof body === 'string' ? body : JSON.stringify(body), { status });
+afterEach(() => { vi.unstubAllGlobals(); });
+
+describe('外接服务 reads only what /api/voice/status can tell', () => {
+ const kind = (answer: () => Response | Promise<Response>) => readVoiceStatus(signal(), (async () => answer()) as unknown as typeof fetch);
+ it('tells not configured, reachable, down, no route and unreadable apart', async () => {
+ expect(await kind(() => reply({ configured: false, tts_ok: false, agent_name: null, voice: false }))).toBe('off');
+ expect(await kind(() => reply({ configured: true, tts_ok: true, voice: false }))).toBe('ok');
+ expect(await kind(() => reply({ configured: true, tts_ok: false, voice: false }))).toBe('down');
+ expect(await kind(() => reply({ detail: 'Not Found' }, 404))).toBe('none');
+ expect(await kind(() => reply('oops', 500))).toBe('unknown');
+ expect(await kind(() => reply('not json'))).toBe('unknown');
+ expect(await kind(() => { throw new TypeError('Failed to fetch'); })).toBe('unknown');
+ });
+ it('a configured flag alone is never "reachable"', async () => {
+ expect(await kind(() => reply({ configured: true }))).toBe('down');
+ });
+});
+
+// ── The call's own voice ──
+class Mic implements MicTrack { stopped = 0; stop() { this.stopped++; } onEnded() {} }
+class Room implements VoiceRoom { closed = 0; on!: RoomEvents; async publish() {} hush() {} async resumeAudio() {} async close() { this.closed++; } }
+function rig() {
+ const views: VoiceView[] = [], rooms: Room[] = [], voices: unknown[] = [], gates: (ReturnType<typeof gate> | undefined)[] = [];
+ let made = 0;
+ const fetch = vi.fn(async (url: string, init: RequestInit = {}) => {
+ if (url === '/api/voice/status') return reply({ configured: true, tts_ok: true, voice: false });
+ if (url === '/api/voice/session' && init.method === 'POST') {
+ const n = made++, g = gates[n]; if (g) await g.promise;
+ const grant: Record<string, unknown> = { url: 'wss://lk.test', token: `t${n}`, room: `r${n}`, session_id: `s${n}`, epoch: 3, expires_at: 0 };
+ if (voices[n] !== undefined) grant.voice = voices[n];
+ return reply(grant, 201);
+ }
+ return reply({ ended: true });
+ });
+ const transport = { capture: async () => new Mic(), join: async (_u: string, _t: string, on: RoomEvents) => { const r = new Room(); r.on = on; rooms.push(r); return r; } };
+ const c = new VoiceController({ transport: async () => transport, onView: v => views.push(v), onRecord: () => {}, onSpoke: () => {}, fetch: fetch as unknown as typeof fetch, secure: () => true, agentWaitMs: 1000 });
+ return { c, views, rooms, voices, gates, view: () => views.at(-1)! };
+}
+
+describe('本次通话声音 comes from the session answer and stays for the call', () => {
+ it('shown from the granted room, unchanged through the call, gone when it ends', async () => {
+ const r = rig(); r.voices[0] = 'cg-1a2b3c4d';
+ await r.c.start();
+ expect(r.views.find(v => v.phase === 'opening')?.voice).toBeNull();
+ expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-1a2b3c4d' } });
+ r.rooms[0].on.agentJoined(); r.rooms[0].on.agentState('speaking'); r.rooms[0].on.agentState('listening');
+ expect(r.views.filter(v => v.phase !== 'opening').every(v => v.voice?.id === 'cg-1a2b3c4d')).toBe(true);
+ await r.c.end();
+ expect(r.view()).toMatchObject({ phase: 'off', voice: null });
+ });
+ it('a missing or odd field is "not said", never a guess', () => {
+ expect(callVoiceOf({})).toEqual({ id: null });
+ expect(callVoiceOf({ voice: '' })).toEqual({ id: null });
+ expect(callVoiceOf({ voice: 42 })).toEqual({ id: null });
+ expect(callVoiceOf({ voice: '<b>x</b>' })).toEqual({ id: null });
+ expect(callVoiceOf({ voice: 'default' })).toEqual({ id: 'default' });
+ });
+ it('an older call answering late never overwrites the newer call', async () => {
+ const r = rig(); r.voices[0] = 'old-voice'; r.voices[1] = 'cg-00000002'; r.gates[0] = gate();
+ const first = r.c.start(); await flush();
+ await r.c.start();
+ expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-00000002' } });
+ r.gates[0]!.open(); await first; await flush();
+ expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-00000002' } });
+ expect(r.views.some(v => v.voice?.id === 'old-voice')).toBe(false);
+ });
+ it('a failure after the room clears the voice with the call', async () => {
+ const r = rig(); r.voices[0] = 'rain';
+ await r.c.start(); r.rooms[0].on.agentJoined();
+ r.rooms[0].on.dropped(); await flush();
+ expect(r.view()).toMatchObject({ phase: 'off', voice: null });
+ });
+ it('names: its own label for that id only, the id otherwise, default and unknown in words', () => {
+ expect(callVoiceName('cg-1', { id: 'cg-1', label: '轻一点的她' })).toBe('轻一点的她');
+ expect(callVoiceName('cg-2', { id: 'cg-1', label: '轻一点的她' })).toBe('cg-2');
+ expect(callVoiceName('default', null)).toBe('默认声音');
+ expect(callVoiceName(null, null)).toBe('声音未说明');
+ });
+});
+
+describe('a registration with no clear answer says to look before registering again', () => {
+ const wav = new Blob([new Uint8Array(64)], { type: 'audio/wav' }), form = { label: 'x', text: '你好', allowAsr: false };
+ const attempt = async (answer: () => Response | Promise<Response>) => {
+ try { await registerVoice(wav, form, signal(), (async () => answer()) as unknown as typeof fetch); } catch (e) { return e as VoiceError; }
+ throw new Error('expected a failure');
+ };
+ it('500 / 503 / a dropped answer / an unreadable success may have been filed', async () => {
+ for (const e of [await attempt(() => reply({ detail: 'Internal Server Error' }, 500)), await attempt(() => reply({ detail: '音色登记服务这会儿不在,稍后再试试。' }, 503)), await attempt(() => { throw new TypeError('network'); }), await attempt(() => reply('', 201))]) {
+ expect(e).toBeInstanceOf(VoiceError);
+ expect(e.uncertain).toBe(true);
+ }
+ expect((await attempt(() => reply({ detail: '音色登记服务这会儿不在,稍后再试试。' }, 503))).message).toContain('音色登记服务');
+ });
+ it('a clear refusal (4xx) or a request never sent is not "maybe filed"', async () => {
+ expect((await attempt(() => reply({ detail: '参考音频太短(至少 2 秒),请录 5–20 秒。' }, 422))).uncertain).toBe(false);
+ expect((await attempt(() => reply({ detail: '还没有创建真实伴侣,语音也不会开始。' }, 409))).uncertain).toBe(false);
+ vi.stubGlobal('navigator', { onLine: false });
+ const offline = await attempt(() => reply({}, 201));
+ expect(offline.message).toBe(SAY.offline);
+ expect(offline.uncertain).toBe(false);
+ });
+ it('the advice points at refreshing the list and the numbered entries', () => {
+ expect(SAY.maybeRegistered).toContain('刷新列表');
+ expect(SAY.maybeRegistered).toContain('cg-');
+ expect(SAY.maybeRegistered).toContain('不用再登记');
+ });
+ it('an entry without local metadata is recognised as id-only', () => {
+ const v = (x: Partial<CustomVoice>): CustomVoice => ({ id: 'cg-9', label: 'cg-9', source: 'custom', selected: false, previewed: false, registered_at: null, ...x });
+ expect(idOnly(v({}))).toBe(true);
+ expect(idOnly(v({ label: '轻一点的她', registered_at: 1 }))).toBe(false);
+ expect(idOnly(v({ id: 'rain', label: 'rain', source: 'builtin' }))).toBe(false);
+ });
+});
+58
View File
@@ -0,0 +1,58 @@
# HEL-904 后台有限返修记录(登记失败恢复 / worker 状态实况 / capture 竞态)
作者:施工员2号(HEL-899 原作者)。2026-10-28。基准 v1.12 @ `b09a907`。
零真实 LLM/嵌入/ASR/TTS 调用、零共享音色写入、零部署、零依赖下载安装、
未动 web/、未改共享 worker、未新增依赖。不重做架构、不改网页。
对应 `docs/rework/hel902/REVIEW.md` §5 的第 2、3、4、6 项(后台部分)。
## 1. 改动清单
| 文件 | 改动 |
| --- | --- |
| `src/cybergril/voice/custom.py` | ① `register()`:本地元数据落盘失败从裸 OSError 改为 `LocalRecordFailed`(共享登记不回滚、编号写进中文提示、刷新可找回继续试听/应用、不逼重复登记);`register_asr` 流水改在本地落盘**之前**记(转写真实发生,资源口径必计)。② `write_worker_voice()` docstring 补损坏实况:文件读不出时整体重写仅剩 voice,不再宣称任何情况下保留其他键;正常可读文件行为不变。③ 新增通话持有 `hold/release_call_voice`/`call_voice_held`;`apply_selection()` 在有活跃通话时只记选择、暂缓写 worker 文件(整通话窗口按「worker 尚未读」对待),无活跃通话行为与原先一致(含回滚)。 |
| `src/cybergril/voice/router.py` | `_capture_voice()` 捕获后登记通话持有(无 await 间隙);`open_session` 失败/取消即释放;`register_voice` 映射 `LocalRecordFailed`→503 中文文案;挂载 `on_session_settled`→释放持有;头部契约注释更新。 |
| `src/cybergril/voice/session.py` | 新增可选 `on_session_settled` 回调;会话离开活跃表(显式挂断成功、挂断失败转欠账、TTL 到期 `_prune`)即结算。回调异常只影响释放时机(安全方向),不拖垮会话流程。 |
| `tests/test_voice_review_hel902.py` | 第 1 例按新契约改写:503 中文映射+编号在文案里+不误称回滚+进程内可试听可应用+重启按编号(label=编号)继续用+新音色未试听仍 409+11 支/通用助手零改写。 |
| `tests/test_voice_rework_hel904.py`(新增 4 例) | 确定性时序用例(asyncio.Event 门控,无 sleep 判定):worker 读时点延迟到 dispatch 返回之后+并发应用→本通话保持旧音色(响应=实读)、挂断结算、下一通用新音色;挂断失败转欠账与 TTL 到期两条结算路径;dispatch 失败即释放(应用立即写透);开房请求在途取消即释放。 |
| `docs/voice-custom-contract-HEL899.md` | §2.2 补共享登记成功/本地写失败与断连孤儿行为;§2.5 补通话窗口持有语义(含重叠通话如实保留的残余);§3 worker 文件损坏实况。 |
| `docs/rework/hel902/r5-followup-package.md` | 补测材料收紧(仅材料):head 里程碑表(d167ba9=整合提交、b09a907=审核 head、返修 head 待统一复审登记)、登记/连续通话 ASR 区分、连续通话 ASR 计数手段与待补阻塞如实声明、停声量测改为单一时间基准帧级录音法、参考音频明确到 D1 包具体段(含 sha256/长度)、回收清单与「不未经确认保留测试垃圾」、共享 TTS 启停快照恢复与逐项核对表、git 回退不代替共享回收、上限表补含失败口径与硬闸/监看界线。 |
## 2. 竞态收口的事实依据与设计
- 事实入口(不猜):专属 worker 是**另一进程**,在**它自己的会话入口**读
一次 `voice_state_cybergril.json`(HEL-877 既有行为,合同 §2.5;本仓库
不含 worker 代码,不改共享 worker)。API 只知道 dispatch 返回时刻,
**dispatch 返回 ≠ worker 已读**。
- 设计=等价「会话不可变快照」:capture 时(写文件后、任何 await 前)登记
按音色计数的通话持有;活跃通话期间 `apply` 只更新选择(本地选择仍原子
落盘),worker 文件定在本通话捕获值上;挂断/到期/失败/取消结算持有。
暂缓的写不需要补写:下一次通话 capture 总会按当前选择重写文件(从未
选择仍不代写,维持 D1 边界)。
- 保证:**会话响应 `voice` = worker 本通话实际读到并使用的声音**;通话中
改选只影响下一通,绝不半句换人。
- 如实保留的残余(不宣称消除):两通**重叠**(单用户产品退化场景)且选了
不同声音时,较新通话的 capture 会重写文件;较早通话的 worker 若迟迟
未读,可能读到较新捕获值。每个 worker 仍整通话单一音色。确定性用例
覆盖的是正常单通话流。
## 3. 自测(全部离线,零真实调用;py3.11.16)
- `pytest tests -q`:**380 通过**(基线 376+新增 4;改写的第 1 例保持
原名)。含既有 18 项 HEL-899 与总工 6 项故障注入全数通过。
- `python -I scripts/verify_hel899_custom_voice.py`:**12/12**(含通话中
改选定住本通话、下一通生效)。
- `ruff check src tests scripts`:通过。
- 需求检查 4 组(origin/main 与 b09a907,各带/不带本卡 handoff):通过
(证据见交付评论)。
## 4. 未验证 / 承接(如实)
- 真实 worker 读时点、真实 dispatch/房间行为、打断停声与真机麦克风路径:
属获批补测窗口(材料已收紧,见 `docs/rework/hel902/r5-followup-package.md`)。
- 本卡修复(后台)与前端另卡仍需原总工**一次统一复审**;通过后由复审
登记最终已验证 head,再由主卡向白栖知申请批准新窗口。复审前不得把本
head 当作最终已验证 head。
- 前端「会话页显示本次通话声音」(审核建议第 5 项)属 web/,归前端另卡。
- CG-009/R6 删除与完整备份恢复、R5 整体验收、R1–R7 完整功能持续承接;
本卡通过≠自定义音色用户验收,更≠完整产品完成。
+226
View File
@@ -0,0 +1,226 @@
# 自定义音色契约与部署说明(HEL-899)
> 性质:**产品后端代码 + 前端可直接实现的请求/响应/错误/录音格式契约**。本卡零真实
> LLM/嵌入/ASR/TTS 调用、零模型下载/安装、零共享音色实写、零部署;全部验证用
> fake 面板/TTS 传输完成(见 §5)。基准 v1.11 → 本卡按治理机制升 v1.12
> (CG-010 边界更新,来源评论 `01a17de1-2892-78be-a422-eb6a11ffebfd`)。
> 读取提交:`8dc84f88e9d5e961e17b246f24c23eaf526b012e`(hel-879-assembly tip)。
## 0. 用户原话(需求来源,2026-10-27)
> ".12已恢复,另外以上一条让我解压附件包试听,我不明白是什么意思,是让我听预设音色吗?
> 如果是我觉得你的设计不够合理,我想要的是可以自定义音色,我的.12上安装的TTS是支持
> 自定义音色的。至于最长2小时,3次通话的测试,我同意,音色你自己决定就行"
要点:① 自定义音色(不是仅选预设);② 直接复用 .12 已装 TTS 的克隆能力;
③ 本轮通话测试音色总管自定、最长 2 小时/3 次通话(授权给总管,不在本卡执行)。
## 1. 能力边界(复用,不自建)
- **登记**:复用 .12 语音面板(8090)`POST /api/voices/upload` 的 zero-shot 注册
(写共享 `voices.json` + CosyVoice mtime 热加载;参考文本留空时面板用 FunASR 转写)。
CyberGril 统一 API 是它的**服务端代理**:浏览器不直连 8090,凭据不进浏览器,
共享 `voices.json` 只有 8090 一个写者(无跨进程读-改-写竞态)。
- **试听**:复用 CosyVoice(9880)`POST /tts`(`{"text","voice"}` → 24k mono int16
PCM 流);API 现场包成 WAV 返回。每次试听都是新合成,**无缓存**。
- **应用**:写 CyberGril 专属 worker 的 `voice_state_cybergril.json`(原子写);
worker 在**会话入口读一次**(HEL-877 既有行为,未改)。
- **不自建**克隆引擎、不训练模型、不改 CosyVoice/8090 任何代码与配置。
### 共享注册表涉及的操作(全部经 8090,本服务不直接写共享文件)
| 操作 | 途径 | 性质 |
| --- | --- | --- |
| 列表 | `GET /api/voices` | 只读 |
| 登记 | `POST /api/voices/upload`(服务端生成 `cg-` 专属 ID) | 唯一写入,只新增 |
| 参考文本回看 | 只读 `voices.json`(部署配置路径),补全 ASR 转写文本供备份清单 | 只读 |
通用助手自己的 `voice_state.json`、`persona_state.json`、既有 11 支音色条目
(bailu/bajie/default/female_soft/haimianbaobao/jiaojiao/male_warm/nezha/rain/
sunwukong/xiaoyun)**从不触碰**;也不通过改面板全局默认偷换她的声音。
### 命名空间
- 新增音色 ID = `cg-` + 8 位十六进制(**服务端生成**,不收用户起名)→
结构上不可能撞上/覆盖既有 11 支,也不互相覆盖(撞名重试,见 §4 测试)。
- 显示名 `label`(可中文,≤20 字)只存 CyberGril 资料空间,不进共享注册表。
- 8090 的名字规则(`[a-zA-Z0-9_\-]{1,24}`)由 cg- 前缀天然满足。
## 2. HTTP 契约(前端可直接实现)
全部挂在统一 API(与现有 `/api/voice/*` 同前缀、同进程、同拒绝风格)。
错误体一律 `{"detail": "<中文文案>"}`。
### 2.1 `GET /api/voice/voices` — 列出可用声音
```json
{
"configured": true,
"voices": [
{"id": "rain", "label": "rain", "source": "builtin", "selected": false,
"previewed": false, "registered_at": null, "prompt_text": null, "prompt_wav": null},
{"id": "cg-9f2c1a77", "label": "她的声音", "source": "custom", "selected": true,
"previewed": true, "registered_at": 1793114934826,
"prompt_text": "这段参考音频念的文字", "prompt_wav": "/opt/voice-agent/cosyvoice/voice_samples/cg-9f2c1a77.wav"}
],
"selection": {"voice": "cg-9f2c1a77", "label": "她的声音", "updated_at": 1793114940000}
}
```
- `source`: `builtin`(11 支既有)| `custom`(`cg-` 前缀)。
- `prompt_text`/`prompt_wav` 是备份清单字段(CG-009 承接),前端 UI 可不展示。
- 实例未配置语音:`200 {"configured": false, "voices": [], "selection": null}`(诚实空态)。
- 面板暂不可用:`503 {"detail": "音色登记服务这会儿不在,稍后再试试。"}`。
### 2.2 `POST /api/voice/voices` — 登记参考音频
**请求体就是参考音频原始 WAV 字节**(不引入 multipart 依赖);参数走查询串:
```
POST /api/voice/voices?label=她的声音&prompt_text=这段音频念的文字&allow_asr=false
Content-Type: audio/wav (或 application/octet-stream)
<body: WAV 字节>
```
| 参数 | 必填 | 说明 |
| --- | --- | --- |
| `label` | 否 | 显示名,≤20 字,不含控制字符/尖括号;缺省用 ID |
| `prompt_text` | 否 | 参考文字(音频念的内容),≤500 字 |
| `allow_asr` | 否 | `true` = 参考文字留空时**明确允许**面板 FunASR 自动转写(显式,不隐形) |
`201` 响应即登记结果(同列表项结构):
```json
{"id": "cg-9f2c1a77", "label": "她的声音", "prompt_text": "...",
"prompt_text_source": "user" | "asr", "prompt_wav": "...", "registered_at": 1793114934826}
```
服务端复验(不信任任何客户端声明;用户文件路径不是输入):
| 检查 | 拒绝(`422`) |
| --- | --- |
| WAV magic(RIFF/WAVE,fmt/data 可解析) | "只支持 WAV 格式的参考音频。" / "WAV 头不完整。" |
| 大小 ≤ 15MB(同 8090) | "文件过大(>15MB),请录 5–20 秒的一段。" |
| 字节数与真实时长 ≥ 2 秒(同 8090 字节线 + 按头计算时长) | "参考音频太短(至少 2 秒),请录 5–20 秒。" |
| 无参考文字且未明确允许转写 | "请填写这段音频的参考文字,或明确选择让系统转写(会用到语音识别)。" |
其他错误:面板 400 透传原中文 detail(`422`);面板 5xx/断连 `503`
"音色登记服务这会儿不在,稍后再试。";登记成功但列表未出现 `422`
"登记结果没有出现在音色列表里,请重试。"(不留下半条可选项)。
**共享登记成功、本地元数据写不进**(HEL-904 补):`503` 中文文案如实说明
编号与恢复路径——声音**已在共享注册表登记成功(不回滚)**,本地记录写不
进时列表里暂时只显示编号;**刷新列表即可按编号找回并直接试听/应用,不
需要重新登记**(重复登记只会另立新编号、留下无元数据孤儿条目)。响应
断连重试同样会另立新 `cg-` 编号并在共享注册表留下以编号显示的孤儿条目
(可正常使用,回收经 8090 面板自有删除入口,CyberGril 不代管)——实际
行为如实声明,不自动删除。
**建议录音格式**(前端 MediaRecorder 录完后转):WAV(PCM),16k 或 24k 采样,
单声道,16 位,5–20 秒,安静环境念 1–2 句完整的话。API 端校验是
"合法 WAV + ≥2 秒";时长上限由 15MB 大小线自然约束。
### 2.3 `POST /api/voice/preview` — 试听(应用前必听)
```
POST /api/voice/preview
{"voice": "cg-9f2c1a77", "text": "可选:自定义试听文字(≤200 字)"}
```
- 成功:`200 audio/wav`(24k/mono/16bit),现场新合成,绝不缓存旧音频。
- 默认试听句:"你好,这是我的声音,以后就用它陪你说话。"
- 未知音色 `404` "这个声音现在不在列表里。"(**前置拦截**:CosyVoice 对未知
音色会静默回落 default,绝不让它"换人"冒充);TTS 不在 `503`
"她的声音服务这会儿不在,稍后再试试。";缺 `voice` `422`。
- 试听不写聊天/记忆/生活/回执(测试断言四表零变化),只记一条资源流水(§4)。
### 2.4 `GET /api/voice/selection` — 读取当前选择
```json
{"voice": "cg-9f2c1a77", "label": "她的声音", "updated_at": 1793114940000}
```
从未选择过:`{"voice": null, "label": "", "updated_at": null}`。
### 2.5 `POST /api/voice/selection` — 应用给她
```
POST /api/voice/selection {"voice": "cg-9f2c1a77"}
```
- 成功 `200 {"voice","label","updated_at"}`;未知音色 `404`;
**自定义音色未试听过 `409` "这个声音还没有试听过,先试听再给她用。"**
(内置 11 支是 D1 既有候选,不强制试听);状态写不进 `503`
"她的音色状态这会儿写不进,这次没有换成功。"(已整体回滚,无半状态)。
- 语义:**换声音只换发声**——不重建伴侣、不改出生底色/名字/关系/聊天/记忆
(测试断言 companion/bazi_core/chat_message 逐字节不变)。
- 生效时机:worker 在会话入口读一次音色 → **通话中改选最迟下一通话生效**,
绝不半句换人;`POST /api/voice/session` 的响应新增 `"voice"` 字段 =
本次通话捕获的有效音色(选择已失效则诚实回落 `"default"`)。
- **通话窗口持有**(HEL-904 收口 capture 与 worker 实读之间的竞态):
worker 是另一进程、读时点对 API 不可知(dispatch 返回 ≠ 已读),因此
开房 capture 把捕获值登记为通话持有,整个通话窗口(capture→挂断/到期)
都按「worker 尚未读」对待:期间 `POST /api/voice/selection` 只更新选择、
**暂缓写 worker 状态文件**,文件定在本通话捕获值上——会话响应的
`voice` 与 worker 本通话实际读到并使用的声音一致;挂断/到期/开房失败
或取消即结算持有,之后的应用立即写透。重叠通话属单用户产品的退化
场景:较新通话的 capture 会重写文件,较早通话若其 worker 迟迟未读,
可能读到较新捕获值(每个 worker 仍整通话单一音色,无半句换人)——
如实记录,不宣称完全消除。
## 3. 状态与资源(可核算、可恢复)
CyberGril 资料空间(实例数据库旁,与语音清理账同模式,重启读回):
| 文件 | 内容 |
| --- | --- |
| `<db>-voice-selection.json` | 当前选择(voice/label/updated_at),原子写 |
| `<db>-voice-custom.json` | 自定义音色元数据 + 备份清单(id/label/prompt_text/prompt_wav/registered_at) |
| `<db>-voice-usage.jsonl` | 资源流水:`{"ts","kind":"preview_tts"|"register_asr","voice"}`,有界保留(>5000 行裁到 2000) |
- ASR 只在 `allow_asr=true` 且参考文字为空时发生(面板内 FunASR,非 LLM),
每次记 `register_asr` 流水;试听每次记 `preview_tts`。LLM 调用零新增。
- 备份清单:`GET /api/voice/voices` 的 custom 项即完整清单(参考音频路径 +
参考文本 + 映射);恢复 = 按清单重新登记(面板无导出接口,属 R6 备份卡范围)。
- worker 文件 `voice_state_cybergril.json`:原子写且在文件**可解析(JSON
对象)时保留其他键**;文件读不出(损坏/非 JSON/非对象)时会**整体重写**
为仅含 `voice` 的新状态,旧键此时无法保留(HEL-902 审核 §4 核出的实况,
不得宣称任何情况下都保留其他键;正常可读文件其他键原样保留)。
用户从未经 API 选择时,通话开启不代写该文件(部署/D1 域不替用户定)。
## 4. 部署说明(本卡未执行;总工按 HEL-877 runbook §4 顺序)
1. 前置与 HEL-877 相同:统一实例 8360/8361、TTS 9880(GPU≥6GB)、专属 worker 8082。
2. `config/voice.yaml` 增补(全部可选,缺省即本机默认值):
`panel_url: https://127.0.0.1:8090`、`tts_url: http://127.0.0.1:9880`、
`registry_file: /opt/voice-agent/cosyvoice/voices.json`、
`worker_state_file: /opt/voice-agent-cybergril/voice_state_cybergril.json`;
环境变量 `CYBERGRIL_VOICE_PANEL_URL/TTS_URL/REGISTRY_FILE/WORKER_STATE` 可覆盖。
3. 依赖关系:音色登记/列表依赖 8090 面板进程在运行(.12 现状 pid 常驻);
面板不在时相关端点诚实 503,语音会话不受影响(其健康检查仍只看 9880)。
API → 面板走本机回环 https(自签证书,服务端 `verify=False`,仅回环)。
4. 零新增 Python 依赖(登记端点用原始字节体 + 查询串,特意不引 multipart 库);
对 8090 的 multipart 代理由已有 httpx 客户端构造。
5. 回滚:不部署即无痕;已部署时移除 voice.yaml 增补项即回到 HEL-877 行为,
资料空间三个文件留存无害。
## 5. 自测与证据(全部零真实请求)
- `tests/test_voice_custom.py` 18 项:正常登记/列表/选择全链、显式 ASR 准许、
格式/大小/时长拒绝、cg- 命名空间与撞名重试(含并发 4 路登记各自成立)、
面板拒绝/断连/谎报成功不留半条、未知音色 404 且 **TTS 零调用**(防静默回落
default)、TTS 停机诚实 503、试听零写聊天/记忆/回执、自定义未试听 409/内置
直选、写失败整体回滚、重启读回(选择/元数据/试听凭据)、通话捕获并定住/
失效回落 default/未选择不代写、通用助手状态零写入零删除、身份表零改写、
未配置端点诚实拒绝。
- `scripts/verify_hel899_custom_voice.py`:12/12(真实 app + fake 面板/TTS/LiveKit:
列表→拒绝无准许 ASR→显式登记→未试听 409→试听→应用→会话捕获→通话中改选
定住本通话→下一通话生效→capabilities.voice=false→重启读回)。
- 全量 `pytest tests -q`:370 通过(基线 352 + 本卡 18)。
## 6. 未验证 / 承接(如实)
- 真实 CosyVoice 试听、真实 FunASR 转写质量、克隆相似度:留统一审核卡在获批
环境实测(复用合法剩余额度,不另开账绕 HEL-879 上限);本卡零真实调用。
- 前端入口(上传/录音/试听/应用 UI):视觉总监按 §2 契约接线(本卡未动 web/)。
- 自定义音色删除、面板导出/完整备份(CG-009/R6):后续卡(本卡最小合同不含删除,
失败/多余登记不影响使用;8090 面板自己的删除入口不代管)。
- D1 最终音色决定:仍归白栖知/总管(本轮测试音色已授权总管自定)。
+191
View File
@@ -0,0 +1,191 @@
"""HEL-899 offline one-shot chain check: custom voice registration -> preview
-> apply -> call-time capture -> restart persistence, against the real app
with a fake .12 voice stack (panel + CosyVoice) and fake LiveKit.
Zero network, zero real LLM/embedding/ASR/TTS calls. Mirrors
scripts/verify_hel877_voice_chain.py in spirit: real router + real services,
fake transports only.
Usage: PYTHONPATH=src .venv311/bin/python scripts/verify_hel899_custom_voice.py
"""
from __future__ import annotations
import io
import json
import sys
import tempfile
import wave
from pathlib import Path
import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tests"))
from fastapi.testclient import TestClient # noqa: E402
from test_r2 import R2LLM # noqa: E402
from cybergril.api.app import create_app # noqa: E402
from cybergril.life.runtime import LifeConfig # noqa: E402
from cybergril.think.router import build_router # noqa: E402
from cybergril.think.scenes import Tier # noqa: E402
from cybergril.voice.custom import CustomVoiceConfig, CustomVoiceService # noqa: E402
from cybergril.voice.session import VoiceConfig, VoiceSessionService # noqa: E402
BUILTIN = ["default", "rain", "xiaoyun"]
CREATE = {"name": "链路测试", "birth": "2000-10-18", "hour": "亥时", "relation": "朋友"}
RESULTS: list[tuple[str, bool, str]] = []
def check(name: str, ok: bool, note: str = "") -> None:
RESULTS.append((name, ok, note))
print(f"{'PASS' if ok else 'FAIL'} {name} {note}")
def make_wav(seconds: float = 3.0) -> bytes:
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(16000)
wav.writeframes(b"\x00\x01" * int(16000 * seconds))
return buffer.getvalue()
def _multipart_field(body: bytes, field: bytes) -> str:
for part in body.split(b"--"):
header, _, value = part.partition(b"\r\n\r\n")
if field + b'"' in header:
return value.strip(b"\r\n-").decode()
return ""
def fake_transport(state: dict) -> httpx.MockTransport:
def handler(request: httpx.Request) -> httpx.Response:
host, path = request.url.host, request.url.path
if host == "panel.test":
if path == "/api/voices" and request.method == "GET":
return httpx.Response(200, json={"voices": sorted(state["ids"])})
if path == "/api/voices/upload":
name = _multipart_field(request.content, b"name")
prompt_text = _multipart_field(request.content, b"prompt_text")
if not prompt_text: # 参考文本为空 → 面板 FunASR 转写(桩:固定文本)
prompt_text = state["asr_text"]
state["ids"].append(name)
state["registry"][name] = {"prompt_text": prompt_text,
"prompt_wav": f"/samples/{name}.wav"}
if state.get("registry_file"):
Path(state["registry_file"]).write_text(
json.dumps(state["registry"], ensure_ascii=False), encoding="utf-8")
state["uploads"] += 1
return httpx.Response(200, json={"ok": True, "voice": name})
return httpx.Response(404)
if host == "tts.test":
if path == "/health":
return httpx.Response(200, json={"ok": True, "voices": sorted(state["ids"])})
if path == "/tts":
body = json.loads(request.content)
state["tts_calls"] += 1
return httpx.Response(200, content=f"{body['voice']}:".encode() + b"\x01\x00" * 240)
if host == "livekit.test":
return httpx.Response(200, json={"id": "AD_1", "room": "r"})
return httpx.Response(404)
return httpx.MockTransport(handler)
def build(tmp: Path, state: dict):
combined = httpx.MockTransport(lambda request: fake_transport(state).handler(request))
voice = VoiceSessionService(
config=VoiceConfig(livekit_url="ws://voice.test:7880",
livekit_internal="http://livekit.test",
api_key="devkey", api_secret="test-secret-not-real",
tts_health_url="http://tts.test/health"),
http=httpx.AsyncClient(transport=combined))
custom = CustomVoiceService(
config=CustomVoiceConfig(panel_url="https://panel.test", tts_url="http://tts.test",
registry_file=str(tmp / "voices.json"),
worker_state_file=str(tmp / "worker.json")),
http=httpx.AsyncClient(transport=fake_transport(state), verify=False),
selection_path=tmp / "sel.json", registry_path=tmp / "custom.json",
journal_path=tmp / "usage.jsonl")
provider = R2LLM()
router = build_router(providers={t: provider
for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)})
return create_app(db_path=":memory:", router=router,
life_config=LifeConfig(enabled=True, poll_seconds=3600),
semantic_mode="stub", voice_service=voice,
custom_voice_service=custom), custom
def main() -> int:
state = {"ids": list(BUILTIN), "registry": {}, "tts_calls": 0, "uploads": 0,
"asr_text": "自动转写参考文本。"}
with tempfile.TemporaryDirectory() as raw:
tmp = Path(raw)
registry_file = tmp / "voices.json"
registry_file.write_text(json.dumps(state["registry"]), encoding="utf-8")
state["registry_file"] = str(registry_file)
app, _custom = build(tmp, state)
with TestClient(app) as client:
client.post("/api/companion", json=CREATE)
listing = client.get("/api/voice/voices").json()
check("list builtin", {v["id"] for v in listing["voices"]} == set(BUILTIN))
refusal = client.post("/api/voice/voices", content=make_wav())
check("no-asr-consent refused", refusal.status_code == 422
and state["uploads"] == 0, refusal.json().get("detail", ""))
registered = client.post("/api/voice/voices",
params={"allow_asr": "true", "label": "她的声音"},
content=make_wav())
record = registered.json()
check("register with explicit ASR", registered.status_code == 201
and record["id"].startswith("cg-")
and record["prompt_text_source"] == "asr", str(record))
unapplied = client.post("/api/voice/selection", json={"voice": record["id"]})
check("apply requires preview", unapplied.status_code == 409)
preview = client.post("/api/voice/preview", json={"voice": record["id"]})
check("preview fresh wav", preview.status_code == 200
and preview.headers["content-type"] == "audio/wav"
and state["tts_calls"] == 1)
applied = client.post("/api/voice/selection", json={"voice": record["id"]})
check("apply after preview", applied.status_code == 200
and json.loads((tmp / "worker.json").read_text())["voice"] == record["id"])
opened = client.post("/api/voice/session").json()
check("session captures selection", opened["voice"] == record["id"])
client.post("/api/voice/selection", json={"voice": "rain"})
pinned = client.app.state.voice_service.get(opened["session_id"])
check("call keeps its voice", pinned.voice == record["id"])
second = client.post("/api/voice/session").json()
check("next call uses new voice", second["voice"] == "rain")
health = client.get("/api/health").json()
check("capabilities.voice stays false",
health["capabilities"]["voice"] is False)
# “重启”:同批文件重建服务,选择/元数据/试听凭据都读回
app2, _custom2 = build(tmp, state)
with TestClient(app2) as client2:
client2.post("/api/companion", json=CREATE)
selection = client2.get("/api/voice/selection").json()
listing = client2.get("/api/voice/voices").json()
entry = next(v for v in listing["voices"] if v["id"] == record["id"])
check("selection survives restart", selection["voice"] == "rain")
check("custom entry survives restart", entry["label"] == "她的声音"
and entry["previewed"] is True)
failed = [name for name, ok, _ in RESULTS if not ok]
print(f"\n{len(RESULTS) - len(failed)}/{len(RESULTS)} passed")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
+35 -13
View File
@@ -15,6 +15,7 @@ from typing import Literal
from uuid import UUID
from zoneinfo import ZoneInfo
import httpx
import yaml
from anyio import CancelScope
from fastapi import FastAPI, HTTPException, Query, Request
@@ -25,6 +26,7 @@ from pydantic import BaseModel, ConfigDict, Field, StrictInt, field_validator
from ..config import CharacterConfig, ModelsConfig
from ..contracts import LLMMessage
from ..contracts.text import safe_text
from ..core.bazi import (
GAN_ELEM,
Derived,
@@ -48,7 +50,13 @@ from ..memory.semantic import build_semantic
from ..memory.store import Store
from ..think.scenes import Scene
from ..think.wiring import build_router_from_config
from ..voice import VoiceSessionService, build_voice_router, load_voice_config
from ..voice import (
CustomVoiceConfig,
CustomVoiceService,
VoiceSessionService,
build_voice_router,
load_voice_config,
)
HOURS = [
"子时",
@@ -67,17 +75,6 @@ HOURS = [
ERROR = "她这会儿没接上话,稍后再试试。你写的话还在。"
def safe_text(value: str, maximum: int, label: str, required: bool = False) -> str:
value = value.strip()
if (
(required and not value)
or len(value) > maximum
or any(ord(c) < 32 or ord(c) == 127 or c in "<>" for c in value)
):
raise ValueError(f"{label}请填写{1 if required else 0}–{maximum}字,不含控制字符或尖括号。")
return value
class StrictBody(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
@@ -182,6 +179,7 @@ def create_app(
semantic_mode: str | None = None,
semantic_config: dict | None = None,
voice_service=None,
custom_voice_service=None,
) -> FastAPI:
"""Dependencies can be injected for isolated fault/cancellation tests, never via browser flags."""
db_path = db_path or os.environ.get(
@@ -291,6 +289,8 @@ def create_app(
await provider.aclose()
if getattr(app.state, "voice_service", None):
await app.state.voice_service.aclose()
if getattr(app.state, "custom_voice_service", None):
await app.state.custom_voice_service.aclose()
store.close()
finally:
app.state.lifespan_active = False
@@ -315,8 +315,30 @@ def create_app(
Path(db_path).stem + "-voice-sessions.json"),
) if voice_config is not None else None
)
# Custom voice service (HEL-899) rides the same config: same instance
# data space (selection/registry/journal beside the DB), dedicated
# HTTP client because the .12 voice panel is https with a self-signed
# cert the API must not fail to trust server-to-server.
if voice_config is not None:
in_memory = str(db_path) == ":memory:"
custom_voice_service = CustomVoiceService(
config=CustomVoiceConfig(
panel_url=voice_config.panel_url,
tts_url=voice_config.tts_url,
registry_file=voice_config.registry_file,
worker_state_file=voice_config.worker_state_file,
),
http=httpx.AsyncClient(verify=False, timeout=httpx.Timeout(30, read=120)),
selection_path=None if in_memory else Path(db_path).with_name(
Path(db_path).stem + "-voice-selection.json"),
registry_path=None if in_memory else Path(db_path).with_name(
Path(db_path).stem + "-voice-custom.json"),
journal_path=None if in_memory else Path(db_path).with_name(
Path(db_path).stem + "-voice-usage.jsonl"),
)
app.state.voice_service = voice_service
app.include_router(build_voice_router(voice_service))
app.state.custom_voice_service = custom_voice_service
app.include_router(build_voice_router(voice_service, custom_voice_service))
@app.exception_handler(RequestValidationError)
async def invalid(_request, exc):
+14
View File
@@ -0,0 +1,14 @@
"""跨层共享的纯文本校验(唯一实现,API 与语音模块共用)."""
from __future__ import annotations
def safe_text(value: str, maximum: int, label: str, required: bool = False) -> str:
value = value.strip()
if (
(required and not value)
or len(value) > maximum
or any(ord(c) < 32 or ord(c) == 127 or c in "<>" for c in value)
):
raise ValueError(f"{label}请填写{1 if required else 0}–{maximum}字,不含控制字符或尖括号。")
return value
+4
View File
@@ -19,6 +19,10 @@ def __getattr__(name: str): # lazy: API side must import without livekit instal
from . import session
return getattr(session, name)
if name in {"CustomVoiceConfig", "CustomVoiceService"}:
from . import custom
return getattr(custom, name)
if name == "build_voice_router":
from . import router
+495
View File
@@ -0,0 +1,495 @@
"""参考音频自定义声音与专属音色选择(HEL-899,CyberGril 统一 API 侧).
复用既有能力,不自建克隆引擎(GOV-006):
* 音色登记 = .12 语音面板 ``POST /api/voices/upload``(写共享 ``voices.json``
+ CosyVoice mtime 热加载;参考文本留空时面板用 FunASR 自动转写)。
本模块是它的**服务端代理**:浏览器永远不直连 8090,凭据/共享状态不进浏览器,
共享 ``voices.json`` 也只有 8090 这一个写者(跨进程读-改-写不再加竞态)。
* 试听 = CosyVoice ``POST /tts``(返回 24k mono int16 PCM),本模块包成 WAV
给浏览器;每次试听都是新合成,不缓存旧音频冒充本次结果。
* 应用 = 写 CyberGril 自己的音色状态(worker 的
``voice_state_cybergril.json``),通话开启时由 router 捕获并落同一文件,
worker 在会话入口读一次——通话中改选最迟下一通话生效,绝不半句换人。
HEL-904 起,capture 与 worker 实读之间的竞态以「通话窗口持有」收口:
活跃通话期间选择状态的 worker 写入暂缓(见 ``hold_call_voice``)。
命名空间:新增音色一律 ``cg-`` + 8 位十六进制(服务端生成,不收用户起名),
不可能撞上/覆盖共享注册表里的既有 11 支音色,也不会互相覆盖。显示名
(label,可中文)只存在 CyberGril 自己的资料空间,不进共享注册表。
共享注册表涉及的操作(全部经 8090,本服务不直接改共享文件):
1. ``GET /api/voices`` 读列表(只读);2. ``POST /api/voices/upload`` 以
``cg-`` 专属 ID 登记新参考音频(唯一写入);3. 登记成功后**只读**回看
``voices.json``(部署配置的路径)补全 ASR 自动转写的参考文本,供备份清单。
通用助手自己的 ``voice_state.json`` / ``persona_state.json`` 与既有音色
条目从不触碰,也不通过改全局默认偷换她的声音。
持久化(CyberGril 资料空间,实例数据库旁,与语音清理账同模式):
``<db>-voice-selection.json``(当前选择,重启读回)、
``<db>-voice-custom.json``(自定义音色元数据 + 备份清单)、
``<db>-voice-usage.jsonl``(ASR/TTS 资源使用流水,可核算;有界保留)。
"""
from __future__ import annotations
import asyncio
import contextlib
import io
import json
import secrets
import struct
import time
import wave
from dataclasses import dataclass, field
from pathlib import Path
import httpx
from ..contracts.text import safe_text
CUSTOM_PREFIX = "cg-"
ID_HEX_CHARS = 8
MAX_UPLOAD_BYTES = 15 * 1024 * 1024 # 与 8090 面板一致
MIN_UPLOAD_BYTES = 16000 * 2 # 与 8090 面板一致(<2s @16k mono int16 的字节代理)
MIN_SECONDS = 2.0 # 参考音频有效时长下限(比面板的字节检查更诚实)
PREVIEW_TEXT_MAX = 200
LABEL_MAX = 20
PROMPT_TEXT_MAX = 500
JOURNAL_KEEP_LINES = 2000
JOURNAL_PRUNE_ABOVE = 5000
DEFAULT_PREVIEW_TEXT = "你好,这是我的声音,以后就用它陪你说话。"
STATE_VERSION = 1
class RegistryUnavailable(Exception):
"""8090 面板或 CosyVoice 暂不可用——如实上抛,不假装登记/试听成功。"""
class StateWriteFailed(Exception):
"""音色状态文件写不进——选择未成立,已整体回滚,不留下半状态。"""
class RegistrationFailed(Exception):
"""面板拒绝或返回异常——登记未成立,不留下半条可选项。"""
class LocalRecordFailed(Exception):
"""共享登记已成功、本地元数据落盘失败(HEL-904).
面板侧登记**不回滚**(也回滚不了):声音以编号留在共享注册表里,
刷新列表即可找回并继续试听/应用,绝不要求重复登记。
"""
@dataclass
class CustomVoiceConfig:
panel_url: str = "https://127.0.0.1:8090"
tts_url: str = "http://127.0.0.1:9880"
# 只读回看(ASR 参考文本补全);不配置则清单里该字段如实留空
registry_file: str | None = None
# CyberGril 专属 worker 的音色状态文件(worker 在会话入口读一次)
worker_state_file: str = "/opt/voice-agent-cybergril/voice_state_cybergril.json"
def _atomic_write_json(path: Path, payload: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(path)
def parse_wav_header(data: bytes) -> dict:
"""最小 WAV 头解析(RIFF/WAVE + fmt/data 定位),用于服务端复验.
只做诚实的格式事实读取:magic、声道数、采样率、位深、data 长度、时长。
不信任任何客户端声明,也不接受用户文件路径(输入只有上传字节)。
"""
if len(data) < 44 or data[:4] != b"RIFF" or data[8:12] != b"WAVE":
raise ValueError("只支持 WAV 格式的参考音频。")
pos, channels, rate, bits, data_bytes = 12, None, None, None, 0
while pos + 8 <= len(data):
chunk_id = data[pos:pos + 4]
(size,) = struct.unpack_from("<I", data, pos + 4)
body = pos + 8
if chunk_id == b"fmt ":
if size < 16 or body + 16 > len(data):
raise ValueError("WAV 头不完整。")
fmt_code, channels, rate, _, _, bits = struct.unpack_from("<HHIIHH", data, body)
if fmt_code not in (1, 3, 0xFFFE):
raise ValueError("只支持 PCM/IEEE float WAV。")
elif chunk_id == b"data":
data_bytes = min(size, len(data) - body)
pos = body + size + (size & 1)
if not channels or not rate or not bits:
raise ValueError("WAV 头缺少格式信息。")
byte_rate = max(rate * channels * bits // 8, 1)
return {"channels": channels, "sample_rate": rate, "bits": bits,
"data_bytes": data_bytes, "seconds": data_bytes / byte_rate}
def pcm_to_wav(pcm: bytes, sample_rate: int = 24000, channels: int = 1, width: int = 2) -> bytes:
"""CosyVoice 的 audio/L16 裸 PCM 包成浏览器可播的 WAV(无缓存,每次现包)。"""
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav:
wav.setnchannels(channels)
wav.setsampwidth(width)
wav.setframerate(sample_rate)
wav.writeframes(pcm)
return buffer.getvalue()
@dataclass
class VoiceSelection:
voice: str | None = None
label: str = ""
updated_at: int = 0
@dataclass
class CustomVoiceService:
"""统一 API 的自定义音色门面(一个实例一份文件,进程内锁串行化写)."""
config: CustomVoiceConfig = field(default_factory=CustomVoiceConfig)
http: httpx.AsyncClient = field(default_factory=httpx.AsyncClient)
clock: object = time.time
selection_path: Path | str | None = None
registry_path: Path | str | None = None # 自定义音色元数据(备份清单同源)
journal_path: Path | str | None = None
def __post_init__(self) -> None:
self._lock = asyncio.Lock()
self._selection = self._load_selection()
self._custom: dict[str, dict] = self._load_custom()
self._call_voice_holds: dict[str, int] = {}
self._prune_journal()
# -- 持久化(CyberGril 资料空间;重启读回) ------------------------------
def _load_selection(self) -> VoiceSelection:
if not self.selection_path:
return VoiceSelection()
try:
data = json.loads(Path(self.selection_path).read_text(encoding="utf-8"))
return VoiceSelection(voice=data.get("voice") or None,
label=str(data.get("label", "")),
updated_at=int(data.get("updated_at", 0)))
except (OSError, ValueError):
return VoiceSelection()
def _load_custom(self) -> dict[str, dict]:
if not self.registry_path:
return {}
try:
data = json.loads(Path(self.registry_path).read_text(encoding="utf-8"))
return dict(data.get("voices", {})) if data.get("version") == STATE_VERSION else {}
except (OSError, ValueError):
return {}
def _persist_selection(self) -> None:
if self.selection_path:
_atomic_write_json(Path(self.selection_path), {
"version": STATE_VERSION,
"voice": self._selection.voice,
"label": self._selection.label,
"updated_at": self._selection.updated_at,
})
def _persist_custom(self) -> None:
if self.registry_path:
_atomic_write_json(Path(self.registry_path), {
"version": STATE_VERSION, "voices": self._custom,
})
# -- 资源使用流水(可核算;ASR/TTS 才记,选择/列出不耗资源不记) ----------
def _journal(self, kind: str, voice: str) -> None:
if not self.journal_path:
return
try:
with Path(self.journal_path).open("a", encoding="utf-8") as fh:
fh.write(json.dumps({"ts": int(self.clock() * 1000),
"kind": kind, "voice": voice}, ensure_ascii=False) + "\n")
except OSError:
pass # 流水尽力而为;核算缺失不影响功能诚实性(文档已注明)
def _prune_journal(self) -> None:
if not self.journal_path:
return
try:
lines = Path(self.journal_path).read_text(encoding="utf-8").splitlines()
except OSError:
return
if len(lines) > JOURNAL_PRUNE_ABOVE:
kept = [ln for ln in lines[-JOURNAL_KEEP_LINES:] if ln.strip()]
tmp = Path(self.journal_path).with_suffix(".jsonl.tmp")
tmp.write_text("\n".join(kept) + "\n", encoding="utf-8")
tmp.replace(self.journal_path)
def previewed_voices(self) -> set[str]:
"""已成功试听过的音色(持久流水读回;重启不丢「应用前有可听试听」凭据)."""
if not self.journal_path:
return set()
try:
lines = Path(self.journal_path).read_text(encoding="utf-8").splitlines()
except OSError:
return set()
seen: set[str] = set()
for line in lines:
try:
entry = json.loads(line)
except ValueError:
continue
if entry.get("kind") == "preview_tts":
seen.add(str(entry.get("voice", "")))
return seen
# -- 共享注册表(8090 面板,唯一写者) ------------------------------------
async def registry_list(self) -> list[str]:
try:
response = await self.http.get(
f"{self.config.panel_url.rstrip('/')}/api/voices", timeout=5.0)
response.raise_for_status()
return sorted(response.json().get("voices", []))
except Exception as exc:
raise RegistryUnavailable(f"voices list: {type(exc).__name__}") from exc
def _read_registry_entry(self, voice_id: str) -> dict | None:
"""只读回看共享 voices.json(部署配置路径)补全 ASR 参考文本;失败留空."""
path = self.config.registry_file
if not path:
return None
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
entry = data.get(voice_id)
return dict(entry) if isinstance(entry, dict) else None
except (OSError, ValueError):
return None
async def register(self, *, data: bytes, label: str, prompt_text: str,
allow_asr: bool) -> dict:
"""登记参考音频:服务端复验 → cg- 专属 ID → 面板 zero-shot 注册.
失败不留下半条可选项:面板拒绝/异常时不写任何 CyberGril 元数据,
面板自己对失败的 wav 有清理(ASR 失败即删,见其源码)。共享登记
成功而本地元数据落盘失败时抛 :class:`LocalRecordFailed`(503 中文
提示):共享登记不回滚、按编号可恢复,绝不逼重复登记;进程内
记录保留可用,后续任一次成功登记会把整份元数据(含这一条)一并
写回(自愈)。响应断连后重试会另立新 cg- 编号,共享注册表留下
无元数据孤儿条目(列表以编号显示、可正常使用)——实际行为如此
如实声明,不自动删除、不代管 8090 自有的删除入口.
"""
if not data:
raise RegistrationFailed("请选择一段参考音频。")
if len(data) > MAX_UPLOAD_BYTES:
raise RegistrationFailed("文件过大(>15MB),请录 5–20 秒的一段。")
try:
header = parse_wav_header(data) # 非法格式在这里被拒
label = safe_text(label or "", LABEL_MAX, "声音名字") or ""
prompt_text = (safe_text(prompt_text or "", PROMPT_TEXT_MAX, "参考文字") or "").strip()
except ValueError as exc:
raise RegistrationFailed(str(exc)) from exc
if len(data) < MIN_UPLOAD_BYTES or header["seconds"] < MIN_SECONDS:
raise RegistrationFailed("参考音频太短(至少 2 秒),请录 5–20 秒。")
if not prompt_text and not allow_asr:
raise RegistrationFailed(
"请填写这段音频的参考文字,或明确选择让系统转写(会用到语音识别)。")
async with self._lock:
known = set(await self.registry_list())
for _ in range(6):
voice_id = CUSTOM_PREFIX + secrets.token_hex(ID_HEX_CHARS // 2)
if voice_id not in known and voice_id not in self._custom:
break
else: # pragma: no cover - 2^32 空间连撞 6 次属天文事件
raise RegistrationFailed("生成声音编号失败,请重试。")
files = {"file": (f"{voice_id}.wav", data, "audio/wav")}
form = {"name": voice_id, "prompt_text": prompt_text}
try:
response = await self.http.post(
f"{self.config.panel_url.rstrip('/')}/api/voices/upload",
data=form, files=files, timeout=60.0)
except Exception as exc:
raise RegistryUnavailable(f"voices upload: {type(exc).__name__}") from exc
if response.status_code >= 500:
raise RegistryUnavailable(f"voices upload: HTTP {response.status_code}")
if response.status_code >= 400:
# 面板拒绝(格式/大小/转写失败):登记未成立,如实透传原因
detail = ""
try:
detail = str(response.json().get("detail", ""))
except Exception:
detail = response.text[:120]
raise RegistrationFailed(detail or f"音色登记被拒绝(HTTP {response.status_code})。")
if voice_id not in set(await self.registry_list()):
raise RegistrationFailed("登记结果没有出现在音色列表里,请重试。")
entry = self._read_registry_entry(voice_id) or {}
record = {
"id": voice_id,
"label": label or voice_id,
"prompt_text": entry.get("prompt_text") or (prompt_text or None),
"prompt_text_source": "asr" if (not prompt_text and entry.get("prompt_text"))
else ("user" if prompt_text else None),
"prompt_wav": entry.get("prompt_wav"),
"registered_at": int(self.clock() * 1000),
}
if record["prompt_text_source"] == "asr":
self._journal("register_asr", voice_id) # 显式获准的 FunASR 转写,可核算
# 流水在本地元数据落盘之前记:面板侧转写真实发生了,即使
# 本地记录写不进,资源口径也必须计入(尽力而为,不因后续
# 失败抹掉已发生的用量)。
self._custom[voice_id] = record
try:
self._persist_custom()
except OSError as exc:
# 共享登记已成立(面板不回滚);只坏本地元数据。进程内记录
# 仍在(本进程可试听/应用),重启后元数据丢、共享注册表仍
# 列出该声音(label=ID)——按编号找回即可,不逼重复登记。
raise LocalRecordFailed(
f"{voice_id} 已经在音色登记服务登记成功(共享登记不会因为"
"本地记录失败而回滚);但本地记录这会儿写不进,列表里它会"
f"暂时只显示编号。刷新声音列表就能按编号 {voice_id} 找到它,"
"直接试听并应用就行,不需要重新登记。"
) from exc
return dict(record)
async def preview_pcm(self, *, voice: str, text: str | None) -> bytes:
"""试听:先验存在(CosyVoice 对未知音色会静默回落 default,必须拦住),
再现场合成一段新 PCM。绝不缓存、绝不拿旧音频冒充本次结果。"""
if not voice or not await self._voice_exists(voice):
raise KeyError(voice)
try:
words = safe_text(text or DEFAULT_PREVIEW_TEXT, PREVIEW_TEXT_MAX, "试听文字") \
or DEFAULT_PREVIEW_TEXT
except ValueError as exc:
raise RegistrationFailed(str(exc)) from exc
try:
response = await self.http.post(
f"{self.config.tts_url.rstrip('/')}/tts",
json={"text": words, "voice": voice}, timeout=120.0)
except Exception as exc:
raise RegistryUnavailable(f"tts: {type(exc).__name__}") from exc
if response.status_code >= 400:
raise RegistryUnavailable(f"tts: HTTP {response.status_code}")
pcm = response.content or b""
if not pcm:
raise RegistryUnavailable("tts: empty audio")
self._journal("preview_tts", voice)
return pcm
async def _voice_exists(self, voice: str) -> bool:
return voice in set(await self.registry_list())
# -- 专属音色选择(换声音只换发声;人格/记忆/关系一概不动) ----------------
def selection(self) -> VoiceSelection:
return VoiceSelection(self._selection.voice, self._selection.label,
self._selection.updated_at)
def custom_voices(self) -> dict[str, dict]:
return {vid: dict(rec) for vid, rec in self._custom.items()}
def read_worker_voice(self) -> str:
"""worker 当前状态文件里的音色(部署/D1 域;无人经 API 选择时不代写)."""
path = Path(self.config.worker_state_file)
try:
return str(json.loads(path.read_text(encoding="utf-8")).get("voice", "")) or ""
except (OSError, ValueError):
return ""
def write_worker_voice(self, voice: str) -> None:
"""原子写 worker 音色状态;文件可解析(JSON 对象)时保留其他键.
实况(HEL-902 审核 §4 核出,合同 §3 同步注明):文件读不出
(损坏/非 JSON/非对象)时会**整体重写**为仅含 ``voice`` 的新状态,
旧键此时无法保留——不得宣称任何情况下都保留其他键。正常可读
文件的其他键原样保留;任何失败抛 OSError,不半写.
"""
path = Path(self.config.worker_state_file)
try:
state = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(state, dict):
state = {}
except (OSError, ValueError):
state = {}
state["voice"] = voice
_atomic_write_json(path, state)
# -- 通话音色定住(HEL-904:修 capture 与 worker 实读之间的竞态) --------
# worker(另一进程)在自己的会话入口读一次状态文件,读时点对 API 进程
# 不可知——dispatch 返回 ≠ worker 已读(HEL-902 审核 §4 残余竞态)。
# 等价的「会话不可变快照」:把整个通话窗口(capture→挂断/到期)都当作
# 「worker 可能还没读」。通话期间选择状态的 worker 写入全部暂缓,
# 状态文件定在本通话捕获值上;worker 无论何时来读,读到的都是本
# 通话响应里报告的那个声音。暂缓的写入不需要显式补写:下一次通话
# 的 capture 总会按当前选择重写(从未选择则不代写,维持不代写边界)。
def hold_call_voice(self, voice: str) -> None:
"""登记一个活跃通话捕获的音色(开房 capture 时调用,先于任何 await)."""
if voice:
self._call_voice_holds[voice] = self._call_voice_holds.get(voice, 0) + 1
def release_call_voice(self, voice: str) -> None:
"""通话结算(挂断/到期/开房失败或取消)时释放对应持有."""
count = self._call_voice_holds.get(voice, 0) - 1
if count > 0:
self._call_voice_holds[voice] = count
else:
self._call_voice_holds.pop(voice, None)
def call_voice_held(self) -> bool:
"""是否存在活跃通话持有(True 时应用只记选择、不写 worker 文件)."""
return bool(self._call_voice_holds)
async def apply_selection(self, *, voice: str) -> VoiceSelection:
"""把她后续通话的发声换成指定音色(最迟下一通话生效).
自定义(cg-)音色必须先成功试听过(「应用前有可听试听」);内置
音色是既有 D1 候选,不强制。本地选择状态原子落盘;worker 状态
文件**在没有活跃通话时**同步原子写入,任一失败整体回滚,绝不留
下两边不一致的半状态。有活跃通话时(HEL-904)worker 文件定在
该通话捕获值上——本次只更新选择,worker 文件不动(worker 入口
读时点不可知,整通话窗口视为未读);新音色由下一通话的 capture
重写生效,仍是「最迟下一通话生效」,绝不半句换人。重叠通话属
单用户产品的退化场景:较新通话的 capture 会重写文件,较早通话
若其 worker 迟迟未读,可能读到较新捕获值(每个 worker 仍整通话
单一音色,无半句换人)——如实记录,不在本接口内伪装消除。
"""
if not voice or not await self._voice_exists(voice):
raise KeyError(voice)
if voice.startswith(CUSTOM_PREFIX) and voice not in self.previewed_voices():
raise PermissionError(voice)
async with self._lock:
previous = VoiceSelection(self._selection.voice, self._selection.label,
self._selection.updated_at)
record = self._custom.get(voice)
wrote_worker = False
try:
self._selection = VoiceSelection(
voice=voice,
label=(record["label"] if record else voice),
updated_at=int(self.clock() * 1000))
self._persist_selection()
if not self._call_voice_holds: # 无活跃通话才写透 worker 文件
wrote_worker = True
self.write_worker_voice(voice)
except OSError as exc:
self._selection = previous
with contextlib.suppress(OSError):
self._persist_selection() # 回滚本地状态
if wrote_worker and previous.voice:
self.write_worker_voice(previous.voice)
raise StateWriteFailed(f"voice state write: {type(exc).__name__}") from exc
return VoiceSelection(self._selection.voice, self._selection.label,
self._selection.updated_at)
def manifest(self) -> dict:
"""备份清单(CG-009 承接用):参考音频路径/参考文本/映射 + 当前选择."""
return {
"version": STATE_VERSION,
"selection": {"voice": self._selection.voice, "label": self._selection.label},
"custom_voices": [dict(rec) for rec in
sorted(self._custom.values(), key=lambda r: r["registered_at"])],
"shared_registry_note": (
"自定义音色登记在 .12 共享 voices.json(8090 面板写入)与 "
"voice_samples/<id>.wav;恢复时按 prompt_text + prompt_wav 重新登记即可。"
),
}
async def aclose(self) -> None:
await self.http.aclose()
+207 -4
View File
@@ -4,7 +4,20 @@ POST /api/voice/session open a room, dispatch the dedicated worker,
return a short-lived join token (keys stay
server-side); honest refusal when voice is
not configured, no companion exists yet or
TTS is down.
TTS is down. HEL-899: the effective voice is
captured here (selection if still valid,
else the worker file's value, else default)
and pinned for THIS call — changes applied
mid-call take effect at the NEXT call at
the latest, never mid-sentence. HEL-904:
the capture also HOLDS the worker-state
value for the whole call window (worker
reads that file once at its own session
entry, at an unknowable time — a dispatch
response is NOT proof it has read); any
apply during a live call defers its worker
write, so response voice == the voice the
worker actually reads for this call.
DELETE /api/voice/session/{id} end: delete the LiveKit room, which
disconnects browser and worker, ends the
agent job and releases the microphone.
@@ -15,27 +28,103 @@ DELETE /api/voice/session/{id} end: delete the LiveKit room, which
GET /api/voice/status honest switch state for the frontend
(configured / tts reachable / agent name).
Custom voice protocol (HEL-899) — the browser never talks to the .12 voice
panel or CosyVoice directly; everything goes through this API:
GET /api/voice/voices list builtin + her custom voices (labels,
selection/preview state, backup manifest
fields for custom entries).
POST /api/voice/voices register a reference audio: raw WAV bytes
as the request body (no multipart — the
project takes no new dependency for one
endpoint), label/prompt_text/allow_asr as
query parameters; allow_asr explicitly
lets the existing FunASR transcribe when
no reference text is given.
POST /api/voice/preview synthesize a FRESH sample with a voice and
return audio/wav (no caching, no chat/
memory/receipt writes).
GET /api/voice/selection read her persisted voice selection.
POST /api/voice/selection apply a voice to her subsequent calls
(custom voices require a successful preview
first; builtin D1 candidates do not). While
a call is live the worker-state write is
deferred (HEL-904): the selection is
recorded, the file stays pinned to the
active call's captured voice.
capabilities.voice in /api/health stays false: R5 is not real-accepted yet and
this endpoint never claims end-to-end voice availability.
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
import asyncio
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response
from .custom import (
CustomVoiceService,
LocalRecordFailed,
RegistrationFailed,
RegistryUnavailable,
StateWriteFailed,
pcm_to_wav,
)
from .session import CleanupAbandoned, VoiceSessionService
REFUSAL_NOT_CONFIGURED = "语音还没有在这台服务上配置,不能假装能通话。"
REFUSAL_NO_COMPANION = "还没有创建真实伴侣,语音也不会开始。"
REFUSAL_TTS_DOWN = "她的声音服务这会儿不在,稍后再试试。"
REFUSAL_LIVEKIT_DOWN = "通话服务暂时连不上,稍后再试试。"
REFUSAL_PANEL_DOWN = "音色登记服务这会儿不在,稍后再试试。"
REFUSAL_VOICE_STATE_WRITE = "她的音色状态这会儿写不进,这次没有换成功。"
REFUSAL_CLEANUP_ABANDONED = (
"这次通话的房间还没有删净,清理重试已达上限;已登记待人工收尾,请保留挂断状态。"
)
def build_voice_router(service: VoiceSessionService | None) -> APIRouter:
def _capture_voice(service: VoiceSessionService, custom: CustomVoiceService | None) -> str:
"""通话开启时的有效音色(HEL-899):选择有效→选择;否则 worker 文件现值;
再无效→ default。用户从未经 API 选择时不改写 worker 文件(D1 域不代定)。
HEL-904:捕获值同时登记「通话持有」(先于本函数返回、无 await 间隙)。
worker 在另一进程的会话入口读一次状态文件、读时点不可知(dispatch
返回 ≠ 已读),因此整个通话窗口都按「尚未读」对待:期间应用的
worker 写入暂缓,状态文件定在本通话捕获值上——响应里的 voice 与
worker 实际读到并使用的声音一致;通话中改选只影响下一通。
"""
voices = service.last_health_voices()
def valid(candidate: str) -> bool:
return bool(candidate) and (voices is None or candidate in voices)
if custom is not None:
selection = custom.selection()
if selection.voice:
captured = selection.voice if valid(selection.voice) else "default"
try:
custom.write_worker_voice(captured) # 本通话用捕获值;worker 入口读一次
except OSError:
raise HTTPException(503, REFUSAL_VOICE_STATE_WRITE) from None
custom.hold_call_voice(captured)
return captured
current = custom.read_worker_voice()
captured = current if valid(current) else "default"
custom.hold_call_voice(captured) # 不代写,也要定住现值供本通话
return captured
return ""
def build_voice_router(service: VoiceSessionService | None,
custom: CustomVoiceService | None = None) -> APIRouter:
router = APIRouter(prefix="/api/voice", tags=["voice"])
if service is not None and custom is not None:
# HEL-904:会话结算(挂断/到期/转欠账)→ 释放该通话的音色持有,
# 之后的应用立即写透 worker 文件。capture 时已持有的窗口由此收口。
service.on_session_settled = (
lambda session: custom.release_call_voice(session.voice))
@router.get("/status")
async def status() -> dict:
@@ -59,9 +148,18 @@ def build_voice_router(service: VoiceSessionService | None) -> APIRouter:
raise HTTPException(409, REFUSAL_NO_COMPANION)
if not await service.tts_available():
raise HTTPException(503, REFUSAL_TTS_DOWN)
captured = _capture_voice(service, custom)
try:
session = await service.open_session(epoch=state.epoch)
session = await service.open_session(epoch=state.epoch, voice=captured)
except asyncio.CancelledError:
# 请求被取消(客户端断开等):会话未成立,释放通话持有,
# 不让一次取消把后续应用无限暂缓。
if custom is not None:
custom.release_call_voice(captured)
raise
except Exception:
if custom is not None:
custom.release_call_voice(captured) # 开房失败:持有随请求结算
raise HTTPException(503, REFUSAL_LIVEKIT_DOWN) from None
return {
"url": service.config.livekit_url,
@@ -71,6 +169,7 @@ def build_voice_router(service: VoiceSessionService | None) -> APIRouter:
"agent_name": service.config.agent_name,
"epoch": session.epoch,
"expires_at": int(session.expires_at * 1000),
"voice": session.voice,
}
@router.delete("/session/{session_id}")
@@ -101,4 +200,108 @@ def build_voice_router(service: VoiceSessionService | None) -> APIRouter:
raise HTTPException(404, "这次通话已经不在了。")
return {"ended": True, "room": session.room}
# -- custom voices (HEL-899) --------------------------------------------
@router.get("/voices")
async def voices() -> dict:
if custom is None:
return {"configured": False, "voices": [], "selection": None}
try:
listed = await custom.registry_list()
except RegistryUnavailable:
raise HTTPException(503, REFUSAL_PANEL_DOWN) from None
previewed = custom.previewed_voices()
selection = custom.selection()
records = custom.custom_voices()
items = []
for voice_id in listed:
record = records.get(voice_id)
items.append({
"id": voice_id,
"label": record["label"] if record else voice_id,
"source": "custom" if voice_id.startswith("cg-") else "builtin",
"selected": selection.voice == voice_id,
"previewed": voice_id in previewed,
"registered_at": record["registered_at"] if record else None,
# 备份清单字段(CG-009 承接):自定义条目带参考文本与音频位置
"prompt_text": record["prompt_text"] if record else None,
"prompt_wav": record["prompt_wav"] if record else None,
})
return {
"configured": True,
"voices": items,
"selection": {"voice": selection.voice, "label": selection.label,
"updated_at": selection.updated_at or None},
}
@router.post("/voices", status_code=201)
async def register_voice(request: Request, label: str = "", prompt_text: str = "",
allow_asr: bool = False) -> dict:
if custom is None:
raise HTTPException(503, REFUSAL_NOT_CONFIGURED)
if not request.app.state.store.query_one("SELECT id FROM companion"):
raise HTTPException(409, REFUSAL_NO_COMPANION)
# 请求体就是参考音频原始字节(audio/wav/octet-stream);登记参数走查询串,
# 不引入 multipart 依赖。录音/文件内容照旧服务端复验,不信任任何声明。
data = await request.body()
try:
record = await custom.register(data=data, label=label,
prompt_text=prompt_text, allow_asr=allow_asr)
except RegistrationFailed as exc:
raise HTTPException(422, str(exc)) from None
except LocalRecordFailed as exc:
# 共享登记已成功、本地元数据写不进:如实告知编号与恢复路径,
# 不裸 500,也不误导用户以为共享登记回滚了而去重复登记.
raise HTTPException(503, str(exc)) from None
except RegistryUnavailable:
raise HTTPException(503, REFUSAL_PANEL_DOWN) from None
return record
@router.post("/preview")
async def preview(body: dict) -> Response:
if custom is None:
raise HTTPException(503, REFUSAL_NOT_CONFIGURED)
voice = str(body.get("voice") or "") if isinstance(body, dict) else ""
text = body.get("text") if isinstance(body, dict) else None
if not voice:
raise HTTPException(422, "请指定要试听的声音。")
if text is not None and not isinstance(text, str):
raise HTTPException(422, "试听文字格式无效。")
try:
pcm = await custom.preview_pcm(voice=voice, text=text)
except KeyError:
raise HTTPException(404, "这个声音现在不在列表里。") from None
except RegistrationFailed as exc:
raise HTTPException(422, str(exc)) from None
except RegistryUnavailable:
raise HTTPException(503, REFUSAL_TTS_DOWN) from None
return Response(content=pcm_to_wav(pcm), media_type="audio/wav")
@router.get("/selection")
async def get_selection() -> dict:
if custom is None:
raise HTTPException(503, REFUSAL_NOT_CONFIGURED)
selection = custom.selection()
return {"voice": selection.voice, "label": selection.label,
"updated_at": selection.updated_at or None}
@router.post("/selection")
async def apply_selection(body: dict) -> dict:
if custom is None:
raise HTTPException(503, REFUSAL_NOT_CONFIGURED)
voice = str(body.get("voice") or "") if isinstance(body, dict) else ""
if not voice:
raise HTTPException(422, "请指定要应用的声音。")
try:
selection = await custom.apply_selection(voice=voice)
except KeyError:
raise HTTPException(404, "这个声音现在不在列表里。") from None
except PermissionError:
raise HTTPException(409, "这个声音还没有试听过,先试听再给她用。") from None
except RegistryUnavailable:
raise HTTPException(503, REFUSAL_PANEL_DOWN) from None
except StateWriteFailed:
raise HTTPException(503, REFUSAL_VOICE_STATE_WRITE) from None
return {"voice": selection.voice, "label": selection.label,
"updated_at": selection.updated_at}
return router
+54 -3
View File
@@ -65,6 +65,11 @@ class VoiceConfig:
token_ttl_seconds: float = DEFAULT_TOKEN_TTL
session_ttl_seconds: float = DEFAULT_SESSION_TTL
dispatch_timeout_seconds: float = 5.0
# HEL-899 自定义音色(复用 .12 语音面板与 CosyVoice;缺省即本机默认值)
panel_url: str = "https://127.0.0.1:8090"
tts_url: str = "http://127.0.0.1:9880"
registry_file: str | None = None # 共享 voices.json 只读回看(ASR 参考文本补全)
worker_state_file: str = "/opt/voice-agent-cybergril/voice_state_cybergril.json"
def sanitized(self) -> dict:
return {
@@ -116,6 +121,10 @@ def load_voice_config(
"api_key": os.environ.get("CYBERGRIL_VOICE_API_KEY"),
"api_secret": os.environ.get("CYBERGRIL_VOICE_API_SECRET"),
"tts_health_url": os.environ.get("CYBERGRIL_VOICE_TTS_HEALTH_URL"),
"panel_url": os.environ.get("CYBERGRIL_VOICE_PANEL_URL"),
"tts_url": os.environ.get("CYBERGRIL_VOICE_TTS_URL"),
"registry_file": os.environ.get("CYBERGRIL_VOICE_REGISTRY_FILE"),
"worker_state_file": os.environ.get("CYBERGRIL_VOICE_WORKER_STATE"),
}
values = {**data, **{k: v for k, v in overrides.items() if v}}
required = ("livekit_url", "livekit_internal", "api_key", "api_secret")
@@ -131,6 +140,11 @@ def load_voice_config(
tts_health_url=str(values.get("tts_health_url") or "http://127.0.0.1:9880/health"),
token_ttl_seconds=float(values.get("token_ttl_seconds") or DEFAULT_TOKEN_TTL),
session_ttl_seconds=float(values.get("session_ttl_seconds") or DEFAULT_SESSION_TTL),
panel_url=str(values.get("panel_url") or "https://127.0.0.1:8090"),
tts_url=str(values.get("tts_url") or "http://127.0.0.1:9880"),
registry_file=(str(values["registry_file"]) if values.get("registry_file") else None),
worker_state_file=str(values.get("worker_state_file")
or "/opt/voice-agent-cybergril/voice_state_cybergril.json"),
)
@@ -143,12 +157,13 @@ class VoiceSession:
expires_at: float
epoch: int
dispatch_id: str = ""
voice: str = "" # HEL-899: 本次通话捕获的有效音色(开启时定住,通话中改选不影响本通话)
def as_state(self) -> dict:
return {
"session_id": self.session_id, "room": self.room, "identity": self.identity,
"created_at": self.created_at, "expires_at": self.expires_at,
"epoch": self.epoch, "dispatch_id": self.dispatch_id,
"epoch": self.epoch, "dispatch_id": self.dispatch_id, "voice": self.voice,
}
@classmethod
@@ -158,6 +173,7 @@ class VoiceSession:
identity=str(data.get("identity", "")), created_at=float(data.get("created_at", 0)),
expires_at=float(data.get("expires_at", 0)), epoch=int(data.get("epoch", 0)),
dispatch_id=str(data.get("dispatch_id", "")),
voice=str(data.get("voice", "")),
)
@@ -193,6 +209,7 @@ class _CleanupDebt:
class _Health:
checked: float = 0.0
ok: bool = False
voices: list[str] | None = None # HEL-899: /health 顺带的热加载音色表(无则 None)
@dataclass
@@ -205,6 +222,12 @@ class VoiceSessionService:
cleanup_max_attempts: int = CLEANUP_MAX_ATTEMPTS
cleanup_backoff_seconds: float = CLEANUP_BACKOFF_SECONDS
cleanup_grace_seconds: float = CLEANUP_GRACE_SECONDS
# HEL-904: 可选回调 ``Callable[[VoiceSession], None]``。一个会话离开
# 活跃表(显式挂断、TTL 到期、或转入清理欠账)即「结算」:该通话
# worker 的入口读窗口关闭。router 用它释放自定义音色的通话持有
# (capture 与 worker 实读之间的竞态收口)。回调异常不结算失败拖垮
# 会话流程(持有多留一会儿只会让应用晚一拍写透,安全方向)。
on_session_settled: object = None
def __post_init__(self) -> None:
self._sessions: dict[str, VoiceSession] = {}
@@ -282,12 +305,36 @@ class VoiceSessionService:
try:
response = await self.http.get(self.config.tts_health_url, timeout=3.0)
ok = response.status_code // 100 == 2
voices: list[str] | None = None
if ok:
# CosyVoice /health = {"ok", "voices": [...]}(同请求内已热加载);
# 空体/异常体只当"探到了但没带名单",不推翻 ok 判定。
try:
listed = response.json().get("voices")
voices = sorted(str(v) for v in listed) if isinstance(listed, list) else None
except Exception:
voices = None
except Exception:
ok = False
self._tts_health = _Health(checked=self.clock(), ok=ok)
voices = None
self._tts_health = _Health(checked=self.clock(), ok=ok, voices=voices)
return ok
def last_health_voices(self) -> list[str] | None:
"""最近一次健康探测拿到的音色表(None=本次探测没带回名单)."""
return self._tts_health.voices
# -- lifecycle ----------------------------------------------------------
def _settle(self, session: VoiceSession) -> None:
"""A session left the active registry (ended, expired, or moved into
the cleanup ledger): its worker's entry-read window is closed. The
optional HEL-904 hook releases the custom-voice hold for that call."""
hook = self.on_session_settled
if hook is None:
return
# 见字段注释:持有释放失败只会让应用写透晚一拍,不破坏会话流程
with contextlib.suppress(Exception):
hook(session)
def _prune(self) -> None:
"""Expired registrations move into the cleanup ledger — never silently
dropped: their room stays owned until its bounded delete lands."""
@@ -295,6 +342,7 @@ class VoiceSessionService:
expired = [k for k, v in self._sessions.items() if v.expires_at < now]
for session_id in expired:
session = self._sessions.pop(session_id)
self._settle(session) # TTL 到期:入口读窗口关闭(HEL-904)
if session_id not in self._debts:
self._debts[session_id] = _CleanupDebt(session=session, first_debt_at=now)
if expired:
@@ -387,7 +435,7 @@ class VoiceSessionService:
this instance (bounded by the persisted ledger)."""
self._kick_cleanup()
async def open_session(self, *, epoch: int) -> VoiceSession:
async def open_session(self, *, epoch: int, voice: str = "") -> VoiceSession:
self._prune()
self._kick_cleanup()
alphabet = string.ascii_lowercase + string.digits
@@ -411,6 +459,7 @@ class VoiceSessionService:
expires_at=now + self.config.session_ttl_seconds,
epoch=epoch,
dispatch_id=dispatch.dispatch_id,
voice=voice,
)
self._sessions[session.session_id] = session
self._persist_state()
@@ -470,6 +519,7 @@ class VoiceSessionService:
# From now on the room lives in the ledger alone: _sessions no
# longer bypasses the shared cap on a later end call.
self._sessions.pop(session_id, None)
self._settle(session) # 已挂断(删房在欠账里补):入口读窗口关闭
if session_id not in self._debts:
self._debts[session_id] = debt
debt.attempts += 1
@@ -481,6 +531,7 @@ class VoiceSessionService:
self._kick_cleanup()
raise
self._sessions.pop(session_id, None)
self._settle(session) # 删房落定:本通话结算(HEL-904 释放音色持有)
self._debts.pop(session_id, None)
self._persist_state()
return session
+510
View File
@@ -0,0 +1,510 @@
"""Custom voice tests (HEL-899): reference-audio registration, preview,
selection persistence, call-time capture — all against the real router and
real services with fake panel/TTS transports. Zero network, zero real
LLM/embedding/ASR/TTS calls."""
from __future__ import annotations
import io
import json
import sys
import wave
from pathlib import Path
import httpx
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tests"))
from test_r2 import R2LLM # noqa: E402
from cybergril.api.app import create_app # noqa: E402
from cybergril.life.runtime import LifeConfig # noqa: E402
from cybergril.think.router import build_router # noqa: E402
from cybergril.think.scenes import Tier # noqa: E402
from cybergril.voice.custom import CustomVoiceConfig, CustomVoiceService # noqa: E402
from cybergril.voice.session import VoiceConfig, VoiceSessionService # noqa: E402
CREATE = {"name": "语音测试", "birth": "2000-10-18", "hour": "亥时", "relation": "朋友"}
BUILTIN = ["bailu", "bajie", "default", "female_soft", "haimianbaobao", "jiaojiao",
"male_warm", "nezha", "rain", "sunwukong", "xiaoyun"]
def make_wav(seconds: float = 3.0, rate: int = 16000, channels: int = 1) -> bytes:
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav:
wav.setnchannels(channels)
wav.setsampwidth(2)
wav.setframerate(rate)
wav.writeframes(b"\x00\x01" * int(rate * channels * seconds))
return buffer.getvalue()
class FakeVoiceStack:
""".12 语音面板(8090) + CosyVoice(9880) 的只读桩:记录每一次调用."""
def __init__(self, tmp: Path) -> None:
self.ids: list[str] = list(BUILTIN)
self.registry_file = tmp / "voices.json"
self.uploads: list[dict] = []
self.tts_calls: list[dict] = []
self.panel_down = False
self.tts_down = False
self.reject_upload = "" # 面板 400 detail(如"自动转写失败")
self.swallow = False # 谎报成功:回 ok 但不进列表(测登记后核验)
self.generic_voice_writes = 0 # POST /api/voice —— 必须永远是 0
self.deletes: list[str] = []
self.transport = httpx.MockTransport(self._handle)
def _registry(self) -> dict:
return json.loads(self.registry_file.read_text(encoding="utf-8")) \
if self.registry_file.exists() else {}
def _write_registry(self, data: dict) -> None:
self.registry_file.write_text(json.dumps(data, ensure_ascii=False, indent=1),
encoding="utf-8")
def _handle(self, request: httpx.Request) -> httpx.Response:
host, path = request.url.host, request.url.path
if host == "panel.test":
if self.panel_down:
raise httpx.ConnectError("panel down")
if path == "/api/voices" and request.method == "GET":
return httpx.Response(200, json={"voices": sorted(self.ids)})
if path == "/api/voices/upload":
name = self._form_name(request)
prompt_text = self._form_text(request)
if self.reject_upload:
return httpx.Response(400, json={"detail": self.reject_upload})
if not prompt_text: # 面板在参考文本为空时走 FunASR(桩:固定转写)
prompt_text = "这是自动转写出的参考文本,内容与音频一致。"
registry = self._registry()
registry[name] = {"prompt_text": prompt_text,
"prompt_wav": f"/opt/voice-agent/cosyvoice/voice_samples/{name}.wav"}
self._write_registry(registry)
self.uploads.append({"name": name, "prompt_text": prompt_text,
"bytes": len(request.content)})
if not self.swallow:
self.ids.append(name)
return httpx.Response(200, json={"ok": True, "voice": name})
if path.startswith("/api/voices/") and request.method == "DELETE":
self.deletes.append(path.rsplit("/", 1)[-1])
return httpx.Response(200, json={"ok": True})
if path == "/api/voice": # 通用助手自己的状态 —— CyberGril 绝不写
self.generic_voice_writes += 1
return httpx.Response(200, json={"ok": True})
return httpx.Response(404)
if host == "tts.test":
if path == "/health":
if self.tts_down:
return httpx.Response(503)
return httpx.Response(200, json={"ok": True, "voices": sorted(self.ids)})
if path == "/tts":
if self.tts_down:
return httpx.Response(503)
body = json.loads(request.content)
self.tts_calls.append(body)
marker = f"{body['voice']}|{body['text']}".encode()
return httpx.Response(200, content=marker + b"\x01\x00" * 240)
return httpx.Response(404)
return httpx.Response(404)
@staticmethod
def _form_name(request: httpx.Request) -> str:
return _multipart_field(request.content, b"name") or ""
@staticmethod
def _form_text(request: httpx.Request) -> str:
return _multipart_field(request.content, b"prompt_text") or ""
def _multipart_field(body: bytes, field: bytes) -> str | None:
"""极简 multipart 取字段(只为桩服务;产品代码不解析 multipart)."""
for part in body.split(b"--"):
header, _, value = part.partition(b"\r\n\r\n")
if field + b'"' in header:
return value.strip(b"\r\n-").decode()
return None
class FakeLiveKit:
def __init__(self) -> None:
self.dispatches: list[str] = []
self.deleted_rooms: list[str] = []
self.transport = httpx.MockTransport(self._handle)
def _handle(self, request: httpx.Request) -> httpx.Response:
if request.url.path == "/twirp/livekit.AgentDispatchService/CreateDispatch":
self.dispatches.append(request.url.path)
return httpx.Response(200, json={"id": "AD_1", "room": "r"})
if request.url.path == "/twirp/livekit.RoomService/DeleteRoom":
self.deleted_rooms.append(request.url.path)
return httpx.Response(200, content=b"")
return httpx.Response(404)
def build_app(tmp: Path, stack: FakeVoiceStack, livekit: FakeLiveKit | None = None):
livekit = livekit or FakeLiveKit()
voice = VoiceSessionService(
config=VoiceConfig(
livekit_url="ws://voice.test:7880", livekit_internal="http://livekit.test",
api_key="devkey", api_secret="test-secret-not-real",
tts_health_url="http://tts.test/health"),
http=httpx.AsyncClient(transport=httpx.MockTransport(
lambda request: stack._handle(request) if request.url.host == "tts.test"
else livekit._handle(request))))
worker_state = tmp / "voice_state_cybergril.json"
custom = CustomVoiceService(
config=CustomVoiceConfig(
panel_url="https://panel.test", tts_url="http://tts.test",
registry_file=str(stack.registry_file),
worker_state_file=str(worker_state)),
http=httpx.AsyncClient(transport=stack.transport, verify=False),
selection_path=tmp / "sel.json", registry_path=tmp / "custom.json",
journal_path=tmp / "usage.jsonl")
provider = R2LLM()
router = build_router(providers={t: provider for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)})
app = create_app(db_path=":memory:", router=router,
life_config=LifeConfig(enabled=True, poll_seconds=3600),
semantic_mode="stub", voice_service=voice,
custom_voice_service=custom)
return app, custom, worker_state
@pytest.fixture
def stack(tmp_path):
return FakeVoiceStack(tmp_path)
@pytest.fixture
def instance(tmp_path, stack):
from fastapi.testclient import TestClient
app, custom, worker_state = build_app(tmp_path, stack)
with TestClient(app) as client:
client.post("/api/companion", json=CREATE)
yield client, stack, custom, worker_state
def test_register_list_and_selection_flow(instance):
client, stack, custom, worker_state = instance
audio = make_wav(3)
registered = client.post(
"/api/voice/voices",
params={"label": "她的声音", "prompt_text": "这是参考文字"},
content=audio, headers={"Content-Type": "audio/wav"})
assert registered.status_code == 201
record = registered.json()
assert record["id"].startswith("cg-") and len(record["id"]) == 11
assert record["label"] == "她的声音"
assert record["prompt_text"] == "这是参考文字"
assert record["prompt_text_source"] == "user"
# 上传到达面板的是服务端生成的 cg- 专属 ID,不带用户文件名
assert stack.uploads[-1]["name"] == record["id"]
assert stack.uploads[-1]["bytes"] > 0
listing = client.get("/api/voice/voices").json()
by_id = {v["id"]: v for v in listing["voices"]}
assert set(by_id) == set(BUILTIN) | {record["id"]}
assert by_id[record["id"]]["source"] == "custom"
assert by_id[record["id"]]["label"] == "她的声音"
assert by_id["rain"]["source"] == "builtin"
assert listing["selection"]["voice"] is None
preview = client.post("/api/voice/preview", json={"voice": record["id"]})
assert preview.status_code == 200
assert preview.headers["content-type"] == "audio/wav"
with wave.open(io.BytesIO(preview.content)) as wav:
assert wav.getframerate() == 24000 and wav.getnchannels() == 1
assert stack.tts_calls[-1] == {"text": "你好,这是我的声音,以后就用它陪你说话。",
"voice": record["id"]}
applied = client.post("/api/voice/selection", json={"voice": record["id"]})
assert applied.status_code == 200 and applied.json()["voice"] == record["id"]
assert json.loads(worker_state.read_text())["voice"] == record["id"]
assert client.get("/api/voice/selection").json()["voice"] == record["id"]
listing = client.get("/api/voice/voices").json()
assert {v["id"] for v in listing["voices"] if v["selected"]} == {record["id"]}
# 备份清单字段在列表里(CG-009 承接)
entry = [v for v in listing["voices"] if v["id"] == record["id"]][0]
assert entry["prompt_wav"].endswith(record["id"] + ".wav")
def test_register_requires_explicit_asr_consent(instance):
client, stack, _, _ = instance
refusal = client.post("/api/voice/voices", content=make_wav(3))
assert refusal.status_code == 422
assert "转写" in refusal.json()["detail"] or "参考文字" in refusal.json()["detail"]
assert stack.uploads == [] # 未获明确准许,不触发任何 ASR/上传
allowed = client.post("/api/voice/voices", params={"allow_asr": "true"},
content=make_wav(3))
assert allowed.status_code == 201
assert allowed.json()["prompt_text_source"] == "asr"
journal = [json.loads(ln) for ln in
Path(instance[3].parent, "usage.jsonl").read_text().splitlines()]
assert any(e["kind"] == "register_asr" and e["voice"] == allowed.json()["id"]
for e in journal)
def test_register_rejects_bad_formats(instance):
client, stack, _, _ = instance
assert client.post("/api/voice/voices", params={"prompt_text": "x"},
content=b"ID3-not-a-wav").status_code == 422
assert client.post("/api/voice/voices", params={"prompt_text": "x"},
content=make_wav(1.0)).status_code == 422 # <2s
header_only = b"RIFF" + (9999).to_bytes(4, "little") + b"WAVE" + b"junk" * 10
assert client.post("/api/voice/voices", params={"prompt_text": "x"},
content=header_only).status_code == 422 # WAV magic 但头不完整
too_big = make_wav(2) + b"\x00" * (15 * 1024 * 1024 + 1)
response = client.post("/api/voice/voices", params={"prompt_text": "x"}, content=too_big)
assert response.status_code == 422 and "15MB" in response.json()["detail"]
assert stack.uploads == []
def test_register_ids_never_collide_with_builtin_or_each_other(instance, monkeypatch):
client, stack, _, _ = instance
first = client.post("/api/voice/voices", params={"prompt_text": "一"},
content=make_wav(2)).json()
second = client.post("/api/voice/voices", params={"prompt_text": "二"},
content=make_wav(2)).json()
assert first["id"] != second["id"]
assert not any(v.startswith("cg-") for v in BUILTIN)
# 强制撞名:第一次生成的 ID 已在注册表里 → 必须换新 ID,而不是覆盖
registry = stack._registry()
registry["cg-deadbeef"] = {"prompt_text": "旧条目", "prompt_wav": "/old.wav"}
stack._write_registry(registry)
stack.ids.append("cg-deadbeef") # 让首撞成立
calls = iter(["deadbeef", "cafef00d"])
monkeypatch.setattr("secrets.token_hex", lambda n: next(calls))
forced = client.post("/api/voice/voices", params={"prompt_text": "三"},
content=make_wav(2)).json()
assert forced["id"] == "cg-cafef00d"
assert stack._registry()["cg-deadbeef"]["prompt_text"] == "旧条目" # 未被覆盖
async def test_concurrent_registers_are_serialized_and_distinct(tmp_path, stack):
"""并发登记同名同音频:锁串行化 + 随机 ID,各自成立、互不覆盖."""
import asyncio
service = CustomVoiceService(
config=CustomVoiceConfig(panel_url="https://panel.test", tts_url="http://tts.test",
registry_file=str(stack.registry_file),
worker_state_file=str(tmp_path / "w.json")),
http=httpx.AsyncClient(transport=stack.transport, verify=False),
selection_path=tmp_path / "sel.json", registry_path=tmp_path / "custom.json",
journal_path=tmp_path / "usage.jsonl")
try:
records = await asyncio.gather(*[
service.register(data=make_wav(2), label="", prompt_text="并发", allow_asr=False)
for _ in range(4)])
ids = [record["id"] for record in records]
assert len(set(ids)) == 4 and all(v.startswith("cg-") for v in ids)
assert len(stack.uploads) == 4
assert set(service.custom_voices()) == set(ids)
finally:
await service.aclose()
def test_register_panel_down_or_rejecting_leaves_no_half_option(instance):
client, stack, custom, _ = instance
stack.panel_down = True
assert client.post("/api/voice/voices", params={"prompt_text": "x"},
content=make_wav(2)).status_code == 503
stack.panel_down = False
stack.reject_upload = "自动转写失败(boom), 请手动填写参考文本"
assert client.post("/api/voice/voices", params={"allow_asr": "true"},
content=make_wav(2)).status_code == 422
assert custom.custom_voices() == {}
stack.reject_upload = ""
# 面板谎报成功(列表里没出现)也要拒绝,不登记元数据
stack.swallow = True
lying = client.post("/api/voice/voices", params={"prompt_text": "x"},
content=make_wav(2))
assert lying.status_code in (422, 503)
assert custom.custom_voices() == {}
def test_preview_unknown_voice_never_reaches_tts(instance):
client, stack, _, _ = instance
response = client.post("/api/voice/preview", json={"voice": "ghost"})
assert response.status_code == 404
assert stack.tts_calls == [] # CosyVoice 对未知音色静默回落 default,必须前置拦住
assert client.post("/api/voice/preview", json={}).status_code == 422
def test_preview_tts_down_honest(instance):
client, stack, _, _ = instance
registered = client.post("/api/voice/voices", params={"prompt_text": "x"},
content=make_wav(2)).json()
stack.tts_down = True
response = client.post("/api/voice/preview", json={"voice": registered["id"]})
assert response.status_code == 503
assert "声音服务" in response.json()["detail"]
def test_preview_writes_no_chat_memory_or_receipts(instance):
client, stack, _, _ = instance
registered = client.post("/api/voice/voices", params={"prompt_text": "x"},
content=make_wav(2)).json()
app = client.app
counts = {table: app.state.store.query_one(f"SELECT COUNT(*) c FROM {table}")["c"]
for table in ("chat_message", "mem_episodic", "sharing_attempt", "life_event")}
assert client.post("/api/voice/preview",
json={"voice": registered["id"], "text": "自定义试听句"}).status_code == 200
assert stack.tts_calls[-1]["text"] == "自定义试听句"
after = {table: app.state.store.query_one(f"SELECT COUNT(*) c FROM {table}")["c"]
for table in counts}
assert after == counts
def test_apply_rules(instance):
client, stack, _, worker_state = instance
registered = client.post("/api/voice/voices", params={"prompt_text": "x"},
content=make_wav(2)).json()
# 自定义音色未试听 → 拒绝
unpreviewed = client.post("/api/voice/selection", json={"voice": registered["id"]})
assert unpreviewed.status_code == 409 and "试听" in unpreviewed.json()["detail"]
# 未知音色 → 404
assert client.post("/api/voice/selection", json={"voice": "ghost"}).status_code == 404
# 内置音色(D1 候选)不强制试听
builtin = client.post("/api/voice/selection", json={"voice": "rain"})
assert builtin.status_code == 200
assert json.loads(worker_state.read_text())["voice"] == "rain"
# 试听后自定义音色可应用
assert client.post("/api/voice/preview", json={"voice": registered["id"]}).status_code == 200
applied = client.post("/api/voice/selection", json={"voice": registered["id"]})
assert applied.status_code == 200 and applied.json()["label"] == registered["id"]
def test_apply_rollback_on_state_write_failure(tmp_path, stack):
from fastapi.testclient import TestClient
(tmp_path / "blocker").write_text("not a dir")
app, custom, _ = build_app(tmp_path, stack)
custom.config.worker_state_file = str(tmp_path / "blocker" / "worker.json")
with TestClient(app) as client:
client.post("/api/companion", json=CREATE)
assert client.post("/api/voice/selection", json={"voice": "rain"}).status_code == 503
assert custom.selection().voice is None
assert client.get("/api/voice/selection").json()["voice"] is None
def test_selection_persists_across_restart(tmp_path, stack):
from fastapi.testclient import TestClient
app, custom, worker_state = build_app(tmp_path, stack)
with TestClient(app) as client:
client.post("/api/companion", json=CREATE)
registered = client.post("/api/voice/voices",
params={"label": "留声", "prompt_text": "x"},
content=make_wav(2)).json()
client.post("/api/voice/preview", json={"voice": registered["id"]})
client.post("/api/voice/selection", json={"voice": registered["id"]})
# “重启”:同一批文件新建服务
app2, custom2, _ = build_app(tmp_path, stack)
with TestClient(app2) as client2:
client2.post("/api/companion", json=CREATE)
selection = client2.get("/api/voice/selection").json()
assert selection["voice"] == registered["id"]
listing = client2.get("/api/voice/voices").json()
entry = [v for v in listing["voices"] if v["id"] == registered["id"]][0]
assert entry["label"] == "留声" and entry["selected"] is True
assert entry["previewed"] is True # 试听凭据也在持久流水里
# 重启后无需重新试听即可再应用
other = client2.post("/api/voice/voices", params={"prompt_text": "y"},
content=make_wav(2)).json()
client2.post("/api/voice/preview", json={"voice": other["id"]})
assert client2.post("/api/voice/selection", json={"voice": other["id"]}).status_code == 200
assert json.loads(Path(worker_state).read_text())["voice"] == other["id"]
def test_session_captures_voice_and_pins_for_the_call(instance):
client, stack, custom, worker_state = instance
registered = client.post("/api/voice/voices", params={"prompt_text": "x"},
content=make_wav(2)).json()
client.post("/api/voice/preview", json={"voice": registered["id"]})
client.post("/api/voice/selection", json={"voice": registered["id"]})
opened = client.post("/api/voice/session").json()
assert opened["voice"] == registered["id"]
assert json.loads(worker_state.read_text())["voice"] == registered["id"]
service_session = client.app.state.voice_service.get(opened["session_id"])
assert service_session.voice == registered["id"]
# 通话中改选:本通话定住,最迟下一通话生效
client.post("/api/voice/selection", json={"voice": "rain"})
assert client.app.state.voice_service.get(opened["session_id"]).voice == registered["id"]
second = client.post("/api/voice/session").json()
assert second["voice"] == "rain"
def test_session_capture_falls_back_when_selection_invalid(instance):
client, stack, custom, worker_state = instance
registered = client.post("/api/voice/voices", params={"prompt_text": "x"},
content=make_wav(2)).json()
client.post("/api/voice/preview", json={"voice": registered["id"]})
client.post("/api/voice/selection", json={"voice": registered["id"]})
stack.ids.remove(registered["id"]) # 音色从注册表消失
client.app.state.voice_service._tts_health.checked = -1e9 # 重新探测拿新名单
opened = client.post("/api/voice/session").json()
assert opened["voice"] == "default" # 诚实回落,不冒充原音色
def test_session_without_selection_keeps_operator_voice(instance):
client, stack, custom, worker_state = instance
worker_state.write_text(json.dumps({"voice": "rain", "operator_note": "d1"}), encoding="utf-8")
opened = client.post("/api/voice/session").json()
assert opened["voice"] == "rain" # D1/部署域现值如实生效
assert json.loads(worker_state.read_text()) == {"voice": "rain", "operator_note": "d1"}
def test_shared_generic_state_never_touched(instance):
client, stack, custom, worker_state = instance
registered = client.post("/api/voice/voices", params={"prompt_text": "x"},
content=make_wav(2)).json()
client.post("/api/voice/preview", json={"voice": registered["id"]})
client.post("/api/voice/selection", json={"voice": registered["id"]})
client.post("/api/voice/session")
assert stack.generic_voice_writes == 0 # 通用助手 voice_state 从不写
assert stack.deletes == [] # 既有音色从不删
assert all(upload["name"].startswith("cg-") for upload in stack.uploads)
assert not any(v in stack._registry() for v in BUILTIN) # 11 支既有条目原样
def test_no_identity_rewrite(instance):
client, stack, custom, worker_state = instance
app = client.app
registered = client.post("/api/voice/voices", params={"prompt_text": "x"},
content=make_wav(2)).json()
client.post("/api/voice/preview", json={"voice": registered["id"]})
before = {
"identity": app.state.store.query_one("SELECT identity_json FROM companion WHERE id=1"),
"bazi": app.state.store.query_one("SELECT pillars,derived FROM bazi_core WHERE id=1"),
"messages": app.state.store.query("SELECT id,role,text,turn_id,status FROM chat_message"),
}
client.post("/api/voice/selection", json={"voice": registered["id"]})
client.post("/api/voice/session")
after = {
"identity": app.state.store.query_one("SELECT identity_json FROM companion WHERE id=1"),
"bazi": app.state.store.query_one("SELECT pillars,derived FROM bazi_core WHERE id=1"),
"messages": app.state.store.query("SELECT id,role,text,turn_id,status FROM chat_message"),
}
assert before == after # 换声音只换发声:底色/名字/关系/聊天/记忆一概不动
def test_unconfigured_custom_endpoints_honest(tmp_path):
from fastapi.testclient import TestClient
provider = R2LLM()
router = build_router(providers={t: provider for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)})
app = create_app(db_path=":memory:", router=router,
life_config=LifeConfig(enabled=False), semantic_mode="stub")
with TestClient(app) as client:
listing = client.get("/api/voice/voices")
assert listing.status_code == 200 and listing.json()["configured"] is False
for method, path, kwargs in (
("post", "/api/voice/voices", {"content": make_wav(2)}),
("post", "/api/voice/preview", {"json": {"voice": "rain"}}),
("post", "/api/voice/selection", {"json": {"voice": "rain"}})):
response = getattr(client, method)(path, **kwargs)
assert response.status_code == 503
assert client.get("/api/voice/selection").status_code == 503
+206
View File
@@ -0,0 +1,206 @@
"""HEL-902 统一审核:后台重点故障注入补核(总工独立新增).
针对总管列出的统一审核重点,用可控假服务注入故障,零真实调用:
1. 8090 已登记、CyberGril 本地元数据落盘失败 —— 是否留下无法恢复或重复注册的半状态。
(HEL-904 返修:503 中文映射 + 编号可恢复 + 重启断言 + 409 守门保持。)
2. 登记上传成功后面板响应断连(结果已生效、响应丢失)+ 用户重试 —— 共享注册表里出现什么。
3. 通话开启时 worker 音色状态写不进 —— 诚实拒绝且不开房。
4. worker 状态文件可读时其他键保留;文件损坏(读不出 JSON)时其他键是否被丢弃。
(HEL-904:该实况已补入 docstring 与合同,行为不变、如实记录。)
5. 试听流水写不进 —— 「应用前有可听试听」凭据缺失时应用是否守门(fail-closed)。
6. 选择状态落盘失败 —— 整体回滚,worker 文件不被写。
时间与真实性边界:worker 实际在会话入口读音色文件、与真实 worker 的时序一致性
属真机窗口验证范围(见 docs/rework/hel902/);本文件锁 API 侧可离线证明的事实,
capture→worker 实读竞态的确定性时序用例见 tests/test_voice_rework_hel904.py。
"""
import json
import httpx
import pytest
from test_voice_custom import ( # noqa: F401 (复用作者既有桩;同目录根式导入)
BUILTIN,
CREATE,
FakeVoiceStack,
build_app,
make_wav,
)
from cybergril.voice.custom import CustomVoiceService
def _register(client, **params):
return client.post("/api/voice/voices", params={"prompt_text": "参考文字", **params},
content=make_wav(3), headers={"Content-Type": "audio/wav"})
@pytest.fixture
def stack(tmp_path):
return FakeVoiceStack(tmp_path)
@pytest.fixture
def instance(tmp_path, stack):
"""同作者桩,但 raise_server_exceptions=False:故障路径要按产品语义看 500."""
from fastapi.testclient import TestClient
app, custom, worker_state = build_app(tmp_path, stack)
with TestClient(app, raise_server_exceptions=False) as client:
client.post("/api/companion", json=CREATE)
yield client, stack, custom, worker_state, tmp_path
def _swap_panel(custom, stack, handler):
transport = httpx.MockTransport(handler)
stack.transport = transport
custom.http = httpx.AsyncClient(transport=transport, verify=False)
def test_panel_ok_local_metadata_write_fails(instance):
"""8090 登记成功、本地元数据写不进(HEL-904 已修):不裸 500,中文提示
说清编号、共享登记未回滚、刷新可恢复;不逼重复注册;重启后按编号可用;
试听凭据 409 守门不放松."""
client, stack, custom, worker_state, tmp_path = instance
blocked = tmp_path / "blocked-custom.json"
blocked.mkdir()
custom.registry_path = blocked
response = _register(client)
assert response.status_code == 503 # OSError 映射为可恢复的 503 中文文案
detail = response.json()["detail"]
voice_id = stack.uploads[0]["name"]
assert len(stack.uploads) == 1 # 用户没被逼重复登记
assert voice_id in detail # 编号直接给到用户手里
assert "不会" in detail and "回滚" in detail # 不误称共享登记也回滚了
assert "刷新" in detail and "不需要重新登记" in detail # 恢复路径说清楚
# 面板已登记;进程内记录仍在:本进程里看得到、可试听可应用,不是死条目
listing = client.get("/api/voice/voices").json()
assert voice_id in {v["id"] for v in listing["voices"]}
assert client.post("/api/voice/preview", json={"voice": voice_id}).status_code == 200
assert client.post("/api/voice/selection", json={"voice": voice_id}).status_code == 200
assert client.get("/api/voice/selection").json()["voice"] == voice_id
# 「重启」后元数据确实丢失(无 label/时间),但共享注册表仍在,声音仍可列出使用
revived = CustomVoiceService(
config=custom.config, http=custom.http,
selection_path=tmp_path / "sel.json", registry_path=tmp_path / "custom2.json",
journal_path=tmp_path / "usage.jsonl")
assert voice_id in set(stack.ids)
assert voice_id not in revived.custom_voices() # 元数据丢了,声音没丢
assert not revived.call_voice_held()
# 重启恢复后按编号继续用:列表 label=编号;新音色未试听仍 409 守门
app2, custom2, worker2 = build_app(tmp_path, stack)
from fastapi.testclient import TestClient
with TestClient(app2) as client2:
client2.post("/api/companion", json=CREATE)
listing2_raw = client2.get("/api/voice/voices").json()
by_id = {v["id"]: v for v in listing2_raw["voices"]}
assert by_id[voice_id]["label"] == voice_id # 无元数据:以编号为名
assert client2.post("/api/voice/preview", json={"voice": voice_id}).status_code == 200
fresh = _register(client2).json()
assert client2.post("/api/voice/selection", json={"voice": fresh["id"]}).status_code == 409
assert client2.post("/api/voice/preview", json={"voice": fresh["id"]}).status_code == 200
assert client2.post("/api/voice/selection",
json={"voice": fresh["id"]}).status_code == 200
assert set(BUILTIN) <= set(by_id)
assert stack.generic_voice_writes == 0 and stack.deletes == []
def test_upload_response_lost_then_retry_leaves_orphan(instance):
"""上传在面板侧已生效但响应断连 → 503;重试另立新 ID,共享注册表留下无元数据孤儿条目."""
client, stack, custom, worker_state, tmp_path = instance
original = stack._handle
def flaky(request: httpx.Request) -> httpx.Response:
if request.url.host == "panel.test" and request.url.path == "/api/voices/upload":
original(request) # 面板真的登记了(写进共享注册表与上传账)
raise httpx.ConnectError("response lost after panel committed")
return original(request)
_swap_panel(custom, stack, flaky)
first = _register(client)
assert first.status_code == 503 # 诚实说「登记服务不在」,但面板侧已成立
committed = [u["name"] for u in stack.uploads]
assert len(committed) == 1
_swap_panel(custom, stack, original) # 用户看到失败后重试
second = _register(client)
assert second.status_code == 201
orphan, live = committed[0], second.json()["id"]
registry = json.loads(stack.registry_file.read_text())
assert orphan in registry and live in registry
listing = {v["id"]: v for v in client.get("/api/voice/voices").json()["voices"]}
assert orphan in listing and listing[orphan]["label"] == orphan # 孤儿以 ID 为名仍可见
assert listing[live]["label"] == live # 本次登记未起名,同样以 ID 为名(一致行为)
# 既有 11 支与通用助手状态零改写;孤儿条目未被 CyberGril 覆盖或删除
assert set(BUILTIN) <= set(listing)
assert stack.generic_voice_writes == 0
assert stack.deletes == []
def test_session_refuses_when_worker_state_unwritable(instance):
"""通话开启时 worker 音色写不进:诚实 503;恢复后正常开启,选择不受牵连."""
client, stack, custom, worker_state, tmp_path = instance
registered = _register(client).json()
assert client.post("/api/voice/preview", json={"voice": registered["id"]}).status_code == 200
client.post("/api/voice/selection", json={"voice": registered["id"]})
blocked = tmp_path / "blocked-worker.json"
blocked.mkdir()
custom.config.worker_state_file = str(blocked)
response = client.post("/api/voice/session")
assert response.status_code == 503
assert "写不进" in response.json()["detail"]
custom.config.worker_state_file = str(worker_state)
assert client.post("/api/voice/session").status_code == 201
def test_worker_state_other_keys_and_corruption(instance):
"""可读的 worker 文件其他键保留;文件损坏(非 JSON)时重写只剩 voice —— 如实记录行为."""
client, stack, custom, worker_state, tmp_path = instance
registered = _register(client).json()
client.post("/api/voice/preview", json={"voice": registered["id"]})
worker_state.write_text(json.dumps(
{"voice": "default", "persona": "keep-me", "volume": 7}), encoding="utf-8")
client.post("/api/voice/selection", json={"voice": registered["id"]})
state = json.loads(worker_state.read_text())
assert state["voice"] == registered["id"]
assert state["persona"] == "keep-me" and state["volume"] == 7
worker_state.write_text("{not json", encoding="utf-8")
other = _register(client).json()
client.post("/api/voice/preview", json={"voice": other["id"]})
client.post("/api/voice/selection", json={"voice": other["id"]})
state = json.loads(worker_state.read_text())
# 损坏文件读不出 → 整体重写仅含 voice,旧键丢失(该文件由专属 worker 维护;
# 损坏属异常现场。记录事实,不构成合同违约)
assert state == {"voice": other["id"]}
def test_journal_unwritable_keeps_apply_gated(instance):
"""试听流水写不进:试听仍可成功(尽力而为),但应用凭据缺失 → 409 守门不放松."""
client, stack, custom, worker_state, tmp_path = instance
blocked = tmp_path / "blocked-usage.jsonl"
blocked.mkdir()
custom.journal_path = blocked
registered = _register(client).json()
assert client.post("/api/voice/preview", json={"voice": registered["id"]}).status_code == 200
refused = client.post("/api/voice/selection", json={"voice": registered["id"]})
assert refused.status_code == 409 # fail-closed:宁可再听一次,不放过未试听
def test_apply_rolls_back_when_selection_file_unwritable(instance):
"""本地选择状态写不进:整体回滚,worker 文件不动,错误如实 503."""
client, stack, custom, worker_state, tmp_path = instance
registered = _register(client).json()
client.post("/api/voice/preview", json={"voice": registered["id"]})
worker_state.write_text(json.dumps({"voice": "rain"}), encoding="utf-8")
blocked = tmp_path / "blocked-sel.json"
blocked.mkdir()
custom.selection_path = blocked
response = client.post("/api/voice/selection", json={"voice": registered["id"]})
assert response.status_code == 503
assert "写不进" in response.json()["detail"]
assert json.loads(worker_state.read_text())["voice"] == "rain"
assert custom.selection().voice is None
+243
View File
@@ -0,0 +1,243 @@
"""HEL-904 后台返修:capture 音色与 worker 实读竞态的确定性时序用例.
HEL-902 审核第 6 项的收口验证。事实前提(先核真实入口,不猜):
* 专属 worker 是**另一进程**,在自己的会话入口读一次
``voice_state_cybergril.json``(HEL-877 既有行为,合同 §2.5);
* API 进程只知道 dispatch 何时返回,**dispatch 返回 ≠ worker 已读**——
worker 的读时点对 API 不可知;
* 因此修复采用等价的「会话不可变快照」:capture 时登记通话持有,
整个通话窗口(capture→挂断/到期)都按「worker 尚未读」对待,
期间应用的 worker 写入暂缓,状态文件定在本通话捕获值上。
时序全部用 asyncio.Event 门控(无 sleep 凑数);「worker 读文件」由
测试在 dispatch 返回之后亲自读文件扮演——这正是竞态窗口里最坏的读者。
零真实 LLM/嵌入/ASR/TTS 调用、零网络(全假服务)。
"""
from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
import httpx
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tests"))
from test_r2 import R2LLM # noqa: E402
from test_voice_custom import CREATE, FakeVoiceStack, make_wav # noqa: E402
from cybergril.api.app import create_app # noqa: E402
from cybergril.life.runtime import LifeConfig # noqa: E402
from cybergril.think.router import build_router # noqa: E402
from cybergril.think.scenes import Tier # noqa: E402
from cybergril.voice.custom import CustomVoiceConfig, CustomVoiceService # noqa: E402
from cybergril.voice.session import VoiceConfig, VoiceSessionService # noqa: E402
class GatedLiveKitTransport(httpx.AsyncHTTPTransport):
"""可门控的假 LiveKit:dispatch 进站即亮灯,返回被 gate 扣住.
这让测试能把「开房请求在途」精确固定成一个时间点:capture 已写文件、
dispatch 尚未返回,此刻并发发起应用正是审核指出的竞态窗口。
"""
def __init__(self, stack: FakeVoiceStack) -> None:
super().__init__()
self.stack = stack
self.dispatch_in_flight = asyncio.Event()
self.gate = asyncio.Event()
self.fail_dispatch = False
self.fail_delete = False
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
if request.url.host == "tts.test": # TTS 健康/合成走既有桩
return self.stack._handle(request)
path = request.url.path
if path == "/twirp/livekit.AgentDispatchService/CreateDispatch":
self.dispatch_in_flight.set()
await self.gate.wait() # 扣住 dispatch 响应,直到测试放行
if self.fail_dispatch:
raise httpx.ConnectError("dispatch wire cut")
return httpx.Response(200, json={"id": "AD_1", "room": "r"})
if path == "/twirp/livekit.RoomService/DeleteRoom":
if self.fail_delete:
raise httpx.ConnectError("delete wire cut")
return httpx.Response(200, content=b"")
return httpx.Response(404)
def build_gated_app(tmp: Path, stack: FakeVoiceStack, transport: GatedLiveKitTransport):
voice = VoiceSessionService(
config=VoiceConfig(
livekit_url="ws://voice.test:7880", livekit_internal="http://livekit.test",
api_key="devkey", api_secret="test-secret-not-real",
tts_health_url="http://tts.test/health"),
http=httpx.AsyncClient(transport=transport))
worker_state = tmp / "voice_state_cybergril.json"
custom = CustomVoiceService(
config=CustomVoiceConfig(
panel_url="https://panel.test", tts_url="http://tts.test",
registry_file=str(stack.registry_file),
worker_state_file=str(worker_state)),
http=httpx.AsyncClient(transport=stack.transport, verify=False),
selection_path=tmp / "sel.json", registry_path=tmp / "custom.json",
journal_path=tmp / "usage.jsonl")
provider = R2LLM()
router = build_router(providers={t: provider for t in (Tier.HIGH, Tier.STANDARD, Tier.LOW)})
app = create_app(db_path=":memory:", router=router,
life_config=LifeConfig(enabled=True, poll_seconds=3600),
semantic_mode="stub", voice_service=voice,
custom_voice_service=custom)
return app, custom, worker_state
def _worker_reads(worker_state: Path) -> str:
"""扮演 worker 的会话入口读:读时点由测试决定(这里是 dispatch 返回之后)."""
return str(json.loads(worker_state.read_text(encoding="utf-8")).get("voice", ""))
async def _register_and_apply_old(client: httpx.AsyncClient) -> str:
registered = await client.post(
"/api/voice/voices", params={"prompt_text": "参考文字"},
content=make_wav(3), headers={"Content-Type": "audio/wav"})
assert registered.status_code == 201
voice_id = registered.json()["id"]
assert (await client.post("/api/voice/preview", json={"voice": voice_id})).status_code == 200
applied = await client.post("/api/voice/selection", json={"voice": voice_id})
assert applied.status_code == 200
return voice_id
@pytest.fixture
def gated(tmp_path):
stack = FakeVoiceStack(tmp_path)
transport = GatedLiveKitTransport(stack)
app, custom, worker_state = build_gated_app(tmp_path, stack, transport)
return app, stack, transport, custom, worker_state
async def test_worker_read_after_dispatch_sees_pinned_voice(gated):
"""核心时序:worker 读时点被延迟到 dispatch 返回之后、且发生在并发应用
完成之后——本通话保持旧音色(响应=实读),下一通话使用新音色."""
app, stack, transport, custom, worker_state = gated
async with app.router.lifespan_context(app), httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
await client.post("/api/companion", json=CREATE)
old_voice = await _register_and_apply_old(client)
# 开房:capture 已写文件,dispatch 在途(gate 扣住响应)
open_task = asyncio.create_task(client.post("/api/voice/session"))
await asyncio.wait_for(transport.dispatch_in_flight.wait(), timeout=5)
assert _worker_reads(worker_state) == old_voice # capture 已落盘
# 竞态窗口内并发应用新音色(dispatch 尚未返回,worker 必然还没读)
apply_task = asyncio.create_task(
client.post("/api/voice/selection", json={"voice": "rain"}))
applied = await asyncio.wait_for(apply_task, timeout=5)
assert applied.status_code == 200 # 应用成功:选择已记
assert custom.selection().voice == "rain"
# 关键断言(defer 生效):worker 文件仍定在本通话捕获值上
assert _worker_reads(worker_state) == old_voice
transport.gate.set() # 放行 dispatch
opened = (await asyncio.wait_for(open_task, timeout=5)).json()
assert opened["voice"] == old_voice # 响应报告本通话捕获值
# worker 现在才读文件(dispatch 返回之后、通话仍进行中)
assert _worker_reads(worker_state) == old_voice # 读到的=响应报告的
# 挂断结算 → 持有释放;暂缓的写入由下一通 capture 重写生效
ended = await client.delete(f"/api/voice/session/{opened['session_id']}")
assert ended.status_code == 200
assert not custom.call_voice_held()
assert _worker_reads(worker_state) == old_voice # 结算不补写,下一通重写
transport.dispatch_in_flight.clear()
second = (await client.post("/api/voice/session")).json()
assert second["voice"] == "rain"
assert _worker_reads(worker_state) == "rain" # 下一通:新音色生效
await client.delete(f"/api/voice/session/{second['session_id']}")
async def test_end_delete_failure_and_ttl_expiry_settle_hold(gated):
"""挂断失败转清理欠账、TTL 到期(_prune)两条路径都结算持有."""
app, stack, transport, custom, worker_state = gated
async with app.router.lifespan_context(app), httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
await client.post("/api/companion", json=CREATE)
old_voice = await _register_and_apply_old(client)
transport.gate.set()
opened = (await client.post("/api/voice/session")).json()
assert opened["voice"] == old_voice
applied = await client.post("/api/voice/selection", json={"voice": "rain"})
assert applied.status_code == 200
assert _worker_reads(worker_state) == old_voice # 通话活跃:暂缓
# 显式挂断失败(删房线断)→ 503、会话转欠账、持有照样结算
transport.fail_delete = True
ended = await client.delete(f"/api/voice/session/{opened['session_id']}")
assert ended.status_code == 503
assert not custom.call_voice_held() # 转欠账即结算:持有释放
# 暂缓的写入不靠结算补写(下一次 capture 会重写)——但结算后
# 再次应用立即写透,这是面向用户的恢复路径
assert _worker_reads(worker_state) == old_voice
reapplied = await client.post("/api/voice/selection", json={"voice": "rain"})
assert reapplied.status_code == 200
assert _worker_reads(worker_state) == "rain"
# TTL 到期路径:再开一通、拨表到期,_prune 也要释放持有
transport.fail_delete = False
transport.dispatch_in_flight.clear()
third = (await client.post("/api/voice/session")).json()
assert third["voice"] == "rain"
assert custom.call_voice_held()
service = app.state.voice_service
session_obj = service.get(third["session_id"])
session_obj.expires_at = service.clock() - 1 # 拨到过去:立即到期
service._prune()
assert not custom.call_voice_held()
async def test_dispatch_failure_releases_hold(gated):
"""失败清理:dispatch 失败 → 503 且持有立即释放,应用马上写透."""
app, stack, transport, custom, worker_state = gated
async with app.router.lifespan_context(app), httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
await client.post("/api/companion", json=CREATE)
await _register_and_apply_old(client)
transport.fail_dispatch = True
transport.gate.set()
refused = await client.post("/api/voice/session")
assert refused.status_code == 503
assert not custom.call_voice_held() # 失败即结算,无泄漏
applied = await client.post("/api/voice/selection", json={"voice": "rain"})
assert applied.status_code == 200
assert _worker_reads(worker_state) == "rain" # 无活跃通话:立即写透
async def test_cancelled_open_releases_hold(gated):
"""取消清理:开房请求在途被取消(客户端断开)→ 持有释放,不无限暂缓."""
app, stack, transport, custom, worker_state = gated
async with app.router.lifespan_context(app), httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
await client.post("/api/companion", json=CREATE)
await _register_and_apply_old(client)
open_task = asyncio.create_task(client.post("/api/voice/session"))
await asyncio.wait_for(transport.dispatch_in_flight.wait(), timeout=5)
open_task.cancel() # dispatch 在途时客户端取消
with pytest.raises(asyncio.CancelledError):
await open_task # 取消在解除 await 前已完成整个栈展开:持有已释放
assert not custom.call_voice_held() # 取消路径也结算
applied = await client.post("/api/voice/selection", json={"voice": "rain"})
assert applied.status_code == 200
assert _worker_reads(worker_state) == "rain"
+13
View File
@@ -11,6 +11,7 @@ import { motionOff } from '../stage/roomScene';
import { LineStage, type PresentHow } from './lineStage';
import { anchorOf, cursorAt, groupTurns, pagesOf, ReadingClock, type Anchor, type Metrics, type ShownMessage } from './reading';
import { mergeTurns, OFF, recentRows, VoiceController, voiceLine, voiceStatus, WORDS, type VoiceView } from '../voice/session';
import { callVoiceName, listVoices } from '../voice/custom';
import { loadTransport } from '../voice/livekit';
import { newestId, serverId } from '../adapters/inbox';
@@ -89,6 +90,17 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
voiceStatus(c.signal).then(s => setVoiceOff(s.available ? '' : s.reason), () => {});
return () => c.abort();
}, [real]);
// The voice this call was opened with (HEL-903). Its name is looked up by that id only — never the choice settings
// hold now, which is the next call's — and an answer for another id (an older call) is never shown.
const pinned = real ? voiceView.voice : null, [named, setNamed] = useState<{ id: string; label: string } | null>(null);
useEffect(() => {
const id = pinned?.id; if (!id || id === 'default' || named?.id === id) return;
const c = new AbortController();
listVoices(c.signal).then(list => { const v = !c.signal.aborted && list.configured ? list.voices.find(x => x.id === id) : undefined; if (v) setNamed({ id, label: v.label }); }, () => { /* The id stays on show. */ });
return () => c.abort();
// eslint-disable-next-line react-hooks/exhaustive-deps -- one lookup per pinned id.
}, [pinned?.id]);
const pinnedName = pinned && callVoiceName(pinned.id, named);
const voiceOn = real ? voiceView.phase !== 'off' : voice;
const voiceLabel = { off: '', opening: '正在接通…', joining: `在等${name}接起…`, listening: '在听你说', thinking: `${name}在想`, speaking: `${name}在说 · 开口就能打断` }[voiceView.phase];
useEffect(() => {
@@ -334,6 +346,7 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation,
{real && voiceOn && <div className="voice-row real" data-phase={voiceView.phase} role="status" aria-live="polite">
<span className="voice-wave" aria-hidden="true"><i/><i/><i/><i/></span>
<span className="voice-state">{voiceLabel}</span>
{pinned && pinnedName && <span className={`voice-pin ${pinned.id ? '' : 'unknown'}`} title={pinned.id ? `本次通话用「${pinnedName}」,到这次通话结束都不变;设置里换的声音下一次通话才生效。` : '服务器这次没有说明用的是哪个声音,这里不猜。设置里换的声音下一次通话才生效。'}><small>本次<span className="vp-wide">通话</span></small>{pinnedName}</span>}
{voiceView.spoken ? <span className="voice-heard said" title={voiceView.spoken}>{name}:{voiceView.spoken}<em>未记入聊天</em></span> : voiceView.heard && <span className="voice-heard" title={voiceView.heard}>「{voiceView.heard}」</span>}
{voiceView.blocked && <button onClick={() => void voiceCtl.current?.resumeAudio()}>点一下听{name}的声音</button>}
<button className="voice-end" onClick={() => { void voiceCtl.current?.end(); setNotice('语音已结束,麦克风已关闭。'); }}>结束语音</button>
+29 -7
View File
@@ -4,12 +4,22 @@ import type { DemoState, Settings, Theme } from '../domain/model';
import { delay } from '../adapters/demo';
import { exportDemo, parseState } from '../adapters/storage';
import { BaseColor, Button, Field, Icon, Modal, Status } from '../shared/ui';
const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['city', 'pin', '城市'], ['appearance', 'image', '外观素材'], ['backup', 'box', '本地演示导出'], ['services', 'plug', '外接服务'], ['display', 'moon', '显示']];
import { VoiceSection, type VoiceCap } from './VoiceSection';
import { readVoiceStatus, type VoiceStatusKind } from '../voice/session';
const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['voice', 'mic', '她的声音'], ['city', 'pin', '城市'], ['appearance', 'image', '外观素材'], ['backup', 'box', '本地演示导出'], ['services', 'plug', '外接服务'], ['display', 'moon', '显示']];
// What each section can actually do here (HEL-815 capability tags): read-only, adjustable, demo only, not connected, this device.
type Cap = 'ro' | 'edit' | 'demo' | 'off' | 'live' | 'local';
const CAP: Record<Cap, [string, string]> = { ro: ['只读', ''], edit: ['可以调整', 'accent'], demo: ['演示数据', 'demo'], off: ['未接入', 'off'], live: ['文字已接通', 'ok'], local: ['本机生效', 'ok'] };
const capOf = (id: string, real: boolean): Cap => id === 'her' ? 'ro' : id === 'relation' ? 'edit' : id === 'display' ? 'local' : id === 'services' && real ? 'live' : real ? 'off' : 'demo';
const NOTE: Record<string, string> = { her: '只读资料', relation: '可以调整', display: '本机生效' };
const capOf = (id: string, real: boolean, voice: VoiceCap = 'unknown'): Cap => id === 'her' ? 'ro' : id === 'relation' ? 'edit' : id === 'voice' ? real ? voice === 'off' ? 'off' : 'edit' : 'demo' : id === 'display' ? 'local' : id === 'services' && real ? 'live' : real ? 'off' : 'demo';
const NOTE: Record<string, string> = { her: '只读资料', relation: '可以调整', voice: '上传 · 录音 · 试听', display: '本机生效' };
// 外接服务 · voice rows (HEL-903): exactly what GET /api/voice/status can tell — no route, not configured, configured with
// the TTS health probe passing or failing, or unreadable. Recognition has no probe of its own there, so it never claims one.
type VoiceSvc = VoiceStatusKind | 'loading';
const VOICE_SVC: Record<'asr' | 'tts' | 'config', Record<VoiceSvc, string>> = {
tts: { loading: '正在读取语音状态…', none: '这台服务没有语音状态接口', off: '未配置 · 这台服务没有语音配置', ok: '已配置 · 合成服务健康检查可连接', down: '已配置 · 合成服务这会儿暂不可用', unknown: '状态未知 · 这次没读到语音状态' },
asr: { loading: '正在读取语音状态…', none: '这台服务没有语音状态接口', off: '未配置 · 这台服务没有语音配置', ok: '已配置 · 没有单独的健康检查,以实际通话为准', down: '已配置 · 没有单独的健康检查,以实际通话为准', unknown: '状态未知 · 这次没读到语音状态' },
config: { loading: '读取中', none: '服务尚未配置', off: '服务尚未配置', ok: '由服务器管理', down: '由服务器管理', unknown: '暂时读不到' },
};
function CapTag({ cap }: { cap: Cap }) { const [text, tone] = CAP[cap]; return <span className={`tag cap ${tone}`}>{text}</span>; }
// Honest placeholder for a capability that is not connected: what it will do once connected, and that nothing happens now.
function Off({ title, will, now }: { title: string; will: string; now: string }) { return <div className="off-card"><p className="off-t">{title}</p><p>{will}</p><p className="d-dim">{now}</p></div>; }
@@ -32,10 +42,20 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
const approvedNavigation = useRef(false);
const pendingNavigation = useRef<(() => void) | null>(null);
const [serviceHealth, setServiceHealth] = useState<Health>();
// Read on opening 外接服务 and on an explicit re-read; real only (the demo never asks a server).
const [voiceSvc, setVoiceSvc] = useState<VoiceSvc>('loading'), [voiceSvcRead, setVoiceSvcRead] = useState(0);
useEffect(() => {
if (!real || !open || section !== 'services') return;
const c = new AbortController(); setVoiceSvc('loading');
readVoiceStatus(c.signal).then(kind => { if (!c.signal.aborted) setVoiceSvc(kind); }, () => { /* Aborted: left the section. */ });
return () => c.abort();
}, [real, open, section, voiceSvcRead]);
// 她的声音 (HEL-900): an unregistered reference counts as unsaved; the tag follows what the server reports.
const [voiceDirty, setVoiceDirty] = useState(false), [voiceCap, setVoiceCap] = useState<VoiceCap>('unknown');
const request = useRef<AbortController | null>(null), generation = useRef(0), feedback = useRef<HTMLDivElement>(null);
useEffect(()=>{if(message) feedback.current?.scrollIntoView({block:'center'});},[message]);
const latestState = useRef(state); latestState.current = state;
const dirtySections: Record<string, boolean> = {relation: JSON.stringify(draft.identity)!==JSON.stringify(state.identity), city: !real && draft.settings.city!==state.settings.city, services: !real && JSON.stringify(draft.settings.services)!==JSON.stringify(state.settings.services), display: draft.settings.reduced!==state.settings.reduced};
const dirtySections: Record<string, boolean> = {relation: JSON.stringify(draft.identity)!==JSON.stringify(state.identity), city: !real && draft.settings.city!==state.settings.city, services: !real && JSON.stringify(draft.settings.services)!==JSON.stringify(state.settings.services), display: draft.settings.reduced!==state.settings.reduced, voice: real && voiceDirty};
const dirty = !!dirtySections[section], anyDirty = Object.values(dirtySections).some(Boolean);
useEffect(() => { if (!open) {
request.current?.abort();
@@ -53,7 +73,7 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
}, [open, anyDirty]);
async function checkService() {
setBusy(true); setError(false); setMessage('正在检查文字服务…');
try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。语音、视觉和长期记忆仍未接通。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
try { const status = await api<Health>('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。视觉观察和长期记忆仍未接通;语音状态以上面两行为准。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); }
catch (e) { setMessage((e as Error).message); setError(true); }
finally { setBusy(false); }
}
@@ -168,7 +188,7 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
<div><h4>后来慢慢有的<small>成长 · 单独记录</small></h4>{p?.growth.length ? <ul>{p.growth.map(g => <li key={g.id}>{g.text}<small>{SOURCE[g.source] || '有来处的成长'} · {day(g.ts)}</small></li>)}</ul> : <p className="d-dim">{!p ? '读取中…' : '还没有记下的成长。长期记忆尚未接通,聊天记录已独立保存。'}</p>}</div></div>;
}
const sectionLabel = (id: string) => real && id === 'backup' ? '备份' : sections.find(s => s[0] === id)?.[2] || '';
return <section hidden={!open} className={`pane pane-manage glass read ${open ? 'open' : ''} ${detail ? 'has-detail' : ''}`} aria-label="设置"><header><div className="ttl"><small className="kicker"><span className="k-wide">慢慢安排你们的世界 · {real ? '服务器档案' : '仅本机演示'}</span><span className="k-near"><i/>她此刻 · {now || '安静待着'}</span></small><h2>设置</h2></div><span className="grow"/><span className="tag-demo">{real ? '服务器档案' : '仅本机演示'}</span><button className="btn-round sm" aria-label="关闭设置" onClick={() => requestLeave(onClose)}><Icon name="x"/></button></header><div className="cols"><nav className="snav" aria-label="设置分区">{sections.map(([id, icon]) => { const cap = capOf(id, real); return <button key={id} className={dirtySections[id] ? 'is-dirty' : ''} aria-current={section === id} onClick={() => switchSection(id)}><Icon name={icon}/><span className="sl">{sectionLabel(id)}<small aria-hidden="true">{NOTE[id] || CAP[cap][0]}</small></span><i className="dirty"/><span className="chev" aria-hidden="true"><Icon name="next"/></span></button>; })}<p className="snav-foot">首版只有一位她 · 没有重建或清空</p></nav><div className="detail"><button className="btn-text back" onClick={() => setDetail(false)}><Icon name="back"/>设置</button><div className="sec-body on"><div className="sec-h"><h3>{section === 'her' ? state.identity.name : sectionLabel(section)}</h3><CapTag cap={capOf(section, real)}/></div><p className="lead">{section === 'her' ? '她还是她。外观和关系的变化,都不会改写最初的底色。' : real ? '资料保存在服务器。尚未接通的功能写明接上以后能做什么,现在不会发生任何事。' : '所有修改仅用于本机演示。未接通的服务会明确说明。'}</p>
return <section hidden={!open} className={`pane pane-manage glass read ${open ? 'open' : ''} ${detail ? 'has-detail' : ''}`} aria-label="设置"><header><div className="ttl"><small className="kicker"><span className="k-wide">慢慢安排你们的世界 · {real ? '服务器档案' : '仅本机演示'}</span><span className="k-near"><i/>她此刻 · {now || '安静待着'}</span></small><h2>设置</h2></div><span className="grow"/><span className="tag-demo">{real ? '服务器档案' : '仅本机演示'}</span><button className="btn-round sm" aria-label="关闭设置" onClick={() => requestLeave(onClose)}><Icon name="x"/></button></header><div className="cols"><nav className="snav" aria-label="设置分区">{sections.map(([id, icon]) => { const cap = capOf(id, real); return <button key={id} className={dirtySections[id] ? 'is-dirty' : ''} aria-current={section === id} onClick={() => switchSection(id)}><Icon name={icon}/><span className="sl">{sectionLabel(id)}<small aria-hidden="true">{NOTE[id] || CAP[cap][0]}</small></span><i className="dirty"/><span className="chev" aria-hidden="true"><Icon name="next"/></span></button>; })}<p className="snav-foot">首版只有一位她 · 没有重建或清空</p></nav><div className="detail"><button className="btn-text back" onClick={() => setDetail(false)}><Icon name="back"/>设置</button><div className="sec-body on"><div className="sec-h"><h3>{section === 'her' ? state.identity.name : sectionLabel(section)}</h3>{!(real && section === 'voice' && voiceCap === 'unknown') && <CapTag cap={capOf(section, real, voiceCap)}/>}</div><p className="lead">{section === 'her' ? '她还是她。外观和关系的变化,都不会改写最初的底色。' : section === 'voice' ? `换${state.identity.name}说话的声音:给一段参考声音,先试听,再给她用。只换声音,名字、底色、关系、聊天和记忆都不变。` : real ? '资料保存在服务器。尚未接通的功能写明接上以后能做什么,现在不会发生任何事。' : '所有修改仅用于本机演示。未接通的服务会明确说明。'}</p>
{section === 'her' && <><dl className="kv"><dt>名字</dt><dd>{state.identity.name}<small>由你在初次创建时起的名字;首版不另设改名流程。</small></dd><dt>生辰</dt><dd>{state.identity.birth} · {state.identity.hour}<small><Icon name="lock"/>只读</small></dd></dl>
<h4 className="sub-h">她的底色<small>系统资料 · 每轮对话都会重新带上 · 关系设定不能改写</small></h4><BaseColor color={real ? (state.identity as RealIdentity).color : undefined}/>
<h4 className="sub-h">她怎么理解自己<small>模型诠释 · 会延续,也会变化</small></h4>{real ? personaView() : <Off title="演示不生成她对自己的诠释。" will="接上以后,这里是她在相处和生活里慢慢形成的自我理解,带版本和时间;不是固定模板,也不能直接改写。" now="现在这里不显示任何固定段落,避免把模板当成她的个性。"/>}
@@ -178,11 +198,13 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem
{!real && section === 'appearance' && <><div className="card">当前素材:{state.settings.appearance}<small className="d-foot">参考静帧,不代表最终人物素材。</small></div><Field label="选择本地素材清单(演示 JSON)" hint="真实素材包格式尚待定稿。只读 JSON,不加载清单中的 URL 或脚本。"><input type="file" accept=".json,application/json" onChange={e => void importMaterial(e.target.files?.[0])}/></Field>{check && <Status text={check} error={!material}/>} {material && <><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前参考"/><figcaption>当前参考</figcaption></figure><figure className="new"><img src="/assets/her-room.jpg" alt="新外观模拟效果"/><figcaption>模拟效果 · 未加载素材</figcaption></figure></div><Button primary onClick={() => setConfirm('appearance')}>应用演示外观标识</Button></>}<p className="d-foot">只演示检查、预览与确认。媒体真实性未校验,不声称完成素材导入。</p></>}
{!real && section === 'backup' && <><div className="card"><h4>真实后台备份 · 未接通</h4><p>服务器每日备份、数据库和素材完整备份均未提供。本地文件不包含真实后台资料。</p></div><div className="card"><h4>你自己的演示资料</h4><p>包括本机档案、设置、文字、演示日子和回忆;不包含素材文件。</p><Button onClick={() => { exportDemo(state); setMessage('已生成本地演示 JSON 下载。'); setError(false); }}>导出本地演示 JSON</Button></div><Field label="恢复同一位她的本地演示 JSON"><input type="file" accept=".json,application/json" onChange={e => void importBackup(e.target.files?.[0])}/></Field>{restore && <Button onClick={() => { setConfirmText(''); setConfirm('restore'); }}>检查通过,继续恢复</Button>}</>}
{!real && section === 'services' && <>{Object.entries(draft.settings.services).map(([key, value]) => <div className="svc" key={key}><div className="nm">{key}<small>未接通 · 测试仅演示,不发网络请求</small></div><Button onClick={() => { setMessage(`${key}:界面演示,未真实检测。相关功能暂不可用,文字演示仍可使用。`); setError(false); }}>测试连接</Button><input className="input" aria-label={`${key}地址`} value={value} placeholder="服务地址(只保存,不连接)" onChange={e => settings({ services: { ...draft.settings.services, [key]: e.target.value } })}/></div>)}<p className="d-foot">真实麦克风和摄像头未启用。视觉窗只允许模拟状态,观察过期不当作眼前。</p></>}
{!real && section === 'voice' && <><Off title="演示里不录音,也不上传。" will="接上真实服务以后:上传或录一段参考声音 → 试听她用这个声音说话 → 给她之后的通话用。只换声音,名字、底色、关系、聊天和记忆都不变。" now="演示里不会请求麦克风,也不会发出任何网络请求。"/><div className="field"><div className="seg" role="group" aria-label="参考声音来源"><button disabled>上传音频文件</button><button disabled>现在录一段</button></div></div></>}
{real && open && <div hidden={section !== 'voice'}><VoiceSection name={state.identity.name} active={section === 'voice'} onDirty={setVoiceDirty} onCap={setVoiceCap}/></div>}
{section === 'display' && <><div className="opt-row">{(['moon', 'fog'] as Theme[]).map(t => <button className="opt" data-t={t} key={t} aria-pressed={theme === t} onClick={() => setTheme(t)}><div className="sw"/><b>{t === 'moon' ? '靛蓝月光' : '蓝紫雾光'}</b><span>立即切换,保留你的选择</span></button>)}</div><label className="switch-row"><span>减少动效<small>镜头不推近,只做淡入淡出;循环动画停下</small></span><input type="checkbox" className="switch" checked={draft.settings.reduced} onChange={e => settings({ reduced: e.target.checked })}/></label><p className="d-foot">也会自动尊重系统的减少动效设置。切到其他网页时暂停背景动画。</p></>}
{real && section === 'city' && <><Off title="城市与天气还没有接上。" will="接上以后,你选一个真实城市,她房间里的天气、白天黑夜和作息都跟着那里走;换城市不需要每次重新定位。" now="现在窗外的雨是界面氛围,不是真实天气。尚未设置可核实的城市,天气来源尚未确定。"/><Field label="搜索城市" hint="可浏览候选目录;当前无法保存,不会更改她的城市。"><input className="input" value={search} placeholder="输入城市名称" onChange={e => setSearch(e.target.value)}/></Field><ul className="city-list">{['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c => <li key={c}><button disabled>{c}<small>候选城市 · 保存待接入</small></button></li>)}</ul>{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) && <div className="empty">当前目录没有匹配的城市。</div>}<div className="actions"><Button disabled>保存城市 · 待接入</Button></div></>}
{real && section === 'appearance' && <><dl className="kv"><dt>现在的样子</dt><dd>工程外观 v2<small>参考静帧 · 尚无成套日常与交谈视频</small></dd></dl><Off title="换外观还没有接上。" will="接上以后:选择素材包 → 检查素材 → 看新旧对比 → 确认「她还是她」。只换样子,名字、底色、记忆和关系都不变。" now="素材包规范与导入检查尚未定稿。这里不会导入、预览或替换任何素材。"/><Field label="选择外观素材包" hint="素材包格式与媒体检查将在后续接入。当前不上传、不读取文件。"><input type="file" disabled/></Field><ul className="phases">{['清单结构与版本', '媒体格式与时长', '人物一致性', '必要场景覆盖', '可用性与兼容性'].map(label => <li key={label}>待检查 · {label}</li>)}</ul><div className="compare"><figure><img src="/assets/her-room.jpg" alt="当前内置参考静帧"/><figcaption>当前参考</figcaption></figure><figure><div className="empty">导入并通过检查后显示新外观</div><figcaption>新旧对比 · 待接入</figcaption></figure></div><Button disabled>确认应用外观 · 待接入</Button></>}
{real && section === 'backup' && <><Off title="备份还没有接上。" will="接上以后:服务器每天自动备份;你可以随时把她完整导出到自己的电脑(含底色、性格资料、对话、记忆、关系、生活进度和所用素材),也能从备份恢复同一个她。" now="现在没有任何备份在运行,这里也不会假装导出或恢复。"/><div className="card"><h4>每日自动备份</h4><p>暂无可核实的备份记录。自动备份、保留份数与运行结果将在接入后显示。</p><div className="empty">尚未接通备份服务</div></div><div className="card"><h4>完整导出</h4><p>将包含服务器档案、聊天及届时接通的记录与素材。当前不生成伪备份文件。</p><Button disabled>导出完整备份 · 待接入</Button></div><Field label="恢复同一位她的备份" hint="恢复前会检查完整性、版本和同一档案,要求输入“恢复”,并先备份当前资料。当前不接收文件或执行覆盖。"><input type="file" disabled/></Field><ul className="phases">{['检查完整性与版本', '核对是否同一位她', '先备份当前资料', '输入确认并恢复'].map(label => <li key={label}>待接入 · {label}</li>)}</ul><button className="btn-danger" disabled>检查备份并继续 · 待接入</button></>}
{real && section === 'services' && <>{[['文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['语音识别', '尚未接通'], ['语音合成', '尚未接通'], ['视觉观察', '尚未接通']].map(([label, status], index) => <div className="svc" key={label}><div className="nm">{label}<small>{status}</small></div><Button disabled={index !== 0 || busy} onClick={() => void checkService()}>{index === 0 ? busy ? '检查中…' : '重新检查连接' : '测试连接 · 待接入'}</Button><input className="input" aria-label={`${label}配置`} value={index === 0 ? '由服务器管理' : '服务尚未配置'} readOnly/></div>)}<Off title="语音与视觉观察还没有接上。" will="接上以后:语音由你主动开启,结束后停止收音,文字聊天一直保留;视觉观察只看人在不在、明显物品和房间变化,过期的画面不再当作眼前。" now="这里不申请麦克风或摄像头,不采集声音与影像。"/><div className="card"><h4>其余能力</h4><p>{life?.wired ? `生活:${life.label}。${life.note}长期记忆、主动消息和天气尚未接通。` : '生活、长期记忆、主动消息和天气尚未接通。'}文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</p></div></>}
{real && section === 'services' && <>{([['text', '文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['voice', '语音识别', VOICE_SVC.asr[voiceSvc]], ['voice', '语音合成', VOICE_SVC.tts[voiceSvc]], ['off', '视觉观察', '尚未接通']] as const).map(([kind, label, status]) => <div className="svc" key={label} data-state={kind === 'voice' ? voiceSvc : undefined}><div className="nm">{label}<small>{status}</small></div>{kind === 'text' ? <Button disabled={busy} onClick={() => void checkService()}>{busy ? '检查中…' : '重新检查连接'}</Button> : kind === 'voice' ? <Button disabled={voiceSvc === 'loading'} onClick={() => setVoiceSvcRead(n => n + 1)}>{voiceSvc === 'loading' ? '读取中…' : '重新读取状态'}</Button> : <Button disabled>测试连接 · 待接入</Button>}<input className="input" aria-label={`${label}配置`} value={kind === 'text' ? '由服务器管理' : kind === 'voice' ? VOICE_SVC.config[voiceSvc] : '服务尚未配置'} readOnly/></div>)}<p className="d-dim">语音两项读自服务器的语音状态:「可连接」只说明她的声音(合成)这会儿通过了健康检查,不代表整套语音通话已经验收;语音识别这里查不到单独的状态。这里只读状态,不申请麦克风。</p><Off title="视觉观察还没有接上。" will="接上以后:视觉观察只看人在不在、明显物品和房间变化,过期的画面不再当作眼前。语音由你在交谈里主动开启,结束后停止收音,文字聊天一直保留。" now="这里不申请摄像头,不采集影像。"/><div className="card"><h4>其余能力</h4><p>{life?.wired ? `生活:${life.label}。${life.note}长期记忆、主动消息和天气尚未接通。` : '生活、长期记忆、主动消息和天气尚未接通。'}文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。</p></div></>}
<div ref={feedback}><Status text={message} error={error}/></div>{(real ? ['relation', 'display'] : ['relation', 'city', 'services', 'display']).includes(section) && <div className="actions"><span className={`grow ${dirty ? 'dirty' : ''}`}>{dirty ? '有未保存的修改' : '尚未修改'}</span><Button disabled={!dirty || busy} onClick={reset}>取消修改</Button><Button primary disabled={!dirty || busy} onClick={() => void save()}>{busy ? '正在保存…' : error ? '重试保存' : '保存'}</Button></div>}</div></div></div>
{confirm && <Modal title={confirm === 'leave' ? '有未保存的修改' : confirm === 'relation' ? `把关系从「${state.identity.relation}」改成「${draft.identity.relation}」?` : confirm === 'restore' ? '恢复本地演示资料?' : '她还是她'} danger={confirm === 'restore'} onClose={() => setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'leave') { request.current?.abort(); setBusy(false); setDraft({ identity: structuredClone(state.identity), settings: structuredClone(state.settings) }); const action = pendingNavigation.current; pendingNavigation.current = null; approvedNavigation.current = true; try { action?.(); } finally { approvedNavigation.current = false; } } if (which === 'relation')
void save(true); if (which === 'appearance') {
+242
View File
@@ -0,0 +1,242 @@
// ── 她的声音 (CG-010 v1.12, HEL-900): reference → fresh preview → apply, inside the approved settings pane. ──
// Your reference and her synthesized preview are labelled apart and never stand in for each other. Every request and
// every audio URL belongs to one generation; switching voice, retrying, leaving or a late answer throws the old one away
// (request aborted, audio stopped, object URL revoked), so an old sample can never pass for the new one.
import { useEffect, useRef, useState, type RefObject } from 'react';
import { clockOf } from '../adapters/real';
import { applyVoice, idOnly, LABEL_MAX, listVoices, PREVIEW_DEFAULT, PREVIEW_MAX, previewVoice, Recorder, registerVoice, SAY, SUGGEST_MAX, SUGGEST_MIN, TEXT_MAX, toReference, VoiceError, type CustomVoice, type RecView, type VoiceSelection } from '../voice/custom';
import { Button, Field, Icon, Status } from '../shared/ui';
export type VoiceCap = 'edit' | 'off' | 'unknown';
type Load = { state: 'loading' } | { state: 'ok'; voices: CustomVoice[]; selection: VoiceSelection } | { state: 'off'; text: string } | { state: 'error'; text: string };
interface Take { wav: Blob; url: string; seconds: number; from: 'file' | 'record'; name: string }
interface Sample { voice: string; url: string; state: 'loading' | 'ready' | 'error' }
const message = (e: unknown) => e instanceof VoiceError ? e.message : SAY.failed;
const secs = (s: number) => `${s.toFixed(1)} 秒`;
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
// A small themed player instead of the browser's grey bar; the <audio> under it does the playing and reports canplay.
function Pill({ src, label, auto = false, tone, audio, onReady, onBroken }: { src: string; label: string; auto?: boolean; tone: 'ref' | 'her'; audio: RefObject<HTMLAudioElement>; onReady?: () => void; onBroken?: () => void }) {
const [playing, setPlaying] = useState(false), [at, setAt] = useState(0), [length, setLength] = useState(0);
return <div className={`pill ${tone} ${playing ? 'on' : ''}`} role="group" aria-label={label}>
<audio ref={audio} src={src} preload="auto" autoPlay={auto} onCanPlay={onReady} onError={onBroken} onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)} onEnded={() => setPlaying(false)} onTimeUpdate={e => setAt(e.currentTarget.currentTime)} onLoadedMetadata={e => setLength(Number.isFinite(e.currentTarget.duration) ? e.currentTarget.duration : 0)}/>
<button className="pill-play" aria-label={playing ? `暂停${label}` : `播放${label}`} onClick={() => { const a = audio.current; if (!a) return; if (a.paused) void a.play().catch(() => {}); else a.pause(); }}><Icon name={playing ? 'pause' : 'play'}/></button>
<span className="pill-track" aria-hidden="true"><i style={{ transform: `scaleX(${length ? Math.min(1, at / length) : 0})` }}/></span>
<span className="pill-time">{clock(at)} / {clock(length)}</span>
</div>;
}
export function VoiceSection({ name, active, onDirty, onCap }: { name: string; active: boolean; onDirty: (dirty: boolean) => void; onCap: (cap: VoiceCap) => void }) {
const [load, setLoad] = useState<Load>({ state: 'loading' }), [reads, setReads] = useState(0), [seen, setSeen] = useState(active);
const [from, setFrom] = useState<'file' | 'record'>('file'), [rec, setRec] = useState<RecView>({ phase: 'idle', seconds: 0 });
const [take, setTake] = useState<Take | null>(null), [form, setForm] = useState({ label: '', text: '', allowAsr: false });
const [registering, setRegistering] = useState(false), [regNote, setRegNote] = useState<{ text: string; error: boolean }>({ text: '', error: false });
const [candidate, setCandidate] = useState(''), [line, setLine] = useState(''), [sample, setSample] = useState<Sample | null>(null);
const [applying, setApplying] = useState(false), [note, setNote] = useState<{ text: string; error: boolean }>({ text: '', error: false });
const fileInput = useRef<HTMLInputElement>(null), player = useRef<HTMLAudioElement>(null), refPlayer = useRef<HTMLAudioElement>(null);
// One controller per kind of request; a new one aborts the old. `takeGen` guards decoding a file or a recording.
const ctl = useRef<Record<'list' | 'register' | 'preview' | 'apply', AbortController | null>>({ list: null, register: null, preview: null, apply: null });
const takeGen = useRef(0), urls = useRef(new Set<string>()), listed = useRef(false);
// HEL-903: a registration that failed without a clear answer may still have been filed. `unsure` holds the custom ids
// listed when it failed, so an explicit refresh can say which entry is new (never a silent second registration).
const [unsure, setUnsure] = useState(false), unsureBefore = useRef<Set<string> | null>(null);
const settleUnsure = () => { unsureBefore.current = null; setUnsure(false); };
const loadNow = useRef(load); loadNow.current = load;
const fresh = (kind: keyof typeof ctl.current) => { ctl.current[kind]?.abort(); const c = new AbortController(); ctl.current[kind] = c; return c; };
const own = (blob: Blob) => { const url = URL.createObjectURL(blob); urls.current.add(url); return url; };
const drop = (url?: string) => { if (url && urls.current.delete(url)) URL.revokeObjectURL(url); };
const recorder = useRef<Recorder | null>(null);
recorder.current ??= new Recorder({ view: setRec, done: blob => void accept(blob, 'record', '这次录音'), fail: text => setRegNote({ text, error: true }) });
// Everything this section holds goes with it: requests, microphone, audio and every object URL.
useEffect(() => () => {
recorder.current?.cancel();
Object.values(ctl.current).forEach(c => c?.abort());
takeGen.current++;
for (const url of urls.current) URL.revokeObjectURL(url);
urls.current.clear();
}, []);
// Out of sight is out of use: the microphone closes and nothing keeps playing. A finished take stays for later.
useEffect(() => {
if (active) { setSeen(true); return; }
if (recorder.current?.busy && recorder.current.view.phase !== 'converting') { recorder.current.cancel(); setRegNote({ text: '离开了这里,录音已停止并丢弃,麦克风已关闭。', error: false }); }
player.current?.pause(); refPlayer.current?.pause();
}, [active]);
useEffect(() => { onDirty(!!take); return () => onDirty(false); }, [take, onDirty]);
// Read the list the first time the section is opened (and on retry). Reading never touches the microphone.
useEffect(() => {
if (!seen) return;
const c = fresh('list');
setLoad(l => l.state === 'ok' ? l : { state: 'loading' });
listVoices(c.signal).then(list => {
if (c.signal.aborted) return;
if (!list.configured) { setLoad({ state: 'off', text: SAY.notConfigured }); onCap('off'); return; }
listed.current = true; setNote(n => n.error ? { text: '', error: false } : n); setLoad({ state: 'ok', voices: list.voices, selection: list.selection }); onCap('edit');
const before = unsureBefore.current;
if (before) {
const fresh = list.voices.filter(v => v.source === 'custom' && !before.has(v.id));
if (fresh.length) { settleUnsure(); setRegNote({ text: `列表里多了${fresh.map(v => `「${v.label}」`).join('、')},${SAY.refreshFound}`, error: false }); }
else setRegNote({ text: SAY.refreshNone, error: false });
}
}, e => {
if (c.signal.aborted) return;
const status = e instanceof VoiceError ? e.status : 0;
if (status === 404 || status === 405) { setLoad({ state: 'off', text: SAY.noRoute }); onCap('off'); return; }
// A list already on screen stays usable; only the first read turns into the retry card.
if (listed.current) { setNote({ text: message(e), error: true }); return; }
setLoad({ state: 'error', text: message(e) }); onCap('unknown');
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-read only on first sight and explicit retries.
}, [seen, reads]);
async function accept(input: Blob, kind: 'file' | 'record', label: string) {
const gen = ++takeGen.current;
setRegNote({ text: kind === 'file' ? '正在读取并转成 WAV…' : '', error: false });
try {
const ref = await toReference(input);
if (gen !== takeGen.current) return;
settleUnsure();
const url = own(ref.wav);
setTake(old => { drop(old?.url); return { wav: ref.wav, url, seconds: ref.seconds, from: kind, name: label }; });
setRegNote({ text: '', error: false });
} catch (e) {
if (gen !== takeGen.current) return;
setRegNote({ text: message(e), error: true });
} finally { if (gen === takeGen.current) recorder.current?.settled(); }
}
function chooseFile(file?: File) {
if (fileInput.current) fileInput.current.value = '';
if (!file || registering) return;
void accept(file, 'file', file.name.slice(0, 60));
}
function discardTake() { takeGen.current++; recorder.current?.cancel(); settleUnsure(); setTake(old => { drop(old?.url); return null; }); setRegNote({ text: '', error: false }); }
function switchFrom(next: 'file' | 'record') { if (next === from) return; recorder.current?.cancel(); setFrom(next); setRegNote({ text: '', error: false }); }
const text = form.text.trim(), needText = !text && !form.allowAsr;
async function register() {
// A second tap before the first answer is ignored (not a replacement): the first upload may already be on the server.
if (!take || ctl.current.register || needText) return;
const c = fresh('register'), sent = take;
setRegistering(true); setRegNote({ text: form.allowAsr && !text ? '正在登记,并让服务器把这段声音转写成文字…' : '正在登记这段声音…', error: false });
try {
const done = await registerVoice(sent.wav, form, c.signal);
if (c.signal.aborted) return;
settleUnsure();
setTake(old => { if (old === sent) { drop(old.url); return null; } return old; });
setForm({ label: '', text: '', allowAsr: false });
setRegNote({ text: done.prompt_text_source === 'asr' ? `登记好了:「${done.label}」。服务器转写的参考文字是:${done.prompt_text || '(空)'}` : `登记好了:「${done.label}」。`, error: false });
setLoad(l => l.state === 'ok' && !l.voices.some(v => v.id === done.id) ? { ...l, voices: [...l.voices, { id: done.id, label: done.label, source: 'custom', selected: false, previewed: false, registered_at: Date.now() }] } : l);
pick(done.id, `先听听${name}用「${done.label}」说话,再决定要不要给她用。`);
setReads(n => n + 1);
} catch (e) {
if (c.signal.aborted) return;
// Nothing is lost on failure: the take, the label and the text all stay for another try. When the server may
// have filed it anyway, say so and offer a look at the list first; the list is not re-read behind your back.
if (e instanceof VoiceError && e.uncertain) {
const l = loadNow.current;
if (l.state === 'ok' && !unsureBefore.current) unsureBefore.current = new Set(l.voices.filter(v => v.source === 'custom').map(v => v.id));
setUnsure(true);
// The server's own words when it gave any; a bare "try again later" would contradict "look first".
setRegNote({ text: e.message === SAY.failed || e.message === SAY.odd ? SAY.maybeRegistered : `${e.message}${SAY.maybeRegistered}`, error: true });
} else setRegNote({ text: message(e), error: true });
} finally { if (ctl.current.register === c) { ctl.current.register = null; setRegistering(false); } }
}
// Choosing another voice retires the sample of the previous one at once.
function pick(id: string, say = '') {
ctl.current.preview?.abort(); ctl.current.preview = null;
player.current?.pause();
setSample(old => { drop(old?.url); return null; });
setCandidate(id); setNote({ text: say, error: false });
}
async function preview() {
if (!candidate) return;
const c = fresh('preview'), voice = candidate;
player.current?.pause();
setSample(old => { drop(old?.url); return { voice, url: '', state: 'loading' }; });
setNote({ text: '正在用这个声音现场合成…', error: false });
try {
const wav = await previewVoice(voice, line, c.signal);
if (c.signal.aborted) return;
// Shown only once the browser has really loaded it (canplay); until then it is not "heard".
const url = own(wav);
setSample({ voice, url, state: 'loading' });
} catch (e) {
if (c.signal.aborted) return;
setSample(null); setNote({ text: message(e), error: true });
}
}
const playable = (url: string) => { setSample(s => s && s.url === url && s.state === 'loading' ? { ...s, state: 'ready' } : s); setNote(n => n.error ? n : { text: '', error: false }); };
const unplayable = (url: string) => { setSample(s => { if (!s || s.url !== url) return s; drop(url); return null; }); setNote({ text: SAY.unplayable, error: true }); };
async function apply() {
if (!candidate || ctl.current.apply || sample?.voice !== candidate || sample.state !== 'ready') return;
const c = fresh('apply'), voice = candidate;
setApplying(true); setNote({ text: '正在给她换上…', error: false });
try {
const selection = await applyVoice(voice, c.signal);
if (c.signal.aborted) return;
setLoad(l => l.state === 'ok' ? { ...l, selection, voices: l.voices.map(v => ({ ...v, selected: v.id === voice, previewed: v.previewed || v.id === voice })) } : l);
setNote({ text: `好了,${name}之后的通话会用「${selection.label || voice}」。已经接通的通话不会中途换声。名字、底色、关系、聊天和记忆都没有变。`, error: false });
} catch (e) {
if (c.signal.aborted) return;
setNote({ text: message(e), error: true });
} finally { if (ctl.current.apply === c) { ctl.current.apply = null; setApplying(false); } }
}
if (load.state === 'loading') return <div className="skel" aria-hidden="true">{[70, 54, 62].map(w => <i key={w} style={{ width: `${w}%` }}/>)}</div>;
if (load.state === 'off') return <div className="off-card"><p className="off-t">{load.text}</p><p>接上以后:上传或录一段参考声音 → 试听{name}用它说话 → 给她用。只换声音,名字、底色、关系、聊天和记忆都不变。</p><p className="d-dim">这里不会请求麦克风,也不会上传任何声音。</p></div>;
if (load.state === 'error') return <div className="off-card" role="alert"><p className="off-t">声音列表一时没读出来。</p><p>{load.text}</p><Button onClick={() => setReads(n => n + 1)}>再试一次</Button></div>;
const { voices, selection } = load, current = voices.find(v => v.id === selection.voice);
const mine = voices.filter(v => v.source === 'custom'), builtin = voices.filter(v => v.source === 'builtin');
const chosen = voices.find(v => v.id === candidate), ready = sample?.voice === candidate && sample.state === 'ready';
const recording = rec.phase === 'asking' || rec.phase === 'recording';
const row = (v: CustomVoice) => <li key={v.id}><button aria-pressed={candidate === v.id} onClick={() => pick(v.id)} disabled={applying}><span>{v.label}{v.selected && <span className="tag accent">正在用</span>}</span><small>{idOnly(v) ? '自定义 · 只有编号,缺本地资料' : v.source === 'custom' ? `自定义${v.registered_at ? ` · ${clockOf(v.registered_at).date}` : ''}` : '服务自带'}{v.previewed ? ' · 试听过' : ''}</small></button></li>;
return <div className="voice-sec">
<dl className="kv"><dt>现在的声音</dt><dd>{selection.voice ? <>{current?.label || selection.label || selection.voice}<small>{current?.source === 'custom' ? '自定义' : current ? '服务自带' : '这个声音已不在列表里,通话时会用默认声音'}{selection.updated_at ? ` · ${clockOf(selection.updated_at).date} ${clockOf(selection.updated_at).time} 换上` : ''}</small></> : <>还没有为她选过<small>通话时用服务默认的声音</small></>}</dd>
<dt>什么时候生效</dt><dd>下一次通话<small>已经接通的通话不会中途换声</small></dd></dl>
<h4 className="sub-h">给一段参考声音<small>{SUGGEST_MIN}–{SUGGEST_MAX} 秒 · 安静的地方 · 念一两句完整的话</small></h4>
<div className="field"><div className="seg" role="group" aria-label="参考声音来源">{([['file', '上传音频文件'], ['record', '现在录一段']] as const).map(([k, label]) => <button key={k} aria-pressed={from === k} disabled={registering || rec.phase === 'converting'} onClick={() => switchFrom(k)}>{label}</button>)}</div></div>
{from === 'file' ? <div className="field file-pick"><label className={`btn-ghost sm ${registering ? 'is-disabled' : ''}`}><Icon name="up"/>选择音频文件<input ref={fileInput} type="file" accept=".wav,audio/wav,audio/*" disabled={registering} onChange={e => chooseFile(e.target.files?.[0])}/></label><small>WAV 最稳妥;其他格式要看这个浏览器能不能解开,解不开会直接说明。会转成 16kHz 单声道 WAV,只登记到这台服务器。</small></div>
: <div className={`rec card ${recording ? 'on' : ''}`} data-phase={rec.phase}>
{rec.phase === 'idle' && <><Button onClick={() => { setRegNote({ text: '', error: false }); void recorder.current?.start(); }} disabled={registering}><Icon name="mic"/>开始录音</Button><p className="d-dim">点了才会请求麦克风,只录声音。停止、取消或离开这里,麦克风马上关闭。最长 {SUGGEST_MAX} 秒,到时自动停。</p></>}
{rec.phase === 'asking' && <><p className="rec-line" role="status"><i className="rec-dot"/>正在请求麦克风…</p><Button onClick={() => recorder.current?.cancel()}>取消</Button></>}
{rec.phase === 'recording' && <><p className="rec-line" role="status" aria-live="off"><i className="rec-dot"/>正在录音 · 麦克风开着<b>{clock(rec.seconds)} / {clock(SUGGEST_MAX)}</b></p><div className="rec-bar" aria-hidden="true"><i style={{ transform: `scaleX(${Math.min(1, rec.seconds / SUGGEST_MAX)})` }}/><em style={{ left: `${SUGGEST_MIN / SUGGEST_MAX * 100}%` }}/></div><div className="rec-acts"><Button primary onClick={() => recorder.current?.stop()}><Icon name="stop"/>停止,用这段</Button><Button onClick={() => { recorder.current?.cancel(); setRegNote({ text: '录音已取消并丢弃,麦克风已关闭。', error: false }); }}>取消</Button></div></>}
{rec.phase === 'converting' && <p className="rec-line" role="status">麦克风已关闭 · 正在转成 WAV…</p>}
</div>}
{take && <div className="card take">
<h4><Icon name="user"/>参考原声 · 你给的声音,不是{name}的声音</h4>
<Pill key={take.url} src={take.url} label="参考原声" tone="ref" audio={refPlayer}/>
<p className="d-dim">{take.from === 'record' ? '这次录音' : take.name} · 已转为 WAV · 16kHz 单声道 16 位 · {secs(take.seconds)}{take.seconds < SUGGEST_MIN ? ` · 偏短,建议 ${SUGGEST_MIN} 秒以上` : take.seconds > SUGGEST_MAX ? ` · 偏长,建议 ${SUGGEST_MAX} 秒以内` : ''}</p>
<Field label="给这个声音起个名字" hint={`最多 ${LABEL_MAX} 个字,只用来在这里区分;不填就用编号。`}><input className="input" value={form.label} maxLength={LABEL_MAX} placeholder="比如:轻一点的她" disabled={registering} onChange={e => setForm(f => ({ ...f, label: e.target.value.replace(/[<>\p{Cc}]/gu, '') }))}/></Field>
<Field label="参考文字" hint="这段声音里念的原话。写得越准,克隆越像。"><textarea className="textarea" value={form.text} maxLength={TEXT_MAX} rows={2} disabled={registering} placeholder="照着录音里念的内容写下来" onChange={e => setForm(f => ({ ...f, text: e.target.value }))}/></Field>
<label className="switch-row"><span>参考文字留空时,允许服务器转写<small>{text ? '已经填写了参考文字,不会转写。' : '会用这台服务器上的语音识别听一遍这段声音;不打开就必须填写参考文字。'}</small></span><input type="checkbox" className="switch" checked={form.allowAsr && !text} disabled={!!text || registering} onChange={e => setForm(f => ({ ...f, allowAsr: e.target.checked }))}/></label>
<div className="take-acts">{unsure && <Button primary disabled={registering} onClick={() => setReads(n => n + 1)}><Icon name="list"/>刷新列表</Button>}<Button primary={!unsure} disabled={registering || needText} onClick={() => void register()}>{registering ? '正在登记…' : unsure ? '确认没有,再登记' : '登记这段声音'}</Button><Button disabled={registering} onClick={discardTake}>换一段</Button></div>
{needText && !registering && <p className="d-dim">填写参考文字,或打开上面的转写开关,才能登记。</p>}
</div>}
<Status text={regNote.text} error={regNote.error}/>
<h4 className="sub-h">可以给她用的声音<small>先试听,再给她用</small></h4>
<p className="voice-group">你给的声音</p>
{mine.length ? <ul className="city-list voice-list">{mine.map(row)}</ul> : <div className="empty sm">还没有登记过自己的声音。</div>}
{mine.some(idOnly) && <p className="d-dim voice-idonly">{SAY.idOnly}</p>}
{builtin.length > 0 && <details className="voice-builtin" open={!!chosen && chosen.source === 'builtin'}><summary>服务自带的声音 · {builtin.length} 个</summary><ul className="city-list voice-list">{builtin.map(row)}</ul></details>}
{chosen && <div className="card audition" aria-live="polite">
<h4><Icon name="sparkle"/>试听 · {chosen.label}</h4>
<Field label="试听这句话" hint={`可以换成你想听的话,最多 ${PREVIEW_MAX} 个字。每次都是现场合成,不拿旧的冒充。`}><input className="input" value={line} maxLength={PREVIEW_MAX} placeholder={PREVIEW_DEFAULT} onChange={e => setLine(e.target.value)}/></Field>
<div className="take-acts"><Button disabled={sample?.state === 'loading' || applying} onClick={() => void preview()}>{sample?.state === 'loading' ? '正在现场合成…' : sample ? '再听一次' : '生成试听'}</Button>
{chosen.selected ? <span className="tag accent">她现在就用这个</span> : <Button primary disabled={!ready || applying} onClick={() => void apply()}>{applying ? '正在换上…' : `给${name}用这个声音`}</Button>}</div>
{sample?.url && <figure className={`sample ${ready ? 'ready' : ''}`}><Pill key={sample.url} src={sample.url} label="试听" tone="her" auto audio={player} onReady={() => playable(sample.url)} onBroken={() => unplayable(sample.url)}/><figcaption>{name}用「{chosen.label}」说的 · 刚刚现场合成 · 不是参考原声</figcaption></figure>}
{!ready && !chosen.selected && <p className="d-dim">听过这次现场合成的试听,才能给她用。</p>}
</div>}
<Status text={note.text} error={note.error}/>
<p className="d-foot">换声音只换她说话的声音:名字、出生底色、关系、聊天和记忆都不变,也不会重新创建她。声音登记在这台服务器上;暂时不能在这里删除登记过的声音。</p>
</div>;
}
+56
View File
@@ -990,4 +990,60 @@ body.creating .now-chip{display:none}
.rel-cards{grid-template-columns:1fr}
.seg button{padding:0 12px;font-size:13px}
.derived{grid-template-columns:1fr}
}
/* ── 她的声音 (HEL-900): reference → fresh preview → apply, inside the settings pane. Tokens only; both themes follow. ── */
.voice-sec .card h4{letter-spacing:.06em}
.voice-sec .pill{display:flex;align-items:center;gap:12px;height:44px;padding:0 14px 0 5px;margin:2px 0 6px;border-radius:var(--radius-pill);background:var(--field);border:1px solid var(--field-line);transition:border-color var(--dur-ui) var(--ease-out),box-shadow var(--dur-ui) var(--ease-out)}
.voice-sec .pill audio{display:none}
.voice-sec .pill.on{border-color:color-mix(in srgb,var(--accent) 45%,transparent);box-shadow:0 0 16px -8px var(--accent-glow)}
.pill-play{flex:none;width:34px;height:34px;border-radius:50%;display:grid;place-items:center;color:var(--text-1);background:rgba(255,255,255,.07);transition:background-color var(--dur-ui) var(--ease-out),color var(--dur-ui) var(--ease-out)}
.pill.her .pill-play{color:var(--on-accent);background:var(--accent);box-shadow:0 0 14px -4px var(--accent-glow)}
.pill-play .ico{width:15px;height:15px}
.pill-track{position:relative;flex:1;height:3px;border-radius:2px;background:rgba(255,255,255,.08);overflow:hidden}
.pill-track i{position:absolute;inset:0;transform-origin:left;background:var(--text-2);transition:transform 250ms linear}
.pill.her .pill-track i{background:var(--accent)}
.pill-time{flex:none;font-family:var(--font-num);font-size:12px;color:var(--text-3);font-variant-numeric:tabular-nums}
.file-pick label{position:relative;cursor:pointer}
.file-pick label.is-disabled{opacity:.5;pointer-events:none}
.file-pick input[type=file]{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}
.file-pick:focus-within label{outline:2px solid var(--accent);outline-offset:2px}.voice-sec .take-acts,.voice-sec .rec-acts{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:6px}
.voice-sec .take .switch-row{border-top:0;padding:4px 0 10px}
.voice-sec .rec{display:flex;flex-direction:column;align-items:flex-start;gap:10px;transition:border-color var(--dur-ui) var(--ease-out),box-shadow var(--dur-ui) var(--ease-out)}
.voice-sec .rec .d-dim{margin:0}
.voice-sec .rec.on{border-color:color-mix(in srgb,var(--accent) 45%,transparent);box-shadow:0 0 22px -10px var(--accent-glow)}
.rec-line{display:flex;align-items:center;gap:10px;font-size:14.5px;color:var(--text-1)}
.rec-line b{font-family:var(--font-num);font-weight:400;color:var(--text-2);font-variant-numeric:tabular-nums;margin-left:4px}
.rec-dot{width:9px;height:9px;border-radius:50%;background:var(--danger);box-shadow:0 0 10px var(--danger);animation:dotPulse 1.2s var(--ease-in-out) infinite}
.rec[data-phase="asking"] .rec-dot{background:var(--text-3);box-shadow:none}
.rec-bar{position:relative;width:100%;height:4px;border-radius:2px;background:var(--field);overflow:hidden}
.rec-bar i{position:absolute;inset:0;transform-origin:left;background:var(--accent);box-shadow:0 0 8px var(--accent-glow);transition:transform 200ms linear}
.rec-bar em{position:absolute;top:0;bottom:0;width:1px;background:var(--text-3)}
.voice-group{font-size:12.5px;color:var(--text-3);margin:0 0 6px}
.voice-list li button > span{display:inline-flex;align-items:center;gap:8px}
.voice-list li button:disabled{opacity:.6}
.voice-sec .empty.sm{padding:14px 12px;font-size:13.5px;border:1px dashed var(--field-line);border-radius:14px}
.voice-builtin{margin:12px 0 16px}
.voice-builtin summary{cursor:pointer;font-size:13.5px;color:var(--text-2);padding:6px 2px;list-style-position:inside}
.voice-builtin summary:hover{color:var(--text-1)}
.voice-sec .audition{margin-top:16px;animation:detail-in var(--dur-ui) var(--ease-out)}
.voice-sec .sample{margin:12px 0 4px;padding:10px 12px;border-radius:14px;border:1px solid var(--field-line);opacity:.55;transition:opacity var(--dur-ui) var(--ease-out),border-color var(--dur-ui) var(--ease-out),box-shadow var(--dur-ui) var(--ease-out)}
.voice-sec .sample.ready{opacity:1;border-color:color-mix(in srgb,var(--accent) 50%,transparent);box-shadow:0 0 18px -8px var(--accent-glow)}
.voice-sec .sample figcaption{font-size:12px;color:var(--text-3)}
.voice-sec .take .ico,.voice-sec .audition .ico{width:14px;height:14px}
.voice-sec .btn-ghost .ico,.voice-sec .btn-primary .ico{width:16px;height:16px}
@media (max-width:720px){
.voice-sec .take-acts .btn-primary,.voice-sec .take-acts .btn-ghost,.voice-sec .rec-acts button{flex:1}
.voice-sec .rec .btn-ghost{width:100%}
}
/* ── HEL-903: the call's own voice on the call line, the voice status rows, id-only entries. Tokens only. ── */
.voice-row.real .voice-pin{display:inline-block;flex:0 1 auto;min-width:0;max-width:12em;padding-left:10px;border-left:1px solid var(--glass-line);color:var(--text-1);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.voice-row.real .voice-pin small{margin-right:6px;font-size:11px;color:var(--text-3);letter-spacing:.04em}
.voice-row.real .voice-pin.unknown{color:var(--text-3)}
.svc[data-state="ok"] .nm small{color:var(--ok)}
.svc[data-state="down"] .nm small{color:var(--warn)}
.voice-idonly{margin:8px 0 0;font-size:12.5px}
@media (max-width:720px){
.voice-sec .take-acts .btn-primary,.voice-sec .take-acts .btn-ghost{flex:1 1 auto;white-space:nowrap}
.voice-row.real .voice-pin{max-width:9.5em;padding-left:8px}
.voice-row.real .voice-pin .vp-wide{display:none}
}
+268
View File
@@ -0,0 +1,268 @@
// ── Her own voice (CG-010 v1.12, HEL-900): give a reference recording, hear her say something with it, apply it. ──
// Contract: docs/voice-custom-contract-HEL899.md §2. Everything goes through the same-origin API; the page never talks to
// the voice panel or TTS itself and holds no credentials. Registration takes raw PCM WAV bytes, so whatever the browser
// recorded or the file it was given is decoded here and written out again as 16 kHz mono 16-bit WAV — a WebM renamed to
// .wav is not a WAV. The reference you gave and her synthesized preview are separate things and are never swapped.
export interface CustomVoice { id: string; label: string; source: 'builtin' | 'custom'; selected: boolean; previewed: boolean; registered_at: number | null }
export interface VoiceSelection { voice: string | null; label: string; updated_at: number | null }
export type VoiceList = { configured: false } | { configured: true; voices: CustomVoice[]; selection: VoiceSelection };
export interface Registered { id: string; label: string; prompt_text: string; prompt_text_source: 'user' | 'asr' }
export const RATE = 16000;
export const MIN_SECONDS = 2, SUGGEST_MIN = 5, SUGGEST_MAX = 20, MAX_BYTES = 15 * 1024 * 1024;
// Bigger files are refused before decoding (memory); the server's own line is 15 MB of WAV.
export const MAX_INPUT_BYTES = 50 * 1024 * 1024;
export const LABEL_MAX = 20, TEXT_MAX = 500, PREVIEW_MAX = 200;
export const PREVIEW_DEFAULT = '你好,这是我的声音,以后就用它陪你说话。';
export const SAY = {
noRoute: '这台服务还没有自定义声音的通路。文字和现有语音不受影响。',
notConfigured: '语音还没有在这台服务上配置,暂时不能换声音。文字仍可使用。',
offline: '网络断开了。你给的声音和填写的内容都还在,连上后可以再试。',
unreachable: '暂时连不上服务器。你给的声音和填写的内容都还在,可以再试。',
odd: '服务器这次没有正确响应,请稍后再试。',
failed: '这次没有完成,请稍后再试。',
tooShort: '这段太短了(至少 2 秒),请录 5–20 秒、念一两句完整的话。',
tooBig: '这段声音太大了,请选 5–20 秒的一段。',
fileTooBig: '这个文件太大了,请选 5–20 秒的一段。',
undecodable: '这个浏览器解不开这段音频,没法转成 WAV。可以换一个 WAV 文件,或在这里直接录一段。',
silent: '这段几乎没有声音,请靠近麦克风再录一次。',
badPreview: '这次试听的结果不是可播放的声音,没有拿来播放。可以再试一次。',
unplayable: '这段试听在这个浏览器里播不出来,没有当作试过。可以再试一次。',
denied: '没有拿到麦克风权限,没有开始录音。可以改用上传文件。',
noMic: '没有找到可用的麦克风。可以改用上传文件。',
micBusy: '麦克风被别的程序占用了,没有开始录音。',
insecure: '这个地址不是安全连接,浏览器不允许使用麦克风。可以改用上传文件,或从 HTTPS 入口打开。',
noRecorder: '这个浏览器不支持在网页里录音。可以改用上传文件。',
micGone: '麦克风被关掉了,这段没有录完,已经丢弃。',
recordFailed: '这次录音没有成功,已经丢弃,可以再录一次。',
// HEL-903: the upload may have reached the shared voice panel even though no answer came back (server error, a
// dropped connection, local metadata not saved). Look before registering again, so one voice is not filed twice.
maybeRegistered: '这段声音可能已经登记上了,只是这边没收到结果。先点「刷新列表」,看看「你给的声音」里有没有新出现的编号条目(cg- 开头):有就直接选它试听、使用,不用再登记一次。',
refreshFound: '很可能就是刚才那段:直接在下面选它试听、使用就好,不用再登记一次。',
refreshNone: '列表里暂时没有新出现的条目。服务器可能还在处理,稍等几秒再刷新一次;确认没有再登记。',
idOnly: '只有编号的条目:声音已经登记在共享面板上,但这台服务器没存下它的名字和参考文字,多半来自一次没收到结果的登记。它仍可试听、使用;这里暂时不能删除。',
} as const;
// `uncertain`: the request may have been carried out even though it failed here (sent, then no usable answer).
export class VoiceError extends Error { constructor(message: string, readonly status = 0, readonly uncertain = false) { super(message); } }
// ── WAV ──
export function encodeWav(samples: Float32Array, rate = RATE): ArrayBuffer {
const out = new ArrayBuffer(44 + samples.length * 2), v = new DataView(out);
const text = (at: number, s: string) => { for (let i = 0; i < s.length; i++) v.setUint8(at + i, s.charCodeAt(i)); };
text(0, 'RIFF'); v.setUint32(4, 36 + samples.length * 2, true); text(8, 'WAVE');
text(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, rate, true); v.setUint32(28, rate * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true);
text(36, 'data'); v.setUint32(40, samples.length * 2, true);
for (let i = 0; i < samples.length; i++) { const s = Math.max(-1, Math.min(1, samples[i])); v.setInt16(44 + i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true); }
return out;
}
export interface WavInfo { rate: number; channels: number; bits: number; seconds: number }
// Reads the header the way the server checks it (RIFF/WAVE, fmt and data chunks). Anything else is not a WAV.
export function wavInfo(buf: ArrayBuffer): WavInfo | null {
if (buf.byteLength < 44) return null;
const v = new DataView(buf), tag = (at: number) => String.fromCharCode(v.getUint8(at), v.getUint8(at + 1), v.getUint8(at + 2), v.getUint8(at + 3));
if (tag(0) !== 'RIFF' || tag(8) !== 'WAVE') return null;
let at = 12, fmt: Omit<WavInfo, 'seconds'> | null = null;
while (at + 8 <= buf.byteLength) {
const id = tag(at), size = v.getUint32(at + 4, true);
if (id === 'fmt ' && size >= 16 && at + 24 <= buf.byteLength) fmt = { channels: v.getUint16(at + 10, true), rate: v.getUint32(at + 12, true), bits: v.getUint16(at + 22, true) };
if (id === 'data') {
if (!fmt || !fmt.rate || !fmt.channels || !fmt.bits) return null;
const bytes = Math.min(size, buf.byteLength - at - 8);
return { ...fmt, seconds: bytes / (fmt.rate * fmt.channels * (fmt.bits / 8)) };
}
at += 8 + size + (size & 1);
}
return null;
}
export interface Decoded { sampleRate: number; length: number; numberOfChannels: number; getChannelData(channel: number): Float32Array }
export function mixdown(buf: Decoded): Float32Array {
const out = new Float32Array(buf.length), n = buf.numberOfChannels;
for (let c = 0; c < n; c++) { const d = buf.getChannelData(c); for (let i = 0; i < out.length; i++) out[i] += d[i] / n; }
return out;
}
// Decodes with the browser's own decoder, resampled to 16 kHz by an offline context (no device, nothing to close).
export function browserDecode(data: ArrayBuffer): Promise<Decoded> {
const Offline = (globalThis as { OfflineAudioContext?: typeof OfflineAudioContext }).OfflineAudioContext;
if (!Offline) return Promise.reject(new VoiceError(SAY.undecodable));
return new Offline(1, 1, RATE).decodeAudioData(data);
}
export interface Reference { wav: Blob; seconds: number }
// Any audio the browser can decode → the WAV the server accepts. Refuses what the server would refuse anyway.
export async function toReference(input: Blob, decode: (data: ArrayBuffer) => Promise<Decoded> = browserDecode): Promise<Reference> {
if (input.size > MAX_INPUT_BYTES) throw new VoiceError(SAY.fileTooBig);
let decoded: Decoded;
try { decoded = await decode(await input.arrayBuffer()); } catch { throw new VoiceError(SAY.undecodable); }
let samples = mixdown(decoded);
if (decoded.sampleRate !== RATE) samples = resample(samples, decoded.sampleRate, RATE);
const seconds = samples.length / RATE;
if (seconds < MIN_SECONDS) throw new VoiceError(SAY.tooShort);
if (!samples.some(s => Math.abs(s) > 0.003)) throw new VoiceError(SAY.silent);
const wav = encodeWav(samples, RATE);
if (wav.byteLength > MAX_BYTES) throw new VoiceError(SAY.tooBig);
return { wav: new Blob([wav], { type: 'audio/wav' }), seconds };
}
// Only used when a decoder ignored the requested rate; linear is enough for a reference prompt.
export function resample(input: Float32Array, from: number, to: number): Float32Array {
const out = new Float32Array(Math.floor(input.length * to / from)), step = from / to;
for (let i = 0; i < out.length; i++) { const x = i * step, j = Math.floor(x), f = x - j; out[i] = (input[j] ?? 0) * (1 - f) + (input[j + 1] ?? input[j] ?? 0) * f; }
return out;
}
// ── API (same origin) ──
type Fetch = typeof fetch;
const online = () => typeof navigator === 'undefined' || navigator.onLine !== false;
async function call(f: Fetch, path: string, init: RequestInit): Promise<Response> {
if (!online()) throw new VoiceError(SAY.offline);
try { return await f(`/api/voice/${path}`, init); }
// Gone after sending started: whether the server acted on it is unknown (an offline check above sent nothing).
catch (e) { if (init.signal?.aborted) throw e; throw new VoiceError(online() ? SAY.unreachable : SAY.offline, 0, true); }
}
async function refusal(r: Response, fallback: string = SAY.failed): Promise<VoiceError> {
const data = await r.json().catch(() => null) as { detail?: unknown } | null;
return new VoiceError(typeof data?.detail === 'string' && data.detail ? data.detail : fallback, r.status);
}
const voiceItem = (v: unknown): v is CustomVoice => { const x = v as Record<string, unknown>; return !!x && typeof x === 'object' && typeof x.id === 'string' && /^[\w-]{1,40}$/u.test(x.id) && typeof x.label === 'string' && (x.source === 'builtin' || x.source === 'custom'); };
function selectionOf(v: unknown): VoiceSelection {
const s = v as Record<string, unknown> | null;
return { voice: typeof s?.voice === 'string' && s.voice ? s.voice : null, label: typeof s?.label === 'string' ? s.label : '', updated_at: Number.isFinite(s?.updated_at) ? s!.updated_at as number : null };
}
export async function listVoices(signal: AbortSignal, f: Fetch = fetch): Promise<VoiceList> {
const r = await call(f, 'voices', { signal });
// A server from before HEL-899 has no such route: "not wired", not a failure.
if (r.status === 404 || r.status === 405) throw new VoiceError(SAY.noRoute, r.status);
if (!r.ok) throw await refusal(r);
const data = await r.json().catch(() => null) as { configured?: unknown; voices?: unknown; selection?: unknown } | null;
if (!data || typeof data !== 'object') throw new VoiceError(SAY.odd);
if (data.configured !== true) return { configured: false };
const voices = (Array.isArray(data.voices) ? data.voices : []).filter(voiceItem).map(v => ({ id: v.id, label: v.label || v.id, source: v.source, selected: v.selected === true, previewed: v.previewed === true, registered_at: Number.isFinite(v.registered_at) ? v.registered_at : null }));
return { configured: true, voices, selection: selectionOf(data.selection) };
}
export async function registerVoice(wav: Blob, form: { label: string; text: string; allowAsr: boolean }, signal: AbortSignal, f: Fetch = fetch): Promise<Registered> {
const q = new URLSearchParams({ label: form.label.trim(), prompt_text: form.text.trim(), allow_asr: form.allowAsr && !form.text.trim() ? 'true' : 'false' });
const r = await call(f, `voices?${q}`, { method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: wav, signal });
// A 4xx is a clear refusal (nothing filed). A 5xx, or a success whose body never arrived whole, may sit after the
// panel already took the voice (HEL-902 §4: metadata write failing → 500, panel down → 503).
if (!r.ok) { const e = await refusal(r); throw r.status >= 500 ? new VoiceError(e.message, r.status, true) : e; }
const data = await r.json().catch(() => null) as Partial<Registered> | null;
if (!data || typeof data.id !== 'string' || !data.id) throw new VoiceError(SAY.odd, r.status, true);
return { id: data.id, label: typeof data.label === 'string' && data.label ? data.label : data.id, prompt_text: typeof data.prompt_text === 'string' ? data.prompt_text : '', prompt_text_source: data.prompt_text_source === 'asr' ? 'asr' : 'user' };
}
// A voice the server lists with no local metadata (label falls back to the id, no registration time): the panel has it,
// this server never recorded it — typically a registration whose answer was lost.
export const idOnly = (v: CustomVoice) => v.source === 'custom' && v.registered_at === null && v.label === v.id;
// The name a call's pinned voice is shown by: its own label if the list knows that id, else the id itself; never a guess.
export function callVoiceName(id: string | null, named: { id: string; label: string } | null): string {
if (!id) return '声音未说明';
if (id === 'default') return '默认声音';
return named?.id === id ? named.label : id;
}
// A fresh synthesis every time. Only a real WAV comes back as playable; anything else is refused, never played.
export async function previewVoice(voice: string, text: string, signal: AbortSignal, f: Fetch = fetch): Promise<Blob> {
const r = await call(f, 'preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(text.trim() ? { voice, text: text.trim() } : { voice }), signal });
if (!r.ok) throw await refusal(r);
const data = await r.arrayBuffer().catch(() => null);
const info = data && wavInfo(data);
if (!data || !info || info.seconds <= 0) throw new VoiceError(SAY.badPreview);
return new Blob([data], { type: 'audio/wav' });
}
export async function applyVoice(voice: string, signal: AbortSignal, f: Fetch = fetch): Promise<VoiceSelection> {
const r = await call(f, 'selection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice }), signal });
if (!r.ok) throw await refusal(r);
const s = selectionOf(await r.json().catch(() => null));
if (s.voice !== voice) throw new VoiceError(SAY.odd);
return s;
}
// ── Recording. Only ever started by a tap; stop, cancel, leaving or a lost track releases the microphone at once. ──
export type RecPhase = 'idle' | 'asking' | 'recording' | 'converting';
export interface RecView { phase: RecPhase; seconds: number }
export interface MediaParts {
getUserMedia(c: MediaStreamConstraints): Promise<MediaStream>;
recorder(stream: MediaStream): MediaRecorder;
secure(): boolean;
}
export const browserMedia: MediaParts = {
getUserMedia: c => navigator.mediaDevices.getUserMedia(c),
recorder: stream => new MediaRecorder(stream),
secure: () => typeof window !== 'undefined' && window.isSecureContext && !!navigator.mediaDevices?.getUserMedia,
};
function micFailure(e: unknown) {
const name = (e as { name?: string } | null)?.name || '';
if (name === 'NotFoundError' || name === 'DevicesNotFoundError' || name === 'OverconstrainedError') return SAY.noMic;
if (name === 'NotReadableError' || name === 'AbortError') return SAY.micBusy;
return SAY.denied;
}
export class Recorder {
private gen = 0;
private stream: MediaStream | null = null;
private rec: MediaRecorder | null = null;
private timer: ReturnType<typeof setInterval> | undefined;
private began = 0;
view: RecView = { phase: 'idle', seconds: 0 };
constructor(private on: { view(v: RecView): void; done(take: Blob): void; fail(message: string): void }, private media: MediaParts = browserMedia, private maxSeconds = SUGGEST_MAX, private now = () => Date.now()) {}
private show(v: RecView) { this.view = v; this.on.view(v); }
get busy() { return this.view.phase !== 'idle'; }
async start() {
if (this.busy) return;
const gen = ++this.gen;
if (!this.media.secure()) { this.on.fail(SAY.insecure); return; }
if (typeof MediaRecorder === 'undefined' && this.media === browserMedia) { this.on.fail(SAY.noRecorder); return; }
this.show({ phase: 'asking', seconds: 0 });
let stream: MediaStream;
// Audio only: no camera, and the browser's own echo/noise handling stays on.
try { stream = await this.media.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true }, video: false }); }
catch (e) { if (gen === this.gen) { this.show({ phase: 'idle', seconds: 0 }); this.on.fail(micFailure(e)); } return; }
// Cancelled (or left) while the permission prompt was up: the late microphone is closed, never used.
if (gen !== this.gen) { stream.getTracks().forEach(t => t.stop()); return; }
this.stream = stream;
let rec: MediaRecorder;
try { rec = this.media.recorder(stream); } catch { this.release(); this.show({ phase: 'idle', seconds: 0 }); this.on.fail(SAY.noRecorder); return; }
this.rec = rec;
const chunks: Blob[] = [];
rec.ondataavailable = e => { if (e.data?.size) chunks.push(e.data); };
rec.onstop = () => {
if (gen !== this.gen) return;
const take = new Blob(chunks, { type: rec.mimeType || chunks[0]?.type || '' });
this.release(); this.gen++;
this.show({ phase: 'converting', seconds: this.view.seconds });
this.on.done(take);
};
rec.onerror = () => { if (gen === this.gen) this.abandon(SAY.recordFailed); };
stream.getAudioTracks().forEach(t => t.addEventListener('ended', () => { if (gen === this.gen) this.abandon(SAY.micGone); }));
try { rec.start(250); } catch { this.abandon(SAY.recordFailed); return; }
this.began = this.now();
this.show({ phase: 'recording', seconds: 0 });
this.timer = setInterval(() => {
if (gen !== this.gen) return;
const seconds = (this.now() - this.began) / 1000;
if (seconds >= this.maxSeconds) { this.show({ phase: 'recording', seconds: this.maxSeconds }); this.stop(); return; }
this.show({ phase: 'recording', seconds });
}, 200);
}
// Keep what was recorded: the recorder flushes, then `done` hands the take over (and the microphone is already off).
stop() {
clearInterval(this.timer);
const rec = this.rec;
if (this.view.phase !== 'recording' || !rec || rec.state === 'inactive') { this.cancel(); return; }
this.view = { ...this.view, seconds: Math.min(this.maxSeconds, (this.now() - this.began) / 1000) };
try { rec.stop(); } catch { this.abandon(SAY.recordFailed); return; }
// The tracks stop now, not when the recorder gets round to its last chunk.
this.stream?.getTracks().forEach(t => t.stop());
}
// Throw the take away. Safe to call any time, also twice; a late permission answer is closed when it arrives.
cancel() { this.gen++; this.release(); if (this.view.phase !== 'idle') this.show({ phase: 'idle', seconds: 0 }); }
// The converted take was accepted or refused: back to idle.
settled() { if (this.view.phase === 'converting') this.show({ phase: 'idle', seconds: 0 }); }
private abandon(message: string) { this.cancel(); this.on.fail(message); }
private release() {
clearInterval(this.timer); this.timer = undefined;
const rec = this.rec; this.rec = null;
if (rec) { rec.ondataavailable = null; rec.onstop = null; rec.onerror = null; try { if (rec.state !== 'inactive') rec.stop(); } catch { /* Already stopped. */ } }
this.stream?.getTracks().forEach(t => t.stop()); this.stream = null;
}
}
+26 -11
View File
@@ -24,12 +24,16 @@ export interface VoiceView {
notice: string;
// The browser held her audio back until a tap (autoplay rules); `resume` lets it out.
blocked: boolean;
// The voice the server pinned for THIS call (HEL-903, session response `voice`): null until a room is granted;
// `id: null` when the server did not say. It stays for the whole call; a choice made in settings is the next call's.
voice: CallVoice | null;
}
export interface CallVoice { id: string | null }
// One "she speaks" (an SDK speech): her reply to your voice turn, or a stored reach-out read aloud. `open` grows by
// replacement (each interim is the whole text so far); `final` is the stored reply's exact text; `done` is closed and
// stays only until the record (or the next turn) takes its place.
export interface HerLine { speech: string; text: string; state: 'open' | 'final' | 'done'; turnId?: string; messageId?: number; proactive?: boolean }
export const OFF: VoiceView = { phase: 'off', heard: '', spoken: '', her: null, notice: '', blocked: false };
export const OFF: VoiceView = { phase: 'off', heard: '', spoken: '', her: null, notice: '', blocked: false, voice: null };
// ── cg.subtitle contract v2 (docs/voice-subtitle-contract-v2.md). Anything that is not exactly v2 is dropped. ──
const USER_KINDS = ['interim', 'user_final'] as const;
@@ -69,7 +73,9 @@ export interface VoiceRoom { publish(mic: MicTrack): Promise<void>; hush(on: boo
export interface VoiceTransport { capture(): Promise<MicTrack>; join(url: string, token: string, on: RoomEvents, signal: AbortSignal): Promise<VoiceRoom> }
export interface VoiceAvailability { available: boolean; reason: string }
export interface SessionGrant { url: string; token: string; room: string; session_id: string; epoch: number; expires_at: number }
export interface SessionGrant { url: string; token: string; room: string; session_id: string; epoch: number; expires_at: number; voice?: unknown }
// Only an id the voice list could hold is taken as the call's voice; anything else is "not said", never guessed.
export const callVoiceOf = (grant: { voice?: unknown }): CallVoice => ({ id: typeof grant.voice === 'string' && /^[\w-]{1,40}$/u.test(grant.voice) ? grant.voice : null });
export const WORDS = {
notConfigured: '语音还没有在这台服务上配置,文字仍可使用。',
@@ -109,16 +115,24 @@ interface Live { gen: number; abort: AbortController; mic?: MicTrack; room?: Voi
const AGENT_PHASE: Record<string, VoicePhase> = { initializing: 'joining', listening: 'listening', thinking: 'thinking', speaking: 'speaking' };
export async function voiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceAvailability> {
// What GET /api/voice/status can actually tell (HEL-903): no route on this server, not configured, configured with the
// TTS health probe passing ('ok') or failing ('down'), or unreadable. It says nothing about recognition on its own and
// never that a whole call works end to end (`voice` stays false until real-device acceptance).
export type VoiceStatusKind = 'none' | 'off' | 'ok' | 'down' | 'unknown';
export async function readVoiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceStatusKind> {
let response: Response;
try { response = await f('/api/voice/status', { signal }); } catch (e) { if (signal.aborted) throw e; return { available: false, reason: WORDS.unknown }; }
try { response = await f('/api/voice/status', { signal }); } catch (e) { if (signal.aborted) throw e; return 'unknown'; }
// A server from before R5 has no such route: that is "not wired", not a failure.
if (response.status === 404 || response.status === 405) return { available: false, reason: WORDS.noRoute };
if (response.status === 404 || response.status === 405) return 'none';
const data = await response.json().catch(() => null) as { configured?: unknown; tts_ok?: unknown } | null;
if (!response.ok || !data) return { available: false, reason: WORDS.unknown };
if (data.configured !== true) return { available: false, reason: WORDS.notConfigured };
if (data.tts_ok !== true) return { available: false, reason: WORDS.ttsDown };
return { available: true, reason: '' };
if (!response.ok || !data) return 'unknown';
if (data.configured !== true) return 'off';
return data.tts_ok === true ? 'ok' : 'down';
}
const REASON: Record<VoiceStatusKind, string> = { none: WORDS.noRoute, off: WORDS.notConfigured, ok: '', down: WORDS.ttsDown, unknown: WORDS.unknown };
export async function voiceStatus(signal: AbortSignal, f: typeof fetch = fetch): Promise<VoiceAvailability> {
const kind = await readVoiceStatus(signal, f);
return { available: kind === 'ok', reason: REASON[kind] };
}
function micFailure(e: unknown, secure: boolean) {
@@ -202,8 +216,9 @@ export class VoiceController {
if (!response.ok || !data?.token || !data.url || !live.session) return fail(typeof data?.detail === 'string' && data.detail ? data.detail : WORDS.unreachable);
const grant = data;
// 4. Join and speak.
this.show({ phase: 'joining' }, gen);
// 4. Join and speak. The voice this room was opened with is shown from here to the end of this call; a late grant
// of an older call never gets here (stale), and every end or failure resets the view.
this.show({ phase: 'joining', voice: callVoiceOf(grant) }, gen);
const events: RoomEvents = {
agentJoined: () => { if (stale() || live.agent) return; live.agent = true; clearTimeout(live.timer); if (!live.stated) this.show({ phase: 'listening' }, gen); },
// Her worker gone with the room still open: no one would ever answer, so the call ends honestly, exactly as a
+1 -1
View File
@@ -62,7 +62,7 @@ describe('her worker leaves while the room stays open (real adapter, SDK event n
room.say({ v: 2, seq: 3, kind: 'assistant_interim', role: 'assistant', text: '在听呢。', speech_id: 'sp' });
expect(r.view()).toMatchObject({ phase: 'thinking', her: { speech: 'sp', text: '在听呢。', state: 'open' } });
room.leave(worker.identity); await flush();
expect(r.view()).toEqual({ phase: 'off', heard: '', spoken: '', her: null, notice: WORDS.agentLeft, blocked: false });
expect(r.view()).toEqual({ phase: 'off', heard: '', spoken: '', her: null, notice: WORDS.agentLeft, blocked: false, voice: null });
expect(mics[0].stopped).toBeGreaterThan(0);
expect(room.disconnects).toBe(1);
expect(deletes(r.calls)).toEqual(['DELETE /api/voice/session/s1']);
+201
View File
@@ -0,0 +1,201 @@
// HEL-900: custom voice wiring against docs/voice-custom-contract-HEL899.md §2, fully offline (fake fetch, fake decoder,
// fake microphone and recorder). Nothing here reaches a real ASR/TTS or a real microphone.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { applyVoice, encodeWav, listVoices, previewVoice, Recorder, registerVoice, SAY, toReference, VoiceError, wavInfo, type Decoded, type MediaParts, type RecView } from '../src/voice/custom';
const tone = (seconds: number, rate: number, channels = 1, amp = 0.3): Decoded => {
const length = Math.round(seconds * rate), data = Array.from({ length: channels }, () => Float32Array.from({ length }, (_, i) => amp * Math.sin(i / 7)));
return { sampleRate: rate, length, numberOfChannels: channels, getChannelData: c => data[c] };
};
const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
const wavBytes = (seconds: number, rate = 24000) => encodeWav(new Float32Array(Math.round(seconds * rate)).fill(0.1), rate);
const never = new AbortController().signal;
afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers(); });
describe('reference audio becomes the WAV the server accepts', () => {
it('writes PCM 16-bit mono with a header the server can parse', () => {
const info = wavInfo(encodeWav(new Float32Array(16000 * 3), 16000));
expect(info).toEqual({ rate: 16000, channels: 1, bits: 16, seconds: 3 });
});
it('does not take a WebM (or anything else) for a WAV', () => {
const webm = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3, ...new Array(60).fill(0)]).buffer;
expect(wavInfo(webm)).toBeNull();
expect(wavInfo(new ArrayBuffer(10))).toBeNull();
});
it('decodes, mixes down and resamples a 48 kHz stereo take to 16 kHz mono', async () => {
const ref = await toReference(new Blob([new Uint8Array(8)]), async () => tone(3, 48000, 2));
expect(ref.wav.type).toBe('audio/wav');
const info = wavInfo(await ref.wav.arrayBuffer())!;
expect(info.rate).toBe(16000); expect(info.channels).toBe(1); expect(info.bits).toBe(16);
expect(info.seconds).toBeCloseTo(3, 1); expect(ref.seconds).toBeCloseTo(3, 1);
});
it('refuses what the server would refuse, before sending anything', async () => {
await expect(toReference(new Blob([new Uint8Array(8)]), async () => tone(1.5, 16000))).rejects.toThrow(SAY.tooShort);
await expect(toReference(new Blob([new Uint8Array(8)]), async () => tone(4, 16000, 1, 0))).rejects.toThrow(SAY.silent);
await expect(toReference(new Blob([new Uint8Array(8)]), async () => { throw new Error('EncodingError'); })).rejects.toThrow(SAY.undecodable);
await expect(toReference({ size: 60 * 1024 * 1024 } as Blob, async () => tone(4, 16000))).rejects.toThrow(SAY.fileTooBig);
// 15 MB of 16 kHz WAV is about 491 s.
await expect(toReference(new Blob([new Uint8Array(8)]), async () => tone(500, 16000))).rejects.toThrow(SAY.tooBig);
});
});
describe('API contract §2', () => {
it('lists voices, honest about an unconfigured or older server', async () => {
const ok = vi.fn(async () => json(200, { configured: true, voices: [{ id: 'rain', label: 'rain', source: 'builtin', selected: false, previewed: false, registered_at: null }, { id: 'cg-9f2c1a77', label: '她的声音', source: 'custom', selected: true, previewed: true, registered_at: 1 }, { id: '<bad>', label: 'x', source: 'custom' }], selection: { voice: 'cg-9f2c1a77', label: '她的声音', updated_at: 2 } }));
const list = await listVoices(never, ok as typeof fetch);
expect(ok).toHaveBeenCalledWith('/api/voice/voices', expect.anything());
expect(list.configured && list.voices.map(v => v.id)).toEqual(['rain', 'cg-9f2c1a77']);
expect(list.configured && list.selection).toEqual({ voice: 'cg-9f2c1a77', label: '她的声音', updated_at: 2 });
expect(await listVoices(never, (async () => json(200, { configured: false, voices: [], selection: null })) as typeof fetch)).toEqual({ configured: false });
await expect(listVoices(never, (async () => json(404, { detail: 'Not Found' })) as typeof fetch)).rejects.toMatchObject({ status: 404, message: SAY.noRoute });
await expect(listVoices(never, (async () => json(503, { detail: '音色登记服务这会儿不在,稍后再试试。' })) as typeof fetch)).rejects.toMatchObject({ status: 503, message: '音色登记服务这会儿不在,稍后再试试。' });
});
it('registers the raw WAV bytes with its parameters in the query string', async () => {
const wav = new Blob([wavBytes(3, 16000)], { type: 'audio/wav' });
const f = vi.fn(async (_url: string, _init?: RequestInit) => json(201, { id: 'cg-1234abcd', label: '轻一点', prompt_text: '你好呀', prompt_text_source: 'user', prompt_wav: '/x', registered_at: 1 }));
const done = await registerVoice(wav, { label: ' 轻一点 ', text: '你好呀', allowAsr: true }, never, f as unknown as typeof fetch);
const [url, init] = f.mock.calls[0];
const q = new URL(url, 'http://x').searchParams;
expect(url.startsWith('/api/voice/voices?')).toBe(true);
expect([q.get('label'), q.get('prompt_text'), q.get('allow_asr')]).toEqual(['轻一点', '你好呀', 'false']);
expect(init!.method).toBe('POST'); expect(init!.body).toBe(wav); expect((init!.headers as Record<string, string>)['Content-Type']).toBe('audio/wav');
expect(done).toEqual({ id: 'cg-1234abcd', label: '轻一点', prompt_text: '你好呀', prompt_text_source: 'user' });
});
it('asks for transcription only when you left the text empty and allowed it', async () => {
const f = vi.fn(async (_url: string) => json(201, { id: 'cg-1', label: 'cg-1', prompt_text: '转写的', prompt_text_source: 'asr' }));
await registerVoice(new Blob([wavBytes(3)]), { label: '', text: ' ', allowAsr: true }, never, f as unknown as typeof fetch);
await registerVoice(new Blob([wavBytes(3)]), { label: '', text: '', allowAsr: false }, never, f as unknown as typeof fetch);
expect(f.mock.calls.map(([u]) => new URL(u, 'http://x').searchParams.get('allow_asr'))).toEqual(['true', 'false']);
});
it('passes the server refusal through and keeps nothing half-done', async () => {
const detail = '请填写这段音频的参考文字,或明确选择让系统转写(会用到语音识别)。';
await expect(registerVoice(new Blob([wavBytes(3)]), { label: '', text: '', allowAsr: false }, never, (async () => json(422, { detail })) as typeof fetch)).rejects.toMatchObject({ status: 422, message: detail });
await expect(registerVoice(new Blob([wavBytes(3)]), { label: '', text: 'x', allowAsr: false }, never, (async () => { throw new TypeError('Failed to fetch'); }) as typeof fetch)).rejects.toThrow(SAY.unreachable);
});
it('sends nothing while the browser is offline', async () => {
vi.stubGlobal('navigator', { onLine: false });
const f = vi.fn();
await expect(registerVoice(new Blob([wavBytes(3)]), { label: '', text: 'x', allowAsr: false }, never, f as unknown as typeof fetch)).rejects.toThrow(SAY.offline);
await expect(previewVoice('rain', '', never, f as unknown as typeof fetch)).rejects.toThrow(SAY.offline);
expect(f).not.toHaveBeenCalled();
});
it('plays only a real WAV from a fresh preview, and sends the default text only when you gave none', async () => {
const f = vi.fn(async (_u: string, _i?: RequestInit) => new Response(wavBytes(1.2), { status: 200, headers: { 'Content-Type': 'audio/wav' } }));
const blob = await previewVoice('cg-1', '', never, f as unknown as typeof fetch);
expect(wavInfo(await blob.arrayBuffer())?.rate).toBe(24000);
expect(JSON.parse(String(f.mock.calls[0][1]!.body))).toEqual({ voice: 'cg-1' });
await previewVoice('cg-1', ' 晚安 ', never, f as unknown as typeof fetch);
expect(JSON.parse(String(f.mock.calls[1][1]!.body))).toEqual({ voice: 'cg-1', text: '晚安' });
await expect(previewVoice('cg-1', '', never, (async () => new Response('<html>oops</html>', { status: 200 })) as typeof fetch)).rejects.toThrow(SAY.badPreview);
await expect(previewVoice('ghost', '', never, (async () => json(404, { detail: '这个声音现在不在列表里。' })) as typeof fetch)).rejects.toMatchObject({ status: 404 });
await expect(previewVoice('cg-1', '', never, (async () => json(503, { detail: '她的声音服务这会儿不在,稍后再试试。' })) as typeof fetch)).rejects.toThrow('她的声音服务这会儿不在');
});
it('a late preview answer for a cancelled request is not returned as a result', async () => {
const c = new AbortController();
const f = (async (_u: string, init?: RequestInit) => new Promise<Response>((_, reject) => init!.signal!.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))))) as typeof fetch;
const p = previewVoice('cg-1', '', c.signal, f);
c.abort();
await expect(p).rejects.toMatchObject({ name: 'AbortError' });
});
it('applies, and says plainly when a custom voice was not previewed', async () => {
const f = vi.fn(async () => json(200, { voice: 'cg-1', label: '轻一点', updated_at: 5 }));
expect(await applyVoice('cg-1', never, f as unknown as typeof fetch)).toEqual({ voice: 'cg-1', label: '轻一点', updated_at: 5 });
await expect(applyVoice('cg-2', never, (async () => json(409, { detail: '这个声音还没有试听过,先试听再给她用。' })) as typeof fetch)).rejects.toMatchObject({ status: 409, message: '这个声音还没有试听过,先试听再给她用。' });
await expect(applyVoice('cg-2', never, (async () => json(503, { detail: '她的音色状态这会儿写不进,这次没有换成功。' })) as typeof fetch)).rejects.toMatchObject({ status: 503 });
// An answer for some other voice is not taken as "applied".
await expect(applyVoice('cg-2', never, f as unknown as typeof fetch)).rejects.toBeInstanceOf(VoiceError);
});
});
// ── Recorder with a fake microphone and MediaRecorder ──
class FakeTrack extends EventTarget { stopped = false; stop() { this.stopped = true; } }
function fakeMedia(opts: { deny?: string; secure?: boolean } = {}) {
const tracks: FakeTrack[] = [];
let release: (s: MediaStream) => void = () => {};
const recorders: FakeRecorder[] = [];
class FakeRecorder {
state: 'inactive' | 'recording' = 'inactive'; mimeType = 'audio/webm;codecs=opus';
ondataavailable: ((e: { data: Blob }) => void) | null = null; onstop: (() => void) | null = null; onerror: (() => void) | null = null;
start() { this.state = 'recording'; }
stop() { if (this.state === 'inactive') return; this.state = 'inactive'; this.ondataavailable?.({ data: new Blob([new Uint8Array(100)], { type: this.mimeType }) }); queueMicrotask(() => this.onstop?.()); }
}
let asked = 0;
const media: MediaParts = {
secure: () => opts.secure !== false,
getUserMedia: c => {
asked++;
expect(c.video).toBe(false);
if (opts.deny) return Promise.reject(Object.assign(new Error('no'), { name: opts.deny }));
return new Promise(resolve => { release = resolve; });
},
recorder: () => { const r = new FakeRecorder(); recorders.push(r); return r as unknown as MediaRecorder; },
};
const grant = () => { const t = new FakeTrack(); tracks.push(t); release({ getTracks: () => tracks, getAudioTracks: () => tracks } as unknown as MediaStream); };
return { media, grant, tracks, recorders, asked: () => asked };
}
function harness(m: ReturnType<typeof fakeMedia>, max = 20) {
let t = 0;
const views: RecView[] = [], done: Blob[] = [], fails: string[] = [];
const r = new Recorder({ view: v => views.push(v), done: b => done.push(b), fail: s => fails.push(s) }, m.media, max, () => t);
return { r, views, done, fails, advance: (ms: number) => { t += ms; vi.advanceTimersByTime(ms); } };
}
const tick = () => new Promise(r => setTimeout(r, 0));
describe('recording is only on your tap and always lets the microphone go', () => {
it('opens nothing until started; stop keeps the take and closes the microphone', async () => {
const m = fakeMedia(), h = harness(m);
expect(m.asked()).toBe(0);
const started = h.r.start(); m.grant(); await started;
expect(h.r.view.phase).toBe('recording');
h.r.stop();
expect(m.tracks.every(t => t.stopped)).toBe(true);
await tick();
expect(h.done).toHaveLength(1); expect(h.done[0].type).toContain('webm');
expect(h.r.view.phase).toBe('converting');
h.r.settled(); expect(h.r.view.phase).toBe('idle');
});
it('cancel throws the take away and frees the microphone; nothing is handed over later', async () => {
const m = fakeMedia(), h = harness(m);
const started = h.r.start(); m.grant(); await started;
h.r.cancel(); await tick();
expect(m.tracks.every(t => t.stopped)).toBe(true);
expect(h.done).toHaveLength(0); expect(h.r.view.phase).toBe('idle');
});
it('a microphone granted after you cancelled (or left) is closed at once, never used', async () => {
const m = fakeMedia(), h = harness(m);
const started = h.r.start(); h.r.cancel(); m.grant(); await started;
expect(m.tracks[0].stopped).toBe(true);
expect(m.recorders).toHaveLength(0); expect(h.r.view.phase).toBe('idle');
});
it('stops by itself at the length limit', async () => {
vi.useFakeTimers();
const m = fakeMedia(), h = harness(m, 20);
const started = h.r.start(); m.grant(); await started;
h.advance(20200);
expect(m.tracks.every(t => t.stopped)).toBe(true);
await vi.runAllTimersAsync();
expect(h.done).toHaveLength(1);
});
it('a microphone that goes away mid-take ends it honestly', async () => {
const m = fakeMedia(), h = harness(m);
const started = h.r.start(); m.grant(); await started;
m.tracks[0].dispatchEvent(new Event('ended'));
expect(h.fails).toEqual([SAY.micGone]); expect(h.r.view.phase).toBe('idle'); expect(m.recorders[0].state).toBe('inactive');
});
it('a refused permission or an insecure page says why and opens nothing', async () => {
const denied = fakeMedia({ deny: 'NotAllowedError' }), h1 = harness(denied);
await h1.r.start();
expect(h1.fails).toEqual([SAY.denied]); expect(h1.r.view.phase).toBe('idle');
const busy = fakeMedia({ deny: 'NotReadableError' }), h2 = harness(busy);
await h2.r.start(); expect(h2.fails).toEqual([SAY.micBusy]);
const insecure = fakeMedia({ secure: false }), h3 = harness(insecure);
await h3.r.start();
expect(h3.fails).toEqual([SAY.insecure]); expect(insecure.asked()).toBe(0);
});
it('a second tap while recording does not open a second microphone', async () => {
const m = fakeMedia(), h = harness(m);
const started = h.r.start(); await h.r.start(); m.grant(); await started;
expect(m.asked()).toBe(1);
h.r.cancel();
});
});
+120
View File
@@ -0,0 +1,120 @@
// HEL-900 isolated contract mock for her own voice (docs/voice-custom-contract-HEL899.md §2). Serves web/dist plus the
// minimum real-app API (companion, messages, health) and the five custom-voice endpoints, answering exactly as the
// HEL-899 router does. Synthetic data only: no voice panel, no ASR, no TTS, no network beyond 127.0.0.1. The preview is
// a generated tone wrapped as 24 kHz mono WAV, not a voice. Usage: node tests/hel900_mock.mjs <port>.
// Control GETs: /__mode?list=ok|off|none|down, /__fail?register=&preview=&apply= (status code, `net`, `bad`, `slow`),
// /__reset, /__log (drains), /__uploads, /__pid.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const dist = fileURLToPath(new URL('../dist/', import.meta.url));
const port = Number(process.argv[2] || 8900);
const identity = { id: '1', name: '栖', birth: '2000-03-14', hour: '子', relation: '朋友', nickname: '', boundary: '', color: { pillars: { year: '庚辰', month: '己卯', day: '丙午', hour: '戊子' }, traits: '合成测试档案' } };
const BUILTIN = ['bailu', 'bajie', 'default', 'female_soft', 'haimianbaobao', 'jiaojiao', 'male_warm', 'nezha', 'rain', 'sunwukong', 'xiaoyun'];
const log = [], uploads = [], gaveUp = [];
let mode = 'ok', fail = {}, custom = new Map(), previewed = new Set(), selection = { voice: null, label: '', updated_at: null }, made = 0;
const reset = () => { mode = 'ok'; fail = {}; custom = new Map(); previewed = new Set(); selection = { voice: null, label: '', updated_at: null }; made = 0; log.splice(0); uploads.splice(0); };
const send = (res, status, body, type = 'application/json; charset=utf-8') => { res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)); };
const sleep = ms => new Promise(r => setTimeout(r, ms));
const body = async req => { const parts = []; for await (const c of req) parts.push(c); return Buffer.concat(parts); };
function wavInfo(b) {
if (b.length < 44 || b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WAVE') return null;
let at = 12, fmt = null;
while (at + 8 <= b.length) {
const id = b.toString('ascii', at, at + 4), size = b.readUInt32LE(at + 4);
if (id === 'fmt ') fmt = { channels: b.readUInt16LE(at + 10), rate: b.readUInt32LE(at + 12), bits: b.readUInt16LE(at + 22) };
if (id === 'data') return fmt && { ...fmt, seconds: Math.min(size, b.length - at - 8) / (fmt.rate * fmt.channels * fmt.bits / 8) };
at += 8 + size + (size & 1);
}
return null;
}
function tone(seconds = 1.6, rate = 24000) {
const n = Math.round(seconds * rate), b = Buffer.alloc(44 + n * 2);
b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 9) * 6000 * Math.min(1, i / 2000, (n - i) / 2000)), 44 + i * 2);
return b;
}
const known = id => BUILTIN.includes(id) || custom.has(id);
const item = id => { const r = custom.get(id); return { id, label: r ? r.label : id, source: id.startsWith('cg-') ? 'custom' : 'builtin', selected: selection.voice === id, previewed: previewed.has(id), registered_at: r ? r.registered_at : null, prompt_text: r ? r.prompt_text : null, prompt_wav: r ? `/opt/voice-agent/cosyvoice/voice_samples/${id}.wav` : null }; };
// A failure set with /__fail applies once, then the endpoint behaves again (a retry can succeed).
const take = kind => { const f = fail[kind]; delete fail[kind]; return f; };
const failed = async (res, f, detail) => {
if (f === 'net') { res.destroy(); return true; }
if (/^\d+$/.test(String(f))) { send(res, Number(f), { detail }); return true; }
return false;
};
// A request the page aborts (leaving, switching voice) must not take the mock down with it.
const handle = async (req, res) => {
const url = new URL(req.url, 'http://x'), p = url.pathname;
if (p.startsWith('/api/')) log.push(`${req.method} ${p}${url.search}`);
if (p === '/__pid') return send(res, 200, { pid: process.pid, mock: 'hel900' });
if (p === '/__log') return send(res, 200, log.splice(0));
if (p === '/__uploads') return send(res, 200, uploads);
if (p === '/__gaveup') return send(res, 200, gaveUp);
if (p === '/__reset') { reset(); return send(res, 200, { ok: true }); }
if (p === '/__mode') { mode = url.searchParams.get('list') || 'ok'; return send(res, 200, { mode }); }
if (p === '/__fail') { fail = Object.fromEntries(url.searchParams); return send(res, 200, fail); }
if (p === '/__state') return send(res, 200, { selection, previewed: [...previewed], custom: [...custom.keys()] });
if (p === '/api/health') return send(res, 200, { ok: true, epoch: 3, llm: { ok: true, model: 'mock' }, backend: { life: true, persona: true, receipt: true, web_connected: true }, life_runtime: { state: 'running', timezone: 'Asia/Shanghai', budget_warning: false, budget_stopped: false, last: null }, capabilities: { text: true, proactive: true, voice: true, memory: true } });
if (p === '/api/companion') return send(res, 200, identity);
if (p === '/api/messages') return send(res, 200, { messages: [], next_before: null, epoch: 3 });
if (p === '/api/persona') return send(res, 200, { state: 'ready', interpretation: null, seeds: [], growth: [] });
if (p === '/api/life' || p === '/api/memories') return send(res, 200, { events: [], memories: [], next_before: null, epoch: 3 });
if (p === '/api/voice/status') return send(res, 200, { configured: true, tts_ok: true, agent_name: 'cybergril-voice-agent', voice: true });
if (p === '/api/voice/voices' && req.method === 'GET') {
if (mode === 'none') return send(res, 404, { detail: 'Not Found' });
if (mode === 'off') return send(res, 200, { configured: false, voices: [], selection: null });
if (mode === 'down') return send(res, 503, { detail: '音色登记服务这会儿不在,稍后再试试。' });
return send(res, 200, { configured: true, voices: [...BUILTIN, ...custom.keys()].map(item), selection: { ...selection } });
}
if (p === '/api/voice/voices' && req.method === 'POST') {
const data = await body(req), q = url.searchParams, info = wavInfo(data);
uploads.push({ type: req.headers['content-type'], bytes: data.length, magic: data.toString('ascii', 0, 4), wave: data.toString('ascii', 8, 12), info, label: q.get('label'), prompt_text: q.get('prompt_text'), allow_asr: q.get('allow_asr') });
const f = take('register');
if (f === 'slow') await sleep(2500);
else if (await failed(res, f, f === '503' || f === 503 ? '音色登记服务这会儿不在,稍后再试。' : '登记没有成功。')) return;
if (!info) return send(res, 422, { detail: '只支持 WAV 格式的参考音频。' });
if (data.length > 15 * 1024 * 1024) return send(res, 422, { detail: '文件过大(>15MB),请录 5–20 秒的一段。' });
if (info.seconds < 2) return send(res, 422, { detail: '参考音频太短(至少 2 秒),请录 5–20 秒。' });
const text = (q.get('prompt_text') || '').trim(), asr = q.get('allow_asr') === 'true';
if (!text && !asr) return send(res, 422, { detail: '请填写这段音频的参考文字,或明确选择让系统转写(会用到语音识别)。' });
const id = `cg-${(0x9f2c1a70 + ++made).toString(16)}`, label = (q.get('label') || '').trim() || id;
const record = { id, label, prompt_text: text || '(模拟转写)今天天气很好,我们出去走走吧。', prompt_text_source: text ? 'user' : 'asr', prompt_wav: `/opt/voice-agent/cosyvoice/voice_samples/${id}.wav`, registered_at: Date.now() };
custom.set(id, record);
return send(res, 201, record);
}
if (p === '/api/voice/preview' && req.method === 'POST') {
const { voice, text } = JSON.parse((await body(req)).toString() || '{}');
log.push(` preview ${voice} ${text ? `"${text}"` : '(default)'}`);
const f = take('preview');
if (!voice) return send(res, 422, { detail: '请指定要试听的声音。' });
if (!known(voice)) return send(res, 404, { detail: '这个声音现在不在列表里。' });
if (f === 'slow') await sleep(2500);
else if (f === 'bad') return send(res, 200, '<html>not audio</html>', 'text/html');
else if (await failed(res, f, '她的声音服务这会儿不在,稍后再试试。')) return;
previewed.add(voice);
return send(res, 200, tone(), 'audio/wav');
}
if (p === '/api/voice/selection' && req.method === 'GET') return send(res, 200, { ...selection });
if (p === '/api/voice/selection' && req.method === 'POST') {
const { voice } = JSON.parse((await body(req)).toString() || '{}');
const f = take('apply');
if (await failed(res, f, f === '409' ? '这个声音还没有试听过,先试听再给她用。' : '她的音色状态这会儿写不进,这次没有换成功。')) return;
if (!known(voice)) return send(res, 404, { detail: '这个声音现在不在列表里。' });
if (voice.startsWith('cg-') && !previewed.has(voice)) return send(res, 409, { detail: '这个声音还没有试听过,先试听再给她用。' });
selection = { voice, label: custom.get(voice)?.label || voice, updated_at: Date.now() };
return send(res, 200, { ...selection });
}
if (p.startsWith('/api/')) return send(res, 404, { detail: 'Not Found' });
const file = normalize(join(dist, p === '/' ? 'index.html' : p));
if (!file.startsWith(normalize(dist))) return send(res, 403, 'no', 'text/plain');
try { send(res, 200, await readFile(file), { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }[extname(file)] || 'application/octet-stream'); }
catch { send(res, 200, await readFile(join(dist, 'index.html')), 'text/html; charset=utf-8'); }
};
createServer((req, res) => { handle(req, res).catch(e => { gaveUp.push(`${req.method} ${req.url}`); console.log(`client gave up: ${req.method} ${req.url} (${e.code || e.message})`); try { res.destroy(); } catch { /* gone */ } }); }).on('error', e => { console.error(`hel900 mock could not listen on ${port}: ${e.code || e.message}`); process.exit(2); })
.listen(port, '127.0.0.1', () => console.log(`hel900 mock on ${port} pid ${process.pid}`));
+208
View File
@@ -0,0 +1,208 @@
// HEL-900 browser check: 她的声音 in settings — reference (file or recording) → fresh preview → apply. Drives headless
// Edge over CDP against tests/hel900_mock.mjs; Chromium's fake audio device stands in for a microphone (a beep, not a
// person), the preview is a generated tone. No voice panel, ASR, TTS or real clone is involved.
// Usage: node tests/hel900_shots.mjs <edge.exe> <outDir> [port].
import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const [edge, out, portArg] = process.argv.slice(2);
const port = Number(portArg || 8900), base = `http://127.0.0.1:${port}`, cdpPort = 9500;
mkdirSync(out, { recursive: true });
const profile = join(tmpdir(), `hel900-edge-${Date.now()}`);
const browser = spawn(edge, ['--headless=new', `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, '--no-first-run', '--hide-scrollbars', '--force-color-profile=srgb', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', 'about:blank'], { stdio: 'ignore' });
const sleep = ms => new Promise(r => setTimeout(r, ms));
let targets;
for (let i = 0; i < 60 && !targets; i++) { try { targets = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json(); } catch { await sleep(250); } }
const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
await new Promise(r => ws.addEventListener('open', r, { once: true }));
let id = 0; const waiting = new Map();
ws.addEventListener('message', ({ data }) => { const m = JSON.parse(data); if (m.id && waiting.has(m.id)) { const { ok, ko } = waiting.get(m.id); waiting.delete(m.id); m.error ? ko(new Error(m.error.message)) : ok(m.result); } });
const cdp = (method, params = {}) => new Promise((ok, ko) => { const n = ++id; waiting.set(n, { ok, ko }); ws.send(JSON.stringify({ id: n, method, params })); });
const js = async expr => { const r = await cdp('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'js error'); return r.result.value; };
const results = [];
const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail && !ok ? ` — ${String(detail).slice(0, 300)}` : ''}`); };
const ctl = async path => (await fetch(`${base}${path}`)).json();
const shot = async name => { const { data } = await cdp('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }); writeFileSync(join(out, `${name}.png`), Buffer.from(data, 'base64')); };
const click = async (selector, text = '') => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.offsetParent !== null && e.textContent.includes(${JSON.stringify(text)})); if (!el) return false; el.scrollIntoView({ block: 'center' }); el.click(); return true; })()`);
const exists = sel => js(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); return !!e && e.offsetParent !== null; })()`);
const text = sel => js(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.offsetParent !== null).map(e => e.innerText).join('\\n')`);
const disabled = (sel, label) => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(sel)})].find(e => e.offsetParent !== null && e.textContent.includes(${JSON.stringify(label)})); return el ? el.disabled : null; })()`);
const until = async (fn, ms = 6000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; };
const fill = (sel, value) => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(sel)})].find(e => e.offsetParent !== null); if (!el) return false; const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(value)}); el.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`);
const live = () => js('window.__live()');
const status = () => text('.voice-sec .status');
const views = { desktop: { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, phone: { width: 390, height: 844, deviceScaleFactor: 2, mobile: true } };
async function view(v) { await cdp('Emulation.setDeviceMetricsOverride', views[v]); await cdp('Emulation.setTouchEmulationEnabled', { enabled: views[v].mobile, maxTouchPoints: 5 }); }
// Counts every microphone track the page ever got, so "released" means readyState 'ended' on all of them.
const PROBE = `(() => { const md = navigator.mediaDevices; if (!md) return; const orig = md.getUserMedia.bind(md); window.__tracks = []; window.__gum = []; window.__deny = '';
md.getUserMedia = async c => { window.__gum.push(JSON.stringify(c)); if (window.__deny) throw new DOMException('denied', window.__deny); const s = await orig(c); s.getTracks().forEach(t => window.__tracks.push(t)); return s; };
window.__live = () => window.__tracks.filter(t => t.readyState === 'live').length; })();`;
async function boot(theme) {
await ctl('/__reset');
await cdp('Page.navigate', { url: `${base}/` }); await sleep(500);
await js(`localStorage.setItem('cg.theme', ${JSON.stringify(theme)}); sessionStorage.clear(); true`);
await cdp('Page.navigate', { url: `${base}/` }); await sleep(1500);
}
async function openVoice() {
await click('.nav-item', '设置'); await sleep(700);
await click('.snav button', '她的声音');
return until(() => exists('.voice-sec') || exists('.pane-manage .off-card'), 6000);
}
async function record(seconds = 3.5) {
await click('.voice-sec .seg button', '现在录一段');
await click('.voice-sec .rec button', '开始录音');
await until(async () => (await text('.voice-sec .rec')).includes('正在录音'));
await sleep(seconds * 1000);
}
async function stopRecording() { await click('.voice-sec .rec button', '停止'); return until(() => exists('.voice-sec .take .pill'), 8000); }
const wav = (seconds, rate = 22050, channels = 2) => {
const n = Math.round(seconds * rate), b = Buffer.alloc(44 + n * 2 * channels);
b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2 * channels, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(channels, 22);
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2 * channels, 28); b.writeUInt16LE(2 * channels, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2 * channels, 40);
for (let i = 0; i < n * channels; i++) b.writeInt16LE(Math.round(Math.sin(i / 11) * 5000), 44 + i * 2);
return b;
};
const files = join(tmpdir(), `hel900-files-${Date.now()}`); mkdirSync(files, { recursive: true });
writeFileSync(join(files, 'reference-22k-stereo.wav'), wav(6));
writeFileSync(join(files, 'renamed-webm.wav'), Buffer.concat([Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), Buffer.alloc(4000, 7)]));
async function upload(name) {
const { result } = await cdp('Runtime.evaluate', { expression: `[...document.querySelectorAll('.voice-sec input[type=file]')].find(e => e.offsetParent !== null)` });
await cdp('DOM.setFileInputFiles', { files: [join(files, name)], objectId: result.objectId });
}
await cdp('Page.enable'); await cdp('Runtime.enable'); await cdp('Network.enable'); await cdp('DOM.enable');
await cdp('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
check('mock is this hel900 mock', (await ctl('/__pid')).mock === 'hel900');
// ── Main path, both themes × desktop/phone ──
for (const [theme, v] of [['moon', 'desktop'], ['fog', 'desktop'], ['moon', 'phone'], ['fog', 'phone']]) {
const tag = `${theme}-${v}`;
await view(v); await boot(theme);
check(`${tag}: no microphone before anything is tapped`, (await js('window.__gum.length')) === 0);
await openVoice(); await until(() => exists('.voice-sec .voice-list'));
const first = await ctl('/__log');
check(`${tag}: opening the section only reads the list`, first.every(l => !l.includes('POST')) && first.some(l => l.includes('GET /api/voice/voices')), first.join(' | '));
check(`${tag}: says when a change takes effect`, (await text('.voice-sec .kv')).includes('下一次通话'));
await shot(`${tag}-01-section`);
await record(3.2);
check(`${tag}: recording state is explicit and the microphone is live`, (await live()) === 1 && (await text('.voice-sec .rec')).includes('麦克风开着'));
check(`${tag}: audio only, never the camera`, (await js('window.__gum.at(-1)')).includes('"video":false'));
await shot(`${tag}-02-recording`);
await sleep(800);
check(`${tag}: stopping keeps the take and releases the microphone`, await stopRecording() && (await live()) === 0);
check(`${tag}: the take is labelled as your reference, not her voice`, (await text('.voice-sec .take h4')).includes('不是栖的声音'));
check(`${tag}: register needs reference text or an explicit transcription opt-in`, (await disabled('.voice-sec .take button', '登记这段声音')) === true);
await fill('.voice-sec .take input.input', '轻一点的她'); await fill('.voice-sec .take textarea', '今天天气很好,我们出去走走吧。');
await shot(`${tag}-03-take`);
await click('.voice-sec .take button', '登记这段声音');
await until(async () => (await text('.voice-sec .voice-list')).includes('轻一点的她'));
const up = (await ctl('/__uploads')).at(-1);
check(`${tag}: the upload is real PCM WAV (16 kHz mono 16-bit), raw body + query`, up && up.type === 'audio/wav' && up.magic === 'RIFF' && up.wave === 'WAVE' && up.info?.rate === 16000 && up.info?.channels === 1 && up.info?.bits === 16 && up.info.seconds >= 3 && up.allow_asr === 'false' && up.prompt_text === '今天天气很好,我们出去走走吧。', JSON.stringify(up));
check(`${tag}: the new voice is chosen for a preview; apply waits for it`, (await text('.voice-sec .audition h4')).includes('轻一点的她') && (await disabled('.voice-sec .audition button', '给栖用这个声音')) === true);
await shot(`${tag}-04-registered`);
await click('.voice-sec .audition button', '生成试听');
check(`${tag}: a fresh preview plays and only then can be applied`, await until(() => exists('.voice-sec .sample.ready')) && (await disabled('.voice-sec .audition button', '给栖用这个声音')) === false);
check(`${tag}: the preview is labelled as her synthesized voice, not your reference`, (await text('.voice-sec .sample figcaption')).includes('不是参考原声'));
check(`${tag}: the preview actually plays in the page`, await until(() => js(`(() => { const a = document.querySelector('.voice-sec .sample audio'); return !!a && (a.currentTime > 0 || !a.paused); })()`)));
await sleep(600);
await shot(`${tag}-05-preview`);
await click('.voice-sec .audition button', '给栖用这个声音');
await until(async () => (await text('.voice-sec .kv')).includes('轻一点的她'));
const st = await ctl('/__state');
check(`${tag}: applied — current voice shown, from the next call`, st.selection.voice?.startsWith('cg-') && (await text('.voice-sec .audition')).includes('她现在就用这个') && (await status()).includes('已经接通的通话不会中途换声'), await status());
await shot(`${tag}-06-applied`);
check(`${tag}: no object URL or microphone left after closing settings`, await (async () => { await click('.pane-manage button[aria-label="关闭设置"]'); await sleep(400); return (await live()) === 0 && !(await exists('.voice-sec')); })());
}
// ── Edge cases (desktop, moon; the phone fog run repeats the recording ones) ──
await view('desktop'); await boot('moon'); await openVoice(); await until(() => exists('.voice-sec .voice-list'));
// File path: a real WAV from disk (22 kHz stereo) is re-encoded; a WebM renamed .wav is refused before upload.
await upload('renamed-webm.wav');
check('file: a renamed non-WAV is refused in the browser, nothing uploaded', await until(async () => (await status()).includes('解不开')) && (await ctl('/__uploads')).length === 0, await status());
await upload('reference-22k-stereo.wav');
check('file: a 22 kHz stereo WAV becomes 16 kHz mono', await until(() => exists('.voice-sec .take .pill')) && (await text('.voice-sec .take')).includes('16kHz 单声道'));
await shot('edge-01-file-take');
// Explicit transcription opt-in.
await click('.voice-sec .take .switch-row input');
check('asr: opting in enables register with empty text', (await disabled('.voice-sec .take button', '登记这段声音')) === false);
// Offline: nothing is sent; the take and the form stay.
await cdp('Network.emulateNetworkConditions', { offline: true, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
await click('.voice-sec .take button', '登记这段声音');
check('offline: says so, keeps the take, sends nothing', await until(async () => (await status()).includes('网络断开')) && (await exists('.voice-sec .take .pill')) && (await ctl('/__uploads')).length === 0, await status());
await shot('edge-02-offline');
await cdp('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 });
// Server down once (503): message, take kept; the retry succeeds and carries allow_asr=true.
await ctl('/__fail?register=503');
await click('.voice-sec .take button', '登记这段声音');
check('503: honest message, take still there for a retry', await until(async () => (await status()).includes('音色登记服务')) && await exists('.voice-sec .take .pill'), await status());
await shot('edge-03-register-503');
await js(`(() => { const b = [...document.querySelectorAll('.voice-sec .take button')].find(e => e.textContent.includes('登记这段声音')); b.click(); b.click(); return true; })()`);
await until(async () => (await text('.voice-sec .voice-list')).includes('cg-'));
const ups = await ctl('/__uploads');
check('double tap registers once; transcription only on explicit opt-in', ups.length === 2 && ups.at(-1).allow_asr === 'true' && ups.at(-1).prompt_text === '', JSON.stringify(ups.map(u => u.allow_asr)));
check('asr: the transcribed text is shown back', (await status()).includes('服务器转写'), await status());
// Late preview: A is slow, you switch to B; A's sample never shows up as B's (nor at all).
await ctl('/__fail?preview=slow');
await click('.voice-sec .audition button', '生成试听');
await sleep(300);
await click('.voice-sec details.voice-builtin summary'); await sleep(200);
await click('.voice-sec .voice-builtin button', 'rain');
await sleep(3000);
check('late: an older preview arriving after switching is dropped', (await text('.voice-sec .audition h4')).includes('rain') && !(await exists('.voice-sec .sample')) && (await disabled('.voice-sec .audition button', '给栖用这个声音')) === true);
await shot('edge-04-late-dropped');
// A preview that is not audio is refused, never played.
await ctl('/__fail?preview=bad');
await click('.voice-sec .audition button', '生成试听');
check('bad preview: refused, nothing playable shown', await until(async () => (await status()).includes('不是可播放')) && !(await exists('.voice-sec .sample .pill')), await status());
// TTS down.
await ctl('/__fail?preview=503');
await click('.voice-sec .audition button', '生成试听');
check('preview 503: honest message', await until(async () => (await status()).includes('声音服务这会儿不在')), await status());
// 409 from the server even after a preview here (another device, a restart): said plainly, nothing changes.
await click('.voice-sec .audition button', '生成试听'); await until(() => exists('.voice-sec .sample.ready'));
await ctl('/__fail?apply=409');
await click('.voice-sec .audition button', '给栖用这个声音');
check('apply 409: message, selection unchanged', await until(async () => (await status()).includes('还没有试听过')) && (await ctl('/__state')).selection.voice === null, await status());
await shot('edge-05-apply-409');
// Recording: cancel, leave mid-take, denied permission.
await record(1.2);
await click('.voice-sec .rec button', '取消');
check('cancel: microphone released, take discarded', (await live()) === 0 && (await status()).includes('已取消'));
await record(1.2);
await click('.snav button', '城市'); await sleep(300);
check('leaving the section mid-take releases the microphone', (await live()) === 0);
await click('.snav button', '她的声音'); await sleep(300);
check('coming back says the take was dropped', (await status()).includes('离开了这里'), await status());
await js(`window.__deny = 'NotAllowedError'; true`);
await click('.voice-sec .rec button', '开始录音');
check('denied: says why, nothing recording', await until(async () => (await status()).includes('没有拿到麦克风权限')) && (await live()) === 0, await status());
await js(`window.__deny = ''; true`);
await shot('edge-06-denied');
// Unsaved take: closing settings asks first.
await record(2.6); await stopRecording();
await click('.pane-manage button[aria-label="关闭设置"]');
check('an unregistered take guards leaving', await until(() => exists('.modal.open')) && (await text('.modal.open')).includes('未保存'));
await shot('edge-07-leave-guard');
await click('.modal.open button', '放弃修改并离开'); await sleep(400);
check('after leaving, nothing of the section remains', (await live()) === 0 && !(await exists('.voice-sec')));
// Server states for the list.
for (const [mode, words, name] of [['off', '还没有在这台服务上配置', 'unconfigured'], ['none', '还没有自定义声音的通路', 'no-route'], ['down', '一时没读出来', 'panel-down']]) {
await ctl(`/__mode?list=${mode}`);
await cdp('Page.navigate', { url: `${base}/` }); await sleep(1300); await openVoice();
check(`list ${name}: honest card, no microphone, no 'adjustable' tag`, (await text('.pane-manage .sec-body')).includes(words) && (await js('window.__gum.length')) === 0 && !(await text('.pane-manage .sec-h')).includes('可以调整'));
await shot(`edge-08-${name}`);
}
await ctl('/__mode?list=ok');
await click('.pane-manage .off-card button', '再试一次');
check('panel back: retry shows the list', await until(() => exists('.voice-sec .voice-list')));
for (const theme of ['moon', 'fog']) { await view('phone'); await boot(theme); await openVoice(); await until(() => exists('.voice-sec .voice-list')); await ctl('/__mode?list=down'); await cdp('Page.navigate', { url: `${base}/` }); await sleep(1300); await openVoice(); await shot(`phone-${theme}-panel-down`); await ctl('/__mode?list=ok'); }
const gave = await ctl('/__gaveup');
check('no registration or apply was ever cut off by the page itself', !gave.some(l => l.startsWith('POST /api/voice/voices') || l.startsWith('POST /api/voice/selection')), gave.join(' | '));
const passed = results.filter(Boolean).length;
console.log(`\n${passed}/${results.length} passed`);
browser.kill();
process.exit(passed === results.length ? 0 : 1);
+130
View File
@@ -0,0 +1,130 @@
// HEL-903 isolated contract mock: the HEL-878 voice session (fake LiveKit via tests/fake-livekit.js) and the HEL-900
// custom-voice endpoints in one process, answering as src/cybergril/voice/router.py does at b09a907 — the session
// answer carries `voice` (selection if still listed, else "default"), /api/voice/status carries configured/tts_ok.
// Adds the failures this card handles: a registration the panel filed while the answer was lost (500 / dropped).
// Synthetic data only: no voice panel, ASR, TTS, LiveKit or network beyond 127.0.0.1.
// Usage: node tests/hel903_mock.mjs <port>. Control GETs: /__status?mode=ok|down|off|none|500|net,
// /__session-voice?v=<id>|omit|(empty: as the server), /__fail?register=503|500|net|commit500|commitnet,
// /__select?voice=<id> (another device applies a voice), /__reset, /__log (drains), /__uploads, /__state, /__pid.
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { fileURLToPath } from 'node:url';
const dist = fileURLToPath(new URL('../dist/', import.meta.url)), fake = fileURLToPath(new URL('./fake-livekit.js', import.meta.url));
const port = Number(process.argv[2] || 8903);
const T0 = Date.UTC(2026, 9, 27, 12, 0);
const identity = { id: '1', name: '栖', birth: '2000-03-14', hour: '子', relation: '朋友', nickname: '', boundary: '', color: { pillars: { year: '庚辰', month: '己卯', day: '丙午', hour: '戊子' }, traits: '合成测试档案' } };
const BUILTIN = ['bailu', 'default', 'female_soft', 'male_warm', 'rain', 'xiaoyun'];
const log = [], uploads = [], sessions = new Map();
let statusMode = 'ok', sessionVoice = '', fail = {}, custom = new Map(), orphans = new Set(), previewed = new Set(), selection = { voice: null, label: '', updated_at: null }, made = 0, rooms = 0;
const reset = () => { statusMode = 'ok'; sessionVoice = ''; fail = {}; custom = new Map(); orphans = new Set(); previewed = new Set(); selection = { voice: null, label: '', updated_at: null }; made = 0; log.splice(0); uploads.splice(0); sessions.clear(); };
const send = (res, status, body, type = 'application/json; charset=utf-8') => { res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body)); };
const sleep = ms => new Promise(r => setTimeout(r, ms));
const body = async req => { const parts = []; for await (const c of req) parts.push(c); return Buffer.concat(parts); };
function wavInfo(b) {
if (b.length < 44 || b.toString('ascii', 0, 4) !== 'RIFF' || b.toString('ascii', 8, 12) !== 'WAVE') return null;
let at = 12, fmt = null;
while (at + 8 <= b.length) {
const id = b.toString('ascii', at, at + 4), size = b.readUInt32LE(at + 4);
if (id === 'fmt ') fmt = { channels: b.readUInt16LE(at + 10), rate: b.readUInt32LE(at + 12), bits: b.readUInt16LE(at + 22) };
if (id === 'data') return fmt && { ...fmt, seconds: Math.min(size, b.length - at - 8) / (fmt.rate * fmt.channels * fmt.bits / 8) };
at += 8 + size + (size & 1);
}
return null;
}
function tone(seconds = 1.6, rate = 24000) {
const n = Math.round(seconds * rate), b = Buffer.alloc(44 + n * 2);
b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 9) * 6000 * Math.min(1, i / 2000, (n - i) / 2000)), 44 + i * 2);
return b;
}
const listed = () => [...BUILTIN, ...custom.keys(), ...orphans];
const known = id => listed().includes(id);
// An orphan is in the shared panel only: no CyberGril metadata, so the router lists it with label = id.
const item = id => { const r = custom.get(id); return { id, label: r ? r.label : id, source: id.startsWith('cg-') ? 'custom' : 'builtin', selected: selection.voice === id, previewed: previewed.has(id), registered_at: r ? r.registered_at : null, prompt_text: r ? r.prompt_text : null, prompt_wav: r ? r.prompt_wav : null }; };
const take = kind => { const f = fail[kind]; delete fail[kind]; return f; };
const newId = () => `cg-${(0x5e7a0000 + ++made).toString(16)}`;
const handle = async (req, res) => {
const url = new URL(req.url, 'http://x'), p = url.pathname;
if (p.startsWith('/api/')) log.push(`${req.method} ${p}${url.search}`);
if (p === '/__pid') return send(res, 200, { pid: process.pid, mock: 'hel903' });
if (p === '/__log') return send(res, 200, log.splice(0));
if (p === '/__uploads') return send(res, 200, uploads);
if (p === '/__reset') { reset(); return send(res, 200, { ok: true }); }
if (p === '/__status') { statusMode = url.searchParams.get('mode') || 'ok'; return send(res, 200, { statusMode }); }
if (p === '/__session-voice') { sessionVoice = url.searchParams.get('v') || ''; return send(res, 200, { sessionVoice }); }
if (p === '/__fail') { fail = Object.fromEntries(url.searchParams); return send(res, 200, fail); }
if (p === '/__select') { const v = url.searchParams.get('voice'); selection = { voice: v, label: custom.get(v)?.label || v, updated_at: Date.now() }; return send(res, 200, selection); }
if (p === '/__state') return send(res, 200, { selection, custom: [...custom.keys()], orphans: [...orphans], open: [...sessions.keys()], rooms });
if (p.startsWith('/vendor/livekit-client')) return send(res, 200, await readFile(fake), 'text/javascript');
if (p === '/api/health') return send(res, 200, { ok: true, epoch: 3, llm: { ok: true, model: 'mock' }, backend: { life: true, persona: true, receipt: true, web_connected: true }, life_runtime: { state: 'running', timezone: 'Asia/Shanghai', budget_warning: false, budget_stopped: false, last: null }, capabilities: { text: true, proactive: true, voice: false, memory: true } });
if (p === '/api/companion') return send(res, 200, identity);
if (p === '/api/messages') return send(res, 200, { messages: [{ id: 1, ts: T0, role: 'user', text: '今天下雨了吗?', turn_id: 't1', status: 'sent', lifeIds: [] }, { id: 2, ts: T0 + 6e4, role: 'companion', text: '下了一点,窗台上还湿着。', turn_id: 't1', status: 'sent', lifeIds: [] }], next_before: null, epoch: 3 });
if (p === '/api/persona') return send(res, 200, { state: 'ready', interpretation: null, seeds: [], growth: [] });
if (p === '/api/life' || p === '/api/memories') return send(res, 200, { events: [], memories: [], next_before: null, epoch: 3 });
if (p === '/api/voice/status') {
if (statusMode === 'none') return send(res, 404, { detail: 'Not Found' });
if (statusMode === '500') return send(res, 500, 'Internal Server Error', 'text/plain');
if (statusMode === 'net') { res.destroy(); return; }
if (statusMode === 'off') return send(res, 200, { configured: false, tts_ok: false, agent_name: null, voice: false });
return send(res, 200, { configured: true, tts_ok: statusMode === 'ok', agent_name: 'cybergril-voice-agent', voice: false, livekit_url: 'wss://lk.invalid' });
}
if (p === '/api/voice/session' && req.method === 'POST') {
if (statusMode !== 'ok') return send(res, 503, { detail: '她的声音服务这会儿不在,稍后再试试。' });
await sleep(120);
// As _capture_voice: a still-listed selection, else "default"; captured once, at opening.
const captured = selection.voice && known(selection.voice) ? selection.voice : 'default';
const id = `sess-${++rooms}`; sessions.set(id, captured);
const grant = { url: 'wss://lk.invalid', token: `token-${id}`, room: `cg-voice-${rooms}`, session_id: id, agent_name: 'cybergril-voice-agent', epoch: 3, expires_at: Date.now() + 2 * 3600e3, voice: sessionVoice && sessionVoice !== 'omit' ? sessionVoice : captured };
if (sessionVoice === 'omit') delete grant.voice;
return send(res, 201, grant);
}
if (p.startsWith('/api/voice/session/') && req.method === 'DELETE') { const id = decodeURIComponent(p.slice(19)); return sessions.delete(id) ? send(res, 200, { ended: true }) : send(res, 404, { detail: '这次通话已经不在了。' }); }
if (p === '/api/voice/voices' && req.method === 'GET') return send(res, 200, { configured: true, voices: listed().map(item), selection: { ...selection } });
if (p === '/api/voice/voices' && req.method === 'POST') {
const data = await body(req), q = url.searchParams, info = wavInfo(data), f = take('register');
uploads.push({ type: req.headers['content-type'], bytes: data.length, info, label: q.get('label'), prompt_text: q.get('prompt_text'), allow_asr: q.get('allow_asr'), fail: f || '' });
// The panel took it, then the local metadata write failed (HEL-902 §4: OSError → 500) or the answer was lost.
// `commitnet` cuts the answer mid-body (a reset before any byte would let Chromium silently resend the POST on a
// reused socket, which is not the case this card covers).
if (f === 'commit500' || f === 'commitnet') { const lost = newId(); orphans.add(lost); if (f === 'commitnet') { res.writeHead(201, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': '400' }); res.write(`{"id":"${lost}","la`); setTimeout(() => res.destroy(), 50); return; } return send(res, 500, 'Internal Server Error', 'text/plain'); }
if (f === 'net') { res.destroy(); return; }
if (f === '503') return send(res, 503, { detail: '音色登记服务这会儿不在,稍后再试试。' });
if (f === '500') return send(res, 500, 'Internal Server Error', 'text/plain');
if (!info) return send(res, 422, { detail: '只支持 WAV 格式的参考音频。' });
const text = (q.get('prompt_text') || '').trim(), asr = q.get('allow_asr') === 'true';
if (!text && !asr) return send(res, 422, { detail: '请填写这段音频的参考文字,或明确选择让系统转写(会用到语音识别)。' });
const id = newId(), label = (q.get('label') || '').trim() || id;
const record = { id, label, prompt_text: text || '(模拟转写)今天天气很好。', prompt_text_source: text ? 'user' : 'asr', prompt_wav: `/opt/voice-agent/cosyvoice/voice_samples/${id}.wav`, registered_at: Date.now() };
custom.set(id, record);
return send(res, 201, record);
}
if (p === '/api/voice/preview' && req.method === 'POST') {
const { voice } = JSON.parse((await body(req)).toString() || '{}');
if (!known(voice)) return send(res, 404, { detail: '这个声音现在不在列表里。' });
previewed.add(voice);
return send(res, 200, tone(), 'audio/wav');
}
if (p === '/api/voice/selection' && req.method === 'GET') return send(res, 200, { ...selection });
if (p === '/api/voice/selection' && req.method === 'POST') {
const { voice } = JSON.parse((await body(req)).toString() || '{}');
if (!known(voice)) return send(res, 404, { detail: '这个声音现在不在列表里。' });
if (voice.startsWith('cg-') && !previewed.has(voice)) return send(res, 409, { detail: '这个声音还没有试听过,先试听再给她用。' });
selection = { voice, label: custom.get(voice)?.label || voice, updated_at: Date.now() };
return send(res, 200, { ...selection });
}
if (p.startsWith('/api/')) return send(res, 404, { detail: 'Not Found' });
const file = normalize(join(dist, p === '/' ? 'index.html' : p));
if (!file.startsWith(normalize(dist))) return send(res, 403, 'no', 'text/plain');
try { send(res, 200, await readFile(file), { '.js': 'text/javascript', '.css': 'text/css', '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp' }[extname(file)] || 'application/octet-stream'); }
catch { send(res, 200, await readFile(join(dist, 'index.html')), 'text/html; charset=utf-8'); }
};
createServer((req, res) => { handle(req, res).catch(e => { console.log(`client gave up: ${req.method} ${req.url} (${e.code || e.message})`); try { res.destroy(); } catch { /* gone */ } }); })
.on('error', e => { console.error(`hel903 mock could not listen on ${port}: ${e.code || e.message}`); process.exit(2); })
.listen(port, '127.0.0.1', () => console.log(`hel903 mock on ${port} pid ${process.pid}`));
+226
View File
@@ -0,0 +1,226 @@
// HEL-903 browser check: 外接服务 voice rows from /api/voice/status, 本次通话声音 on the call line, and recovery after a
// registration with no clear answer. Drives headless Edge over CDP against tests/hel903_mock.mjs, with
// tests/fake-livekit.js in place of the LiveKit client and Chromium's fake audio device in place of a microphone (a beep,
// not a person). No voice panel, ASR, TTS, LiveKit or real clone is involved.
// Usage: node tests/hel903_shots.mjs <edge.exe> <outDir> [port] [before]. `before` only takes the comparison shots
// (run it against a build of the code before this card; it asserts nothing about the new behaviour).
import { spawn } from 'node:child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
const [edge, out, portArg, mode] = process.argv.slice(2);
const port = Number(portArg || 8903), base = `http://127.0.0.1:${port}`, cdpPort = 9503;
mkdirSync(out, { recursive: true });
const profile = join(tmpdir(), `hel903-edge-${Date.now()}`);
const browser = spawn(edge, ['--headless=new', `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, '--no-first-run', '--hide-scrollbars', '--force-color-profile=srgb', '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required', 'about:blank'], { stdio: 'ignore' });
const sleep = ms => new Promise(r => setTimeout(r, ms));
let targets;
for (let i = 0; i < 60 && !targets; i++) { try { targets = await (await fetch(`http://127.0.0.1:${cdpPort}/json`)).json(); } catch { await sleep(250); } }
const ws = new WebSocket(targets.find(t => t.type === 'page').webSocketDebuggerUrl);
await new Promise(r => ws.addEventListener('open', r, { once: true }));
let id = 0; const waiting = new Map();
ws.addEventListener('message', ({ data }) => { const m = JSON.parse(data); if (m.id && waiting.has(m.id)) { const { ok, ko } = waiting.get(m.id); waiting.delete(m.id); m.error ? ko(new Error(m.error.message)) : ok(m.result); } });
const cdp = (method, params = {}) => new Promise((ok, ko) => { const n = ++id; waiting.set(n, { ok, ko }); ws.send(JSON.stringify({ id: n, method, params })); });
const js = async expr => { const r = await cdp('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'js error'); return r.result.value; };
const results = [];
const check = (name, ok, detail = '') => { results.push(ok); console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail && !ok ? ` — ${String(detail).slice(0, 300)}` : ''}`); };
const ctl = async path => (await fetch(`${base}${path}`)).json();
const shot = async name => { const { data } = await cdp('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }); writeFileSync(join(out, `${name}.png`), Buffer.from(data, 'base64')); };
const click = async (selector, label = '') => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.offsetParent !== null && e.textContent.includes(${JSON.stringify(label)})); if (!el) return false; el.scrollIntoView({ block: 'center' }); el.click(); return true; })()`);
const exists = sel => js(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); return !!e && e.offsetParent !== null; })()`);
const text = sel => js(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.offsetParent !== null).map(e => e.innerText).join('\\n')`);
const scrollTo = sel => js(`(() => { const e = document.querySelector(${JSON.stringify(sel)}); if (e) e.scrollIntoView({ block: 'center' }); return !!e; })()`);
const until = async (fn, ms = 6000) => { const end = Date.now() + ms; while (Date.now() < end) { if (await fn()) return true; await sleep(100); } return false; };
const fill = (sel, value) => js(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(sel)})].find(e => e.offsetParent !== null); if (!el) return false; const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(value)}); el.dispatchEvent(new Event('input', { bubbles: true })); return true; })()`);
const views = { desktop: { width: 1440, height: 900, deviceScaleFactor: 1, mobile: false }, phone: { width: 390, height: 844, deviceScaleFactor: 2, mobile: true } };
async function view(v) { await cdp('Emulation.setDeviceMetricsOverride', views[v]); await cdp('Emulation.setTouchEmulationEnabled', { enabled: views[v].mobile, maxTouchPoints: 5 }); }
const PROBE = `(() => { const md = navigator.mediaDevices; if (!md) return; const orig = md.getUserMedia.bind(md); window.__tracks = [];
md.getUserMedia = async c => { const s = await orig(c); s.getTracks().forEach(t => window.__tracks.push(t)); return s; };
window.__live = () => window.__tracks.filter(t => t.readyState === 'live').length; })();`;
const live = () => js('window.__live ? window.__live() : 0');
async function boot(theme, path = '/') {
await ctl('/__reset');
await cdp('Page.navigate', { url: `${base}${path}` }); await sleep(500);
await js(`localStorage.setItem('cg.theme', ${JSON.stringify(theme)}); localStorage.removeItem('cg.voice.owed'); sessionStorage.clear(); true`);
await cdp('Page.navigate', { url: `${base}${path}` }); await sleep(1500);
}
async function settings(section) { if (!(await exists('.pane-manage.open'))) { await click('.nav-item', '设置'); await sleep(700); } await click('.snav button', section); await sleep(400); }
async function closeSettings() { await click('.pane-manage button[aria-label="关闭设置"]'); await sleep(500); if (await exists('.modal.open')) { await click('.modal.open button', '放弃修改并离开'); await sleep(400); } }
const svc = label => js(`(() => { const r = [...document.querySelectorAll('.svc')].find(e => e.offsetParent !== null && e.querySelector('.nm')?.firstChild?.textContent === ${JSON.stringify(label)}); return r ? r.querySelector('.nm small').innerText : null; })()`);
const wav = (seconds, rate = 22050) => {
const n = Math.round(seconds * rate), b = Buffer.alloc(44 + n * 2);
b.write('RIFF', 0); b.writeUInt32LE(36 + n * 2, 4); b.write('WAVE', 8); b.write('fmt ', 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22);
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); b.write('data', 36); b.writeUInt32LE(n * 2, 40);
for (let i = 0; i < n; i++) b.writeInt16LE(Math.round(Math.sin(i / 11) * 5000), 44 + i * 2);
return b;
};
const files = join(tmpdir(), `hel903-files-${Date.now()}`); mkdirSync(files, { recursive: true });
writeFileSync(join(files, 'reference.wav'), wav(6));
async function upload() {
const { result } = await cdp('Runtime.evaluate', { expression: `[...document.querySelectorAll('.voice-sec input[type=file]')].find(e => e.offsetParent !== null)` });
await cdp('DOM.setFileInputFiles', { files: [join(files, 'reference.wav')], objectId: result.objectId });
return until(() => exists('.voice-sec .take .pill'));
}
async function takeReady(label) { await upload(); await fill('.voice-sec .take input.input', label); await fill('.voice-sec .take textarea', '今天天气很好,我们出去走走吧。'); }
async function applyVoice(label) {
await takeReady(label);
await click('.voice-sec .take button', '登记这段声音');
await until(async () => (await text('.voice-sec .voice-list')).includes(label));
await click('.voice-sec .audition button', '生成试听'); await until(() => exists('.voice-sec .sample.ready'));
await click('.voice-sec .audition button', '给栖用这个声音');
return until(async () => (await text('.voice-sec .kv')).includes(label));
}
const phase = () => js(`document.querySelector('.voice-row.real')?.dataset.phase || 'off'`);
const pin = () => js(`document.querySelector('.voice-row.real .voice-pin')?.innerText || ''`);
async function startCall() {
await click('.voice-button'); await until(() => exists('.modal.open'));
await click('.modal.open .btn-primary', '开始语音');
await until(async () => (await phase()) === 'joining');
await until(() => js(`window.__lk?.rooms.at(-1)?.state === 'connected'`));
await js('window.__lk.join()'); await sleep(300);
}
async function endCall() { await click('.voice-row.real .voice-end', '结束语音'); await until(async () => (await phase()) === 'off'); await sleep(300); }
async function talk() { await click('.nav-item', '交谈'); await sleep(1400); }
await cdp('Page.enable'); await cdp('Runtime.enable'); await cdp('Network.enable'); await cdp('DOM.enable');
await cdp('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
check('mock is this hel903 mock', (await ctl('/__pid')).mock === 'hel903');
if (mode === 'before') {
// The same three places before this card: services rows, the call line, a registration that failed after filing.
await view('desktop'); await boot('moon');
await settings('外接服务'); await sleep(600); await shot('before-01-services');
await settings('她的声音'); await until(() => exists('.voice-sec .voice-list')); await applyVoice('轻一点的她');
await closeSettings(); await talk(); await startCall(); await shot('before-02-call'); await endCall();
await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
await takeReady('第二个'); await ctl('/__fail?register=commit500'); await click('.voice-sec .take button', '登记这段声音'); await sleep(1200); await scrollTo('.voice-sec .take'); await shot('before-03-register-500');
browser.kill(); process.exit(0);
}
// ── Main path, both themes × desktop/phone ──
for (const [theme, v] of [['moon', 'desktop'], ['fog', 'desktop'], ['moon', 'phone'], ['fog', 'phone']]) {
const tag = `${theme}-${v}`;
await view(v); await boot(theme);
// 1. 外接服务: read from /api/voice/status, never "尚未接通" and never "已接通" for voice.
await settings('外接服务');
await until(async () => (await svc('语音合成')) && !(await svc('语音合成')).includes('正在读取'));
const tts = await svc('语音合成'), asr = await svc('语音识别');
check(`${tag}: services — TTS row says configured + health probe reachable`, tts === '已配置 · 合成服务健康检查可连接', tts);
check(`${tag}: services — ASR row claims no probe it does not have`, asr.includes('没有单独的健康检查'), asr);
check(`${tag}: services — no voice row says "尚未接通" or "已接通"`, !/尚未接通|已接通/u.test(`${tts}${asr}`));
check(`${tag}: services — reading status opens no microphone`, (await live()) === 0 && (await js('window.__tracks.length')) === 0);
await scrollTo('.svc'); await shot(`${tag}-01-services`);
// 2. Apply a voice, then call: the call line shows the voice the session answer pinned.
await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
check(`${tag}: a voice is applied through the existing flow`, await applyVoice('轻一点的她'));
check(`${tag}: settings still says a change takes effect at the next call`, (await text('.voice-sec .kv')).includes('下一次通话'));
await closeSettings(); await talk();
await startCall();
check(`${tag}: call line shows 本次通话 voice from the session answer`, (await pin()).includes('轻一点的她'), await pin());
// 3. Another device applies a different voice mid-call: this call keeps its own.
await ctl('/__select?voice=rain');
await js(`window.__lk.say({ kind: 'interim', role: 'user', text: '你听得到吗' })`); await sleep(300);
check(`${tag}: subtitles still show beside it`, (await text('.voice-row.real')).includes('你听得到吗'));
check(`${tag}: a choice made during the call does not change this call`, (await pin()).includes('轻一点的她'), await pin());
await shot(`${tag}-02-call-pinned`);
await endCall();
check(`${tag}: ending the call releases the microphone and drops the pin`, (await live()) === 0 && !(await exists('.voice-row.real .voice-pin')));
// 4. The next call takes the new choice.
await startCall();
check(`${tag}: the next call shows the new voice`, (await pin()).includes('rain'), await pin());
await shot(`${tag}-03-next-call`);
await endCall();
// 5. A registration filed by the panel whose answer was lost: say so, offer the list, find the numbered entry.
await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
await takeReady('第二个');
await ctl('/__fail?register=commit500');
await click('.voice-sec .take button', '登记这段声音');
check(`${tag}: 500 after filing — says it may be registered and to refresh first`, await until(async () => (await text('.voice-sec .status')).includes('可能已经登记上了')) && (await text('.voice-sec .status')).includes('刷新列表'), await text('.voice-sec .status'));
check(`${tag}: offers 刷新列表 and words a second registration as a checked choice`, (await exists('.voice-sec .take')) && (await text('.voice-sec .take .take-acts')).includes('刷新列表') && (await text('.voice-sec .take .take-acts')).includes('确认没有,再登记'));
check(`${tag}: the list is not re-read or re-registered behind your back`, (await ctl('/__uploads')).length === 2);
await scrollTo('.voice-sec .take .take-acts'); await shot(`${tag}-04-register-unsure`);
await click('.voice-sec .take button', '刷新列表');
check(`${tag}: refreshing names the new numbered entry`, await until(async () => /列表里多了「cg-/u.test(await text('.voice-sec .status'))), await text('.voice-sec .status'));
check(`${tag}: the numbered entry is marked as missing local metadata`, (await text('.voice-sec .voice-list')).includes('只有编号,缺本地资料') && (await exists('.voice-sec .voice-idonly')));
check(`${tag}: after it is found the cautious button goes away`, !(await text('.voice-sec .take .take-acts')).includes('刷新列表'));
await scrollTo('.voice-sec .voice-idonly'); await shot(`${tag}-05-found-numbered`);
await closeSettings();
}
// ── Branches, desktop moon unless noted ──
await view('desktop'); await boot('moon');
for (const [m, tts, asr, name] of [
['down', '已配置 · 合成服务这会儿暂不可用', '没有单独的健康检查', 'down'],
['off', '未配置 · 这台服务没有语音配置', '未配置', 'off'],
['none', '这台服务没有语音状态接口', '这台服务没有语音状态接口', 'no-route'],
['500', '状态未知 · 这次没读到语音状态', '状态未知', 'unknown-500'],
['net', '状态未知 · 这次没读到语音状态', '状态未知', 'unknown-net'],
]) {
await ctl(`/__status?mode=${m}`);
await settings('外接服务'); await click('.svc button', '重新读取状态');
await until(async () => !((await svc('语音合成')) || '').includes('正在读取'));
const t = await svc('语音合成'), a = await svc('语音识别');
check(`status ${name}: TTS "${tts}"`, t === tts, t);
check(`status ${name}: ASR row honest`, a.includes(asr), a);
if (m === 'down' || m === 'off') { await scrollTo('.svc'); await shot(`branch-services-${name}`); }
}
await ctl('/__status?mode=ok');
await view('phone'); await boot('fog'); await ctl('/__status?mode=down'); await settings('外接服务'); await until(async () => (await svc('语音合成')) === '已配置 · 合成服务这会儿暂不可用'); await scrollTo('.svc'); await shot('branch-services-down-fog-phone'); await ctl('/__status?mode=ok');
await view('desktop'); await boot('moon');
// The session answer without a voice field (an older server): shown as not said, never guessed from settings.
await settings('她的声音'); await until(() => exists('.voice-sec .voice-list')); await applyVoice('轻一点的她');
await ctl('/__session-voice?v=omit'); await closeSettings(); await talk(); await startCall();
check('session without voice: "声音未说明", not the settings choice', (await pin()).includes('声音未说明') && !(await pin()).includes('轻一点的她'), await pin());
await shot('branch-call-voice-unknown');
await endCall();
await ctl('/__session-voice?v=default'); await startCall();
check('session voice "default": shown as 默认声音', (await pin()).includes('默认声音'), await pin());
await endCall(); await ctl('/__session-voice');
// A dropped answer after filing; a 503 before filing (refresh finds nothing; a checked retry then succeeds).
await settings('她的声音'); await until(() => exists('.voice-sec .voice-list'));
await takeReady('断线那次'); await ctl('/__fail?register=commitnet');
await click('.voice-sec .take button', '登记这段声音');
check('dropped answer: may be registered, refresh first', await until(async () => (await text('.voice-sec .status')).includes('可能已经登记上了')), await text('.voice-sec .status'));
await click('.voice-sec .take button', '刷新列表');
check('dropped answer: refresh finds the numbered entry', await until(async () => /列表里多了「cg-/u.test(await text('.voice-sec .status'))));
await click('.voice-sec .take button', '换一段');
await takeReady('面板不在'); await ctl('/__fail?register=503');
await click('.voice-sec .take button', '登记这段声音');
check('503: keeps the server words and adds the refresh advice', await until(async () => (await text('.voice-sec .status')).includes('音色登记服务')) && (await text('.voice-sec .status')).includes('刷新列表'), await text('.voice-sec .status'));
await click('.voice-sec .take button', '刷新列表');
check('503: refresh finds nothing new and says to wait before registering again', await until(async () => (await text('.voice-sec .status')).includes('暂时没有新出现的条目')), await text('.voice-sec .status'));
await shot('branch-register-503-none');
const before = (await ctl('/__uploads')).length;
await click('.voice-sec .take button', '确认没有,再登记');
check('503: the checked retry registers once and the take is gone', await until(async () => (await text('.voice-sec .voice-list')).includes('面板不在')) && (await ctl('/__uploads')).length === before + 1 && !(await exists('.voice-sec .take')));
// Recording regression: the microphone opens on a tap and closes on stop / cancel.
await click('.voice-sec .seg button', '现在录一段');
await click('.voice-sec .rec button', '开始录音');
await until(async () => (await text('.voice-sec .rec')).includes('正在录音'));
await sleep(2600);
check('recording: microphone live while recording', (await live()) === 1);
await click('.voice-sec .rec button', '停止');
check('recording: stop keeps the take and releases the microphone', await until(() => exists('.voice-sec .take .pill'), 8000) && (await live()) === 0);
await click('.voice-sec .take button', '换一段');
await click('.voice-sec .rec button', '开始录音'); await until(async () => (await text('.voice-sec .rec')).includes('正在录音'));
await click('.voice-sec .rec button', '取消');
check('recording: cancel releases the microphone', (await live()) === 0);
await closeSettings();
// Demo entry: nothing goes to any server from settings (services, her voice) or the conversation.
await boot('moon', '/?demo=1'); await ctl('/__log');
await settings('外接服务'); await sleep(500); await settings('她的声音'); await sleep(500); await closeSettings();
await click('.nav-item', '交谈'); await sleep(1200);
const demoLog = await ctl('/__log');
check('demo: zero /api requests from settings and the conversation', demoLog.length === 0, demoLog.join(' | '));
await settings('外接服务'); await shot('demo-services');
const passed = results.filter(Boolean).length;
console.log(`\n${passed}/${results.length} passed`);
browser.kill();
process.exit(passed === results.length ? 0 : 1);
+122
View File
@@ -0,0 +1,122 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { callVoiceOf, readVoiceStatus, VoiceController, type MicTrack, type RoomEvents, type VoiceRoom, type VoiceView } from '../src/voice/session';
import { callVoiceName, idOnly, registerVoice, SAY, VoiceError, type CustomVoice } from '../src/voice/custom';
// HEL-903 limited rework, offline only: fake fetch, fake room and microphone. No LiveKit, voice panel, ASR or TTS.
const gate = () => { let open!: () => void; const promise = new Promise<void>(r => { open = r; }); return { promise, open }; };
const flush = async (n = 8) => { for (let i = 0; i < n; i++) await Promise.resolve(); await new Promise(r => setTimeout(r, 0)); };
const signal = () => new AbortController().signal;
const reply = (body: unknown, status = 200) => new Response(typeof body === 'string' ? body : JSON.stringify(body), { status });
afterEach(() => { vi.unstubAllGlobals(); });
describe('外接服务 reads only what /api/voice/status can tell', () => {
const kind = (answer: () => Response | Promise<Response>) => readVoiceStatus(signal(), (async () => answer()) as unknown as typeof fetch);
it('tells not configured, reachable, down, no route and unreadable apart', async () => {
expect(await kind(() => reply({ configured: false, tts_ok: false, agent_name: null, voice: false }))).toBe('off');
expect(await kind(() => reply({ configured: true, tts_ok: true, voice: false }))).toBe('ok');
expect(await kind(() => reply({ configured: true, tts_ok: false, voice: false }))).toBe('down');
expect(await kind(() => reply({ detail: 'Not Found' }, 404))).toBe('none');
expect(await kind(() => reply('oops', 500))).toBe('unknown');
expect(await kind(() => reply('not json'))).toBe('unknown');
expect(await kind(() => { throw new TypeError('Failed to fetch'); })).toBe('unknown');
});
it('a configured flag alone is never "reachable"', async () => {
expect(await kind(() => reply({ configured: true }))).toBe('down');
});
});
// ── The call's own voice ──
class Mic implements MicTrack { stopped = 0; stop() { this.stopped++; } onEnded() {} }
class Room implements VoiceRoom { closed = 0; on!: RoomEvents; async publish() {} hush() {} async resumeAudio() {} async close() { this.closed++; } }
function rig() {
const views: VoiceView[] = [], rooms: Room[] = [], voices: unknown[] = [], gates: (ReturnType<typeof gate> | undefined)[] = [];
let made = 0;
const fetch = vi.fn(async (url: string, init: RequestInit = {}) => {
if (url === '/api/voice/status') return reply({ configured: true, tts_ok: true, voice: false });
if (url === '/api/voice/session' && init.method === 'POST') {
const n = made++, g = gates[n]; if (g) await g.promise;
const grant: Record<string, unknown> = { url: 'wss://lk.test', token: `t${n}`, room: `r${n}`, session_id: `s${n}`, epoch: 3, expires_at: 0 };
if (voices[n] !== undefined) grant.voice = voices[n];
return reply(grant, 201);
}
return reply({ ended: true });
});
const transport = { capture: async () => new Mic(), join: async (_u: string, _t: string, on: RoomEvents) => { const r = new Room(); r.on = on; rooms.push(r); return r; } };
const c = new VoiceController({ transport: async () => transport, onView: v => views.push(v), onRecord: () => {}, onSpoke: () => {}, fetch: fetch as unknown as typeof fetch, secure: () => true, agentWaitMs: 1000 });
return { c, views, rooms, voices, gates, view: () => views.at(-1)! };
}
describe('本次通话声音 comes from the session answer and stays for the call', () => {
it('shown from the granted room, unchanged through the call, gone when it ends', async () => {
const r = rig(); r.voices[0] = 'cg-1a2b3c4d';
await r.c.start();
expect(r.views.find(v => v.phase === 'opening')?.voice).toBeNull();
expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-1a2b3c4d' } });
r.rooms[0].on.agentJoined(); r.rooms[0].on.agentState('speaking'); r.rooms[0].on.agentState('listening');
expect(r.views.filter(v => v.phase !== 'opening').every(v => v.voice?.id === 'cg-1a2b3c4d')).toBe(true);
await r.c.end();
expect(r.view()).toMatchObject({ phase: 'off', voice: null });
});
it('a missing or odd field is "not said", never a guess', () => {
expect(callVoiceOf({})).toEqual({ id: null });
expect(callVoiceOf({ voice: '' })).toEqual({ id: null });
expect(callVoiceOf({ voice: 42 })).toEqual({ id: null });
expect(callVoiceOf({ voice: '<b>x</b>' })).toEqual({ id: null });
expect(callVoiceOf({ voice: 'default' })).toEqual({ id: 'default' });
});
it('an older call answering late never overwrites the newer call', async () => {
const r = rig(); r.voices[0] = 'old-voice'; r.voices[1] = 'cg-00000002'; r.gates[0] = gate();
const first = r.c.start(); await flush();
await r.c.start();
expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-00000002' } });
r.gates[0]!.open(); await first; await flush();
expect(r.view()).toMatchObject({ phase: 'joining', voice: { id: 'cg-00000002' } });
expect(r.views.some(v => v.voice?.id === 'old-voice')).toBe(false);
});
it('a failure after the room clears the voice with the call', async () => {
const r = rig(); r.voices[0] = 'rain';
await r.c.start(); r.rooms[0].on.agentJoined();
r.rooms[0].on.dropped(); await flush();
expect(r.view()).toMatchObject({ phase: 'off', voice: null });
});
it('names: its own label for that id only, the id otherwise, default and unknown in words', () => {
expect(callVoiceName('cg-1', { id: 'cg-1', label: '轻一点的她' })).toBe('轻一点的她');
expect(callVoiceName('cg-2', { id: 'cg-1', label: '轻一点的她' })).toBe('cg-2');
expect(callVoiceName('default', null)).toBe('默认声音');
expect(callVoiceName(null, null)).toBe('声音未说明');
});
});
describe('a registration with no clear answer says to look before registering again', () => {
const wav = new Blob([new Uint8Array(64)], { type: 'audio/wav' }), form = { label: 'x', text: '你好', allowAsr: false };
const attempt = async (answer: () => Response | Promise<Response>) => {
try { await registerVoice(wav, form, signal(), (async () => answer()) as unknown as typeof fetch); } catch (e) { return e as VoiceError; }
throw new Error('expected a failure');
};
it('500 / 503 / a dropped answer / an unreadable success may have been filed', async () => {
for (const e of [await attempt(() => reply({ detail: 'Internal Server Error' }, 500)), await attempt(() => reply({ detail: '音色登记服务这会儿不在,稍后再试试。' }, 503)), await attempt(() => { throw new TypeError('network'); }), await attempt(() => reply('', 201))]) {
expect(e).toBeInstanceOf(VoiceError);
expect(e.uncertain).toBe(true);
}
expect((await attempt(() => reply({ detail: '音色登记服务这会儿不在,稍后再试试。' }, 503))).message).toContain('音色登记服务');
});
it('a clear refusal (4xx) or a request never sent is not "maybe filed"', async () => {
expect((await attempt(() => reply({ detail: '参考音频太短(至少 2 秒),请录 5–20 秒。' }, 422))).uncertain).toBe(false);
expect((await attempt(() => reply({ detail: '还没有创建真实伴侣,语音也不会开始。' }, 409))).uncertain).toBe(false);
vi.stubGlobal('navigator', { onLine: false });
const offline = await attempt(() => reply({}, 201));
expect(offline.message).toBe(SAY.offline);
expect(offline.uncertain).toBe(false);
});
it('the advice points at refreshing the list and the numbered entries', () => {
expect(SAY.maybeRegistered).toContain('刷新列表');
expect(SAY.maybeRegistered).toContain('cg-');
expect(SAY.maybeRegistered).toContain('不用再登记');
});
it('an entry without local metadata is recognised as id-only', () => {
const v = (x: Partial<CustomVoice>): CustomVoice => ({ id: 'cg-9', label: 'cg-9', source: 'custom', selected: false, previewed: false, registered_at: null, ...x });
expect(idOnly(v({}))).toBe(true);
expect(idOnly(v({ label: '轻一点的她', registered_at: 1 }))).toBe(false);
expect(idOnly(v({ id: 'rain', label: 'rain', source: 'builtin' }))).toBe(false);
});
});